Merge branch 'main' into fix/4845-autohide-taskbar

This commit is contained in:
Michael Higgins authored and GitHub committed 2026-10-18 10:15:00 -04:00
commit 49e1ae898b
249 files changed
+14900 -1016

No files matched your search

+10
View File
@@ -193,6 +193,16 @@ jobs:
cargo nextest run --profile ci --workspace --locked --features heif "${targets[@]}"
shell: bash
# Every command against adversarial params must return Err, never panic or hang (AGENTS.md
# Rule 9). It is `#[ignore]`d (slow), so Test skips it. Same packages, features and targets as
# Test, so nothing rebuilds: `--test panic_hunt` would drop the other crates' dev-dependency
# features and recompile the whole graph. The `ci` profile terminates it if it hangs.
- name: Panic hunt
if: runner.os == 'Linux'
run: >-
cargo nextest run --profile ci --workspace --locked --features heif --lib --bins --tests
--run-ignored only -E 'binary_id(photocraft-engine::panic_hunt)'
- name: Doctests
if: matrix.part == 'all' || matrix.part == 'unit'
run: cargo test --workspace --locked --features heif --doc
+4 -2
View File
@@ -27,7 +27,7 @@ next to them.
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -38,11 +38,12 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -57,6 +58,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
Generated
+43 -30
View File
@@ -1222,6 +1222,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -4613,7 +4618,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"arboard",
"async-io",
@@ -4646,6 +4651,7 @@ dependencies = [
"rfd",
"ron",
"serde_json",
"tempfile",
"winit",
"winresource",
"winsafe",
@@ -4657,7 +4663,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4667,7 +4673,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4680,7 +4686,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"base64 0.22.1",
"cap-std",
@@ -4702,7 +4708,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4716,7 +4722,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"moxcms",
"rayon",
@@ -4724,7 +4730,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"exr",
"flate2",
@@ -4745,7 +4751,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4753,7 +4759,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4768,7 +4774,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4779,7 +4785,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"blake3",
"craft-print",
@@ -4795,6 +4801,7 @@ dependencies = [
"photocraft-format",
"photocraft-geom",
"photocraft-io",
"photocraft-ipp",
"photocraft-ops",
"photocraft-paint",
"photocraft-plugins",
@@ -4814,7 +4821,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4835,14 +4842,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"half",
"log",
@@ -4859,16 +4866,17 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"blake3",
"craft-docpath",
"crc32fast",
"flate2",
"photocraft-affinity",
@@ -4893,16 +4901,20 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ipp"
version = "0.10.0"
[[package]]
name = "photocraft-ops"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4913,7 +4925,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4928,7 +4940,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4939,7 +4951,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4949,7 +4961,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"image",
@@ -4964,7 +4976,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4976,7 +4988,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4987,7 +4999,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"parley",
@@ -5001,7 +5013,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"base64 0.22.1",
"craft-print",
@@ -5037,12 +5049,13 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"unicode-bidi",
"winsafe",
]
[[package]]
name = "photocraft-vector"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5053,7 +5066,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"eframe",
"egui",
@@ -5075,7 +5088,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"flate2",
"js-sys",
@@ -8662,7 +8675,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.9.0"
version = "0.10.0"
dependencies = [
"serde",
"serde_json",
+11 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.10.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,9 +36,11 @@ photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
photocraft-ipp = { path = "crates/ipp" }
# external
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "3d37da389a3618ab9b76109da11416e9ca4ea719" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
@@ -136,6 +138,14 @@ opt-level = "z"
[profile.wasm-release.package.naga]
opt-level = "z"
# The browser UI toolkit favors size to stay within the WebAssembly upload budget.
[profile.wasm-release.package.egui]
opt-level = "z"
[profile.wasm-release.package.epaint]
opt-level = "z"
[profile.wasm-release.package.eframe]
opt-level = "z"
# Pixel processing keeps its speed-oriented optimization level, with two exceptions.
# Colour-management transforms are built once and cached (`transform::cached`), so "z" costs
# little runtime. The browser app composites per frame on the GPU (`crates/gpu`, WebGPU/WebGL2),
+1
View File
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
+15
View File
@@ -275,6 +275,8 @@ flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-a
flatpak run ai.storyteller.photocraft
```
The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
@@ -300,6 +302,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Community packages
These installers are maintained by community members, not by the PhotoCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'media-gfx/photocraft-bin ~amd64' >> /etc/portage/package.accept_keywords/photocraft
emerge --ask media-gfx/photocraft-bin
```
## Documentation
Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/).
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-cli"
description = "Headless Photocraft: convert, inspect, run commands, batch, MCP server"
description = "Headless PhotoCraft: convert, inspect, run commands, batch, MCP server"
version.workspace = true
edition.workspace = true
license.workspace = true
+2 -2
View File
@@ -1,4 +1,4 @@
//! Headless Photocraft command line. `run` is the whole program, so it can be
//! Headless PhotoCraft command line. `run` is the whole program, so it can be
//! tested in-process as well as through the binary.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,7 +12,7 @@ use photocraft_io::ExportOptions;
use serde_json::{Value, json};
pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
photocraft-cli: headless PhotoCraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>]
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-web"
description = "Photocraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
description = "PhotoCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -34,7 +34,7 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "DomRect", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException", "Response", "IdbFactory", "IdbOpenDbRequest", "IdbDatabase", "IdbRequest", "IdbTransaction", "IdbTransactionMode", "IdbObjectStore", "IdbCursor", "IdbCursorWithValue", "RequestInit", "Navigator"] }
[dev-dependencies]
serde_json = { workspace = true }
+4 -4
View File
@@ -1,4 +1,4 @@
//! Photocraft in the browser.
//! PhotoCraft in the browser.
//!
//! Runs the same [`photocraft_ui_egui::PhotocraftApp`] as the desktop app through eframe's web
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
@@ -6,10 +6,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control server (browsers can't listen on sockets);
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
//! `Services::inbox`;
//! - file pickers return browser file contents asynchronously through `Services::file_dialog`;
//! this includes File → Open and commands such as Place Embedded;
//! - saving/exporting triggers a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
//! - dropped files are read asynchronously by `web::WebShell` and delivered through `Services::inbox`.
//!
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
+62 -12
View File
@@ -1,6 +1,8 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
@@ -79,11 +81,13 @@ pub fn start() {
Box::new(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let inbox: Inbox = Arc::default();
let place_inbox: Inbox = Arc::default();
let served: Served = Arc::default();
load_font_manifest(served.clone(), cc.egui_ctx.clone());
let (session, warnings) = crate::preset_bridge::session(presets.clone());
preset_warnings.extend(warnings);
let mut app = PhotocraftApp::new(session, services(inbox.clone()));
let drag = listen_drag(&pen_target);
let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));
if !preset_warnings.is_empty() {
photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);
}
@@ -97,7 +101,7 @@ pub fn start() {
}
let unsaved = Arc::new(AtomicBool::new(false));
guard_unload(unsaved.clone());
Ok(Box::new(WebShell { app, inbox, unsaved, served, database, presets }))
Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))
}),
)
.await;
@@ -140,6 +144,38 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
}
}
/// The pointer in a drag over the page, in CSS pixels relative to the viewport (`clientX`,
/// `clientY`): the last `dragover` or `drop` event's.
type DragPos = Rc<Cell<Option<(f32, f32)>>>;
/// Follows files dragged over the canvas. eframe's drop events carry no pointer position and the
/// page gets no pointer events during a drag, so this is where a drop lands: on the document's
/// canvas it places the files, elsewhere it opens them (#5749).
fn listen_drag(target: &web_sys::HtmlCanvasElement) -> DragPos {
use wasm_bindgen::closure::Closure;
let pos = DragPos::default();
for kind in ["dragover", "drop"] {
let pos = pos.clone();
// A DragEvent is a MouseEvent: only its client position is read.
let cb = Closure::<dyn FnMut(web_sys::MouseEvent)>::new(move |e: web_sys::MouseEvent| pos.set(Some((e.client_x() as f32, e.client_y() as f32))));
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
}
}
pos
}
/// `Services::cursor_pos` for the web: the last drag position in egui points, mapped as eframe
/// maps pointer events (relative to the canvas, divided by the zoom factor).
fn drag_pos_service(canvas: web_sys::HtmlCanvasElement, pos: DragPos) -> photocraft_ui_egui::CursorPosFn {
Box::new(move |ctx: &egui::Context| {
let (x, y) = pos.get()?;
let rect = canvas.get_bounding_client_rect();
let zoom = ctx.zoom_factor();
(zoom.is_finite() && zoom > 0.0).then(|| egui::pos2((x - rect.left() as f32) / zoom, (y - rect.top() as f32) / zoom))
})
}
/// Closing or reloading the tab while a document has unsaved changes asks first, as closing the
/// desktop window does: the browser shows its own "Leave site?" prompt (#1380). `unsaved` is
/// refreshed every frame by [`WebShell`].
@@ -219,10 +255,13 @@ async fn fetch_bytes(url: &str, integrity: Option<&str>) -> Result<Vec<u8>, Stri
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously,
/// so the app's own drop path can't handle them) and feed them through the inbox.
/// so the app's own drop path can't handle them) and feed them through the inboxes.
struct WebShell {
app: PhotocraftApp,
/// Dropped files to open as documents.
inbox: Inbox,
/// Dropped files to place on the active document's canvas.
place_inbox: Inbox,
/// Read by the `beforeunload` listener ([`guard_unload`]).
unsaved: Arc<AtomicBool>,
served: Served,
@@ -285,17 +324,26 @@ impl WebShell {
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
if !dropped.is_empty() {
let names: Vec<String> =
dropped.iter().map(|f| f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into())).collect();
// Decided now, where the drop landed: on the document's canvas the files are placed
// as layers (as on the desktop), elsewhere opened.
let places = self.app.dropped_on_canvas(ctx, &names);
let files: Vec<_> = dropped.into_iter().zip(names).zip(places).collect();
let (inbox, place_inbox) = (self.inbox.clone(), self.place_inbox.clone());
let ctx = ctx.clone();
// One after another, so they open or place in the order they were dropped.
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
for ((f, name), place) in files {
match f.bytes_async().await {
Ok(bytes) => {
let queue = if place { &place_inbox } else { &inbox };
queue.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
@@ -333,7 +381,7 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox) -> Services {
fn services(inbox: Inbox, place_inbox: Inbox, cursor_pos: photocraft_ui_egui::CursorPosFn) -> Services {
Services {
screen_pick: screen_color_service(),
import: Some(Box::new(|name: &str, bytes: &[u8], max_svg_group_depth: usize| {
@@ -384,6 +432,8 @@ fn services(inbox: Inbox) -> Services {
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: Some(inbox),
place_inbox: Some(place_inbox),
cursor_pos: Some(cursor_pos),
// Preferences live in the browser's localStorage.
load_prefs: Some(Box::new(|| local_storage()?.get_item(PREFS_KEY).ok().flatten())),
save_prefs: Some(Box::new(|text: &str| local_storage().ok_or("no localStorage")?.set_item(PREFS_KEY, text).map_err(|e| format!("{e:?}")))),
+3 -1
View File
@@ -4,7 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
description = "PhotoCraft: an open-source, native image editor"
build = "build.rs"
[features]
@@ -65,6 +65,8 @@ dispatch2 = { version = "=0.3.1", default-features = false, features = ["std"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
photocraft-cms = { workspace = true }
# The print portal's PDF: a private (0600) temporary file, removed when the dialog is done.
tempfile = "3"
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] }
# Clipboard images in types other than PNG on Wayland (`services.rs`, #2546): arboard asks for
# image/png only. Already in Cargo.lock (arboard's wayland-data-control); pinned exactly.
+670
View File
@@ -9,6 +9,9 @@
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
//!
//! One exception: on Windows the platform convention keeps crash-recovery autosaves out of the
//! roaming profile ([`recovery_dir`]).
use std::ffi::OsString;
use std::path::{Path, PathBuf};
@@ -60,6 +63,379 @@ pub fn config_dir() -> Option<PathBuf> {
current().dir.clone()
}
/// The crash-recovery folder's name.
const RECOVERY: &str = "Recovery";
/// Where crash-recovery autosaves live, with any left in the settings directory by an earlier
/// version moved there first ([`adopt_legacy_recovery`]). Call it once, at startup.
pub fn recovery_dir() -> Option<PathBuf> {
let data = current();
let dir = resolve_recovery(data, |k| std::env::var_os(k))?;
Some(match data.dir.as_ref().map(|d| d.join(RECOVERY)) {
Some(legacy) => adopt_legacy_recovery(&legacy, &dir),
None => dir,
})
}
/// The recovery folder for `data` (#5648). Autosaves hold whole documents and are rewritten every
/// few minutes, so on Windows the platform convention puts them in the machine-local
/// `%LOCALAPPDATA%\Photocraft\Recovery`: the roaming `%APPDATA%` may be synced at sign-in and
/// sign-out or capped by policy. They stay out of `%TEMP%`, which cleanup tools may empty before
/// a crashed document is recovered. The override and portable folders keep everything in one
/// place, and other platforms already keep these folders local.
pub fn resolve_recovery(data: &DataDir, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
if cfg!(windows)
&& data.mode == Mode::Platform
&& let Some(local) = env("LOCALAPPDATA").filter(|l| !l.is_empty())
{
return Some(PathBuf::from(local).join("Photocraft").join(RECOVERY));
}
data.dir.as_ref().map(|d| d.join(RECOVERY))
}
/// The lock file, beside the recovery folder, that keeps two PhotoCraft processes starting at
/// once (`--new-instance`, control launches) from moving the same autosaves.
const MIGRATION_LOCK: &str = "recovery-migration.lock";
/// How long a launch waits for another one's move before going ahead without moving anything.
const MIGRATION_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
/// Suffix of a copy staged for publishing ([`stage_copy`]): disposable, the original still exists.
const STAGED: &str = ".moving";
/// Suffix of an original set aside while it is copied to another volume ([`move_across`]): it
/// may be the only copy, so it is put back, never thrown away, if the move doesn't finish.
const HELD: &str = ".held";
/// Move autosaves from `legacy` (the recovery folder of versions before #5648) into `dir`, so a
/// crash just before an update is still offered for recovery. Returns the folder this process
/// uses for its recovery store. That is `legacy` only when `dir` doesn't exist and no process can
/// be moving autosaves (the move failed, or its lock can't be made), so nothing deletes what is
/// written there. A launch that finds another one moving uses `dir`, which the move never
/// replaces or removes. See [`adopt_locked`] for how they move.
pub fn adopt_legacy_recovery(legacy: &Path, dir: &Path) -> PathBuf {
adopt_legacy_recovery_waiting(legacy, dir, MIGRATION_WAIT)
}
/// [`adopt_legacy_recovery`], waiting up to `wait` for another process's move.
fn adopt_legacy_recovery_waiting(legacy: &Path, dir: &Path, wait: std::time::Duration) -> PathBuf {
let held_aside = || legacy.parent().is_some_and(|p| !side_entries(p, legacy, HELD).is_empty());
if legacy == dir || (!legacy.is_dir() && !held_aside()) {
return dir.to_path_buf();
}
// An empty old folder has nothing to move (`remove_dir` only removes an empty one).
if !held_aside() && std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
let unmoved = || if dir.exists() || !legacy.is_dir() { dir.to_path_buf() } else { legacy.to_path_buf() };
let Some(parent) = dir.parent() else { return unmoved() };
let lock =
std::fs::create_dir_all(parent).and_then(|()| std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(parent.join(MIGRATION_LOCK)));
let Ok(lock) = lock else {
// No lock file means no other process can be moving: the old folder is safe to use.
log::warn!("couldn't open the recovery move lock in {}; autosaves stay where they are", parent.display());
return unmoved();
};
let deadline = std::time::Instant::now() + wait;
loop {
match lock.try_lock() {
Ok(()) => break,
Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(50)),
Err(std::fs::TryLockError::WouldBlock) => {
// Another process is moving and may yet remove the old folder: never write there.
log::info!("another PhotoCraft is moving crash-recovery autosaves; using {}", dir.display());
return dir.to_path_buf();
}
Err(std::fs::TryLockError::Error(e)) => {
// Locking fails for every process here alike, so none can be moving.
log::warn!("couldn't lock {}: {e}; autosaves stay where they are", parent.join(MIGRATION_LOCK).display());
return unmoved();
}
}
}
// The lock file stays (removing it would race a process about to lock it); the OS releases
// the lock when `lock` is dropped or the process ends.
let used = adopt_locked(legacy, dir);
if used == legacy {
// This process writes its autosaves to the old folder: it keeps the lock for as long as it
// runs, so no other launch moves (and removes) the folder under it.
KEPT_LOCKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(lock);
}
used
}
/// Migration locks of the old folders this process uses (see [`adopt_legacy_recovery`]), kept
/// until it ends.
static KEPT_LOCKS: std::sync::Mutex<Vec<std::fs::File>> = std::sync::Mutex::new(Vec::new());
/// The move itself, under the migration lock. First, what a process that ended mid-move left is
/// tidied: originals it set aside are put back, copies it staged are removed. An empty `legacy`
/// is then just removed. Otherwise the whole folder moves if `dir` doesn't exist yet; when both
/// exist, each autosave (`<key>.pcraft` bundle and `<key>.json` sidecar, see
/// `photocraft_format::autosave`) moves as a whole, bundle first, unless `dir` already has either
/// part, and one that stays is left in `legacy` untouched. Every move is a [`move_path`].
fn adopt_locked(legacy: &Path, dir: &Path) -> PathBuf {
if let Some(parent) = legacy.parent() {
for held in side_entries(parent, legacy, HELD) {
put_back(&held, legacy);
}
}
// Autosave parts held aside inside the old folder by a per-autosave move.
let held_parts: Vec<(PathBuf, String)> = std::fs::read_dir(legacy)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| e.file_name().to_str().and_then(|n| side_of(n, HELD)).map(|orig| (e.path(), orig.to_owned())))
.collect();
for (held, original) in held_parts {
put_back(&held, &legacy.join(original));
}
for folder in [dir.parent(), Some(dir)].into_iter().flatten() {
remove_stale_staging(folder);
}
// Another process may have finished the move while this one waited.
if !legacy.is_dir() {
return dir.to_path_buf();
}
if std::fs::remove_dir(legacy).is_ok() {
return dir.to_path_buf();
}
if !dir.exists() {
// The common case: the whole folder moves at once.
return match move_path(legacy, dir) {
Ok(()) => {
log::info!("moved crash-recovery autosaves from {} to {}", legacy.display(), dir.display());
dir.to_path_buf()
}
Err(e) => {
log::warn!("couldn't move crash-recovery autosaves from {} to {} ({e}); using the old folder", legacy.display(), dir.display());
if legacy.is_dir() { legacy.to_path_buf() } else { dir.to_path_buf() }
}
};
}
move_entries(legacy, dir);
// Only an emptied folder goes; anything that couldn't move stays for a later launch.
let _ = std::fs::remove_dir(legacy);
dir.to_path_buf()
}
/// Move each autosave of `from` into `to` (both folders): bundle first, then sidecar, skipped if
/// `to` has either part. Temp files of an interrupted write (`photocraft_format::atomic`) go
/// along too, for the recovery store's startup sweep there.
fn move_entries(from: &Path, to: &Path) {
let Ok(entries) = std::fs::read_dir(from) else { return };
let names: Vec<String> = entries.flatten().filter_map(|e| e.file_name().to_str().map(str::to_owned)).collect();
let keys: std::collections::BTreeSet<&str> = names.iter().filter_map(|n| n.strip_suffix(".pcraft").or_else(|| n.strip_suffix(".json"))).collect();
for key in keys {
let parts = [format!("{key}.pcraft"), format!("{key}.json")];
if parts.iter().any(|p| std::fs::symlink_metadata(to.join(p)).is_ok()) {
continue;
}
// A bundle that moves without its sidecar is still listed (`list_recovery` reads a
// committed bundle's manifest); a sidecar must never move without its bundle.
for part in &parts {
let src = from.join(part);
if std::fs::symlink_metadata(&src).is_ok()
&& let Err(e) = move_path(&src, &to.join(part))
{
log::warn!("couldn't move {} to {}: {e}", src.display(), to.display());
break;
}
}
}
for name in names.iter().filter(|n| n.ends_with(".pcsave.tmp")) {
let _ = move_path(&from.join(name), &to.join(name));
}
}
/// How deep [`copy_tree`] follows folders: an autosave bundle is a few levels deep.
const MAX_COPY_DEPTH: u32 = 16;
fn already_exists(path: &Path) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", path.display()))
}
/// A path beside `of`, named `.<name>.<pid>-<n><suffix>`, that no other call uses.
fn side_path(of: &Path, suffix: &str) -> std::io::Result<PathBuf> {
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let parent = of.parent().ok_or_else(|| std::io::Error::other(format!("{} has no parent folder", of.display())))?;
let name = of.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Ok(parent.join(format!(".{name}.{}-{}{suffix}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))))
}
/// The name `side` was made from, if it is a [`side_path`] name with `suffix`.
fn side_of<'a>(side: &'a str, suffix: &str) -> Option<&'a str> {
let rest = side.strip_prefix('.')?.strip_suffix(suffix)?;
let (name, ids) = rest.rsplit_once('.')?;
let (pid, n) = ids.split_once('-')?;
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
(!name.is_empty() && digits(pid) && digits(n)).then_some(name)
}
/// The side paths with `suffix` in `folder` made from `of`.
fn side_entries(folder: &Path, of: &Path, suffix: &str) -> Vec<PathBuf> {
let Some(name) = of.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
std::fs::read_dir(folder)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_name().to_str().and_then(|n| side_of(n, suffix)) == Some(name))
.map(|e| e.path())
.collect()
}
/// Move the file or folder `from` to `to` without ever replacing anything at `to`. On one volume
/// a folder is renamed (Windows refuses an existing target; elsewhere only an empty folder could
/// be replaced) and a file is hard-linked, which fails on an existing target, and then unlinked
/// at `from`. Otherwise (another volume, e.g. a roaming profile redirected to a network share
/// while `%LOCALAPPDATA%` stays local) it is copied ([`move_across`]).
fn move_path(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::symlink_metadata(to).is_ok() {
return Err(already_exists(to));
}
let meta = std::fs::symlink_metadata(from)?;
let moved = if meta.is_dir() { std::fs::rename(from, to) } else { std::fs::hard_link(from, to).and_then(|()| std::fs::remove_file(from)) };
match moved {
Ok(()) => Ok(()),
Err(e) if matches!(e.kind(), std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::DirectoryNotEmpty) => Err(e),
Err(_) => move_across(from, to),
}
}
/// Move `from` to `to` by copying. `from` is first renamed aside on its own volume
/// (`.<name>.<pid>-<n>.held`, [`hold`]), so a process still writing to the old path (another
/// launch that couldn't take the lock, an older PhotoCraft) starts a new `from` there instead of
/// adding to what is copied, and nothing it writes is removed. The held original is copied to a
/// staged path ([`stage_copy`]), published without replacing anything ([`publish`]), and only
/// then removed. If the copy or publish fails, the held original is put back ([`put_back`]).
fn move_across(from: &Path, to: &Path) -> std::io::Result<()> {
let held = hold(from)?;
match stage_copy(&held, to).and_then(|staged| publish(&staged, to)) {
Ok(()) => {
let removed = if std::fs::symlink_metadata(&held)?.is_dir() { std::fs::remove_dir_all(&held) } else { std::fs::remove_file(&held) };
if let Err(e) = removed {
log::warn!("copied {} to {} but couldn't remove {}: {e}", from.display(), to.display(), held.display());
}
Ok(())
}
Err(e) => {
put_back(&held, from);
Err(e)
}
}
}
/// Rename `from` aside on its own volume (see [`move_across`]); returns where it is now.
fn hold(from: &Path) -> std::io::Result<PathBuf> {
let held = side_path(from, HELD)?;
std::fs::rename(from, &held)?;
Ok(held)
}
/// Return the held original `held` to `original`. If `original` has been made again meanwhile (a
/// writer that kept using the old path), the held folder's entries move into it without replacing
/// anything ([`move_entries`]); whatever can't move stays held, logged, and is tried again under
/// the next move's lock ([`adopt_locked`] puts back what a process that ended mid-move held).
/// Nothing held is ever removed with data in it.
fn put_back(held: &Path, original: &Path) {
if std::fs::symlink_metadata(original).is_err() && std::fs::rename(held, original).is_ok() {
return;
}
if held.is_dir() && original.is_dir() {
move_entries(held, original);
if std::fs::remove_dir(held).is_ok() {
return;
}
}
log::warn!("{} is kept aside: {} exists", held.display(), original.display());
}
/// Copy `from` to a staging path beside `to` that this call creates exclusively, so nothing
/// another process made is ever written to or cleaned up. On failure the staged copy is removed.
fn stage_copy(from: &Path, to: &Path) -> std::io::Result<PathBuf> {
let staged = side_path(to, STAGED)?;
let is_dir = std::fs::symlink_metadata(from)?.is_dir();
let copied = if is_dir { copy_tree(from, &staged, 0) } else { copy_file(from, &staged) };
if let Err(e) = copied {
// `copy_tree` and `copy_file` create `staged` exclusively, and everything under a
// fresh folder is new: `AlreadyExists` means `staged` was someone else's to keep.
if e.kind() != std::io::ErrorKind::AlreadyExists {
let _ = if is_dir { std::fs::remove_dir_all(&staged) } else { std::fs::remove_file(&staged) };
}
return Err(e);
}
Ok(staged)
}
/// Remove copies a process left staged in `folder` when it ended mid-move: disposable, since the
/// original is still in place or held aside. Called only under the migration lock, which every
/// staging happens under. Links are never followed.
fn remove_stale_staging(folder: &Path) {
let Ok(entries) = std::fs::read_dir(folder) else { return };
for entry in entries.flatten() {
if !entry.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some()) {
continue;
}
let removed = match entry.file_type() {
Ok(t) if t.is_dir() => std::fs::remove_dir_all(entry.path()),
Ok(t) if t.is_file() => std::fs::remove_file(entry.path()),
_ => continue,
};
if let Err(e) = removed {
log::warn!("couldn't remove the stale staged copy {}: {e}", entry.path().display());
}
}
}
/// Give the staged copy its final name `to` without replacing anything there (see [`move_path`]
/// for the folder and file cases). On failure the staged copy is removed and `to` is untouched.
fn publish(staged: &Path, to: &Path) -> std::io::Result<()> {
let is_dir = std::fs::symlink_metadata(staged)?.is_dir();
let published = if std::fs::symlink_metadata(to).is_ok() {
Err(already_exists(to))
} else if is_dir {
std::fs::rename(staged, to)
} else {
std::fs::hard_link(staged, to).and_then(|()| std::fs::remove_file(staged))
};
if published.is_err() {
let _ = if is_dir { std::fs::remove_dir_all(staged) } else { std::fs::remove_file(staged) };
}
published
}
/// Copy the folder `from` to the new folder `to` (created here; an existing `to` is an error):
/// plain files and folders only (a link or other special file is refused), at most
/// [`MAX_COPY_DEPTH`] levels.
fn copy_tree(from: &Path, to: &Path, depth: u32) -> std::io::Result<()> {
if depth > MAX_COPY_DEPTH {
return Err(std::io::Error::other(format!("{} is nested too deep", from.display())));
}
std::fs::create_dir(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target, depth.saturating_add(1))?;
} else {
copy_file(&entry.path(), &target)?;
}
}
Ok(())
}
/// Copy one plain file (a link or other special file is refused) to the new file `to` (an
/// existing `to` is an error), flushed to disk.
fn copy_file(from: &Path, to: &Path) -> std::io::Result<()> {
if !std::fs::symlink_metadata(from)?.file_type().is_file() {
return Err(std::io::Error::other(format!("{} is not a plain file", from.display())));
}
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
std::io::copy(&mut src, &mut dst)?;
dst.sync_all()
}
/// Resolve the data directory from the environment (`env`) and the executable's folder.
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
@@ -223,6 +599,300 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// #5648: on Windows, autosaves go to the machine-local `%LOCALAPPDATA%`, not the roaming
/// profile; an override or portable folder keeps them inside itself.
#[test]
fn autosaves_stay_out_of_the_roaming_profile() {
let root = temp("recovery-local");
let settings = root.join("appdata").join("Photocraft");
let local = root.join("local");
let env = env_of(&[("LOCALAPPDATA", &local)]);
let platform = DataDir { dir: Some(settings.clone()), mode: Mode::Platform, warning: None };
let expected = if cfg!(windows) { local.join("Photocraft").join("Recovery") } else { settings.join("Recovery") };
assert_eq!(resolve_recovery(&platform, &env), Some(expected));
for mode in [Mode::Override, Mode::Portable] {
let d = DataDir { dir: Some(settings.clone()), mode, warning: None };
assert_eq!(resolve_recovery(&d, &env), Some(settings.join("Recovery")), "{mode:?}");
}
// No (or an empty) LOCALAPPDATA: beside the settings, as before.
let empty = PathBuf::new();
assert_eq!(resolve_recovery(&platform, env_of(&[("LOCALAPPDATA", &empty)])), Some(settings.join("Recovery")));
assert_eq!(resolve_recovery(&platform, |_| None), Some(settings.join("Recovery")));
let nowhere = DataDir { dir: None, mode: Mode::Platform, warning: None };
assert_eq!(resolve_recovery(&nowhere, |_| None), None);
let _ = std::fs::remove_dir_all(&root);
}
/// An autosave bundle and its sidecar, as `format::Autosaver` writes them.
fn autosave(dir: &Path, key: &str) {
std::fs::create_dir_all(dir.join(format!("{key}.pcraft"))).unwrap();
std::fs::write(dir.join(format!("{key}.pcraft")).join("manifest.json"), key).unwrap();
std::fs::write(dir.join(format!("{key}.json")), key).unwrap();
}
#[test]
fn autosaves_from_the_old_folder_move_to_the_new_one() {
let root = temp("recovery-move");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Photocraft").join("Recovery");
autosave(&legacy, "a");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(!legacy.exists(), "the old folder is gone");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_merge_into_an_existing_folder_without_overwriting() {
let root = temp("recovery-merge");
let legacy = root.join("roaming").join("Recovery");
let dir = root.join("local").join("Recovery");
autosave(&legacy, "old");
autosave(&legacy, "both");
// A temp file of an interrupted write moves along, for the recovery store's sweep.
let temp_file = ".old.json.4294967294-0.pcsave.tmp";
std::fs::write(legacy.join(temp_file), b"partial").unwrap();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("both.json"), "newer").unwrap();
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir);
assert!(dir.join(temp_file).is_file() && !legacy.join(temp_file).exists());
assert!(dir.join("old.pcraft").is_dir() && dir.join("old.json").is_file(), "a whole autosave moves");
assert_eq!(std::fs::read_to_string(dir.join("both.json")).unwrap(), "newer", "never overwritten");
assert!(!dir.join("both.pcraft").exists(), "a clashing autosave isn't split");
assert!(legacy.join("both.pcraft").is_dir() && legacy.join("both.json").is_file(), "it stays behind whole");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn autosaves_that_cannot_move_keep_being_used_where_they_are() {
let root = temp("recovery-stuck");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
// A file where the new folder's parent should be: nothing can be created there.
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), legacy);
assert!(legacy.join("a.json").is_file(), "nothing lost");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn an_empty_old_folder_is_removed_even_where_it_cannot_move() {
let root = temp("recovery-empty");
let legacy = root.join("roaming").join("Recovery");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::write(root.join("blocked"), b"in the way").unwrap();
let dir = root.join("blocked").join("Recovery");
assert_eq!(adopt_legacy_recovery(&legacy, &dir), dir, "nothing to keep listed");
assert!(!legacy.exists(), "the empty old folder is gone");
let _ = std::fs::remove_dir_all(&root);
}
/// Where a rename can't cross volumes (a roaming profile on a network share), an autosave
/// is copied whole, nested folders included, and only then removed.
#[test]
fn a_move_across_volumes_copies_then_removes() {
let root = temp("recovery-copy");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
std::fs::create_dir_all(legacy.join("a.pcraft").join("tiles")).unwrap();
std::fs::write(legacy.join("a.pcraft").join("tiles").join("t0.zst"), b"tile").unwrap();
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
move_across(&legacy, &dir).unwrap();
assert!(!legacy.exists(), "the original is removed after the copy");
assert_eq!(side_leftovers(&root.join("roaming")), 0, "nothing is left held aside");
assert_eq!(std::fs::read_to_string(dir.join("a.pcraft").join("manifest.json")).unwrap(), "a");
assert_eq!(std::fs::read(dir.join("a.pcraft").join("tiles").join("t0.zst")).unwrap(), b"tile");
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a");
// One file on its own, as the per-autosave moves do.
move_across(&dir.join("a.json"), &root.join("a.json")).unwrap();
assert!(!dir.join("a.json").exists() && root.join("a.json").is_file());
assert_eq!(side_leftovers(&dir) + side_leftovers(&root), 0);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_failed_copy_puts_the_original_back_and_leaves_no_partial_copy() {
let root = temp("recovery-copy-fail");
let legacy = root.join("Recovery");
autosave(&legacy, "a");
// Deeper than any bundle: the copy gives up part way.
let mut deep = legacy.join("a.pcraft");
for _ in 0..=MAX_COPY_DEPTH {
deep = deep.join("d");
}
std::fs::create_dir_all(&deep).unwrap();
let dir = root.join("Local");
assert!(move_across(&legacy, &dir).is_err());
assert!(!dir.exists(), "nothing is published");
assert_eq!(side_leftovers(&root), 0, "no staged copy, nothing left held aside");
assert!(legacy.join("a.json").is_file() && deep.is_dir(), "the original is back in place");
// A target that already exists is never copied into or removed.
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("keep"), b"mine").unwrap();
assert!(move_across(&legacy.join("a.json"), &dir).is_err());
assert_eq!(std::fs::read(dir.join("keep")).unwrap(), b"mine");
assert!(legacy.join("a.json").is_file());
assert_eq!(side_leftovers(&root) + side_leftovers(&legacy), 0);
let _ = std::fs::remove_dir_all(&root);
}
/// Staged (`.moving`) and held (`.held`) side paths left in `dir`.
fn side_leftovers(dir: &Path) -> usize {
std::fs::read_dir(dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_str().is_some_and(|n| side_of(n, STAGED).is_some() || side_of(n, HELD).is_some()))
.count()
}
/// The target appears after the copy is staged (another process got there first): publishing
/// fails, that process's target keeps its contents, the staged copy is removed and the
/// original is left as it was. Folder and file alike.
#[test]
fn a_target_that_appears_while_copying_is_never_replaced_or_removed() {
let root = temp("recovery-race");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
// A whole folder.
let dir = local.join("Recovery");
let staged = stage_copy(&legacy, &dir).unwrap();
autosave(&dir, "theirs");
let e = publish(&staged, &dir).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(dir.join("theirs.json")).unwrap(), "theirs", "their folder is intact");
assert!(!dir.join("a.json").exists(), "nothing was copied into it");
assert!(!staged.exists() && side_leftovers(&local) == 0, "only the staged copy is removed");
assert!(legacy.join("a.json").is_file(), "the original is untouched");
// One file.
let to = dir.join("a.json");
let staged = stage_copy(&legacy.join("a.json"), &to).unwrap();
std::fs::write(&to, "theirs").unwrap();
assert_eq!(publish(&staged, &to).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_to_string(&to).unwrap(), "theirs");
assert!(!staged.exists() && legacy.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
/// A process holds the lock and is copying the old folder (held aside, copy staged) when a
/// second launch gives up waiting. The second launch must get the new folder, never the old
/// one, and what it writes there survives the first one finishing: the publish refuses the
/// now-existing folder and the original is put back, so both autosaves stay (#6412 review).
#[test]
fn a_launch_that_times_out_never_writes_where_a_move_removes() {
let root = temp("recovery-timeout");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
// First process: takes the lock, sets the original aside and stages its copy, then pauses.
let first = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(local.join(MIGRATION_LOCK)).unwrap();
first.lock().unwrap();
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
// Second launch: times out and writes a newer autosave where it was told to.
let chosen = adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO);
assert_eq!(chosen, dir, "a launch that couldn't get the lock never uses the old folder");
autosave(&chosen, "new");
// First process resumes: publish, and on failure put the original back (as `move_across`).
assert_eq!(publish(&staged, &dir).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
put_back(&held, &legacy);
drop(first);
assert_eq!(std::fs::read_to_string(dir.join("new.json")).unwrap(), "new", "the newer autosave survives");
assert_eq!(std::fs::read_to_string(legacy.join("old.json")).unwrap(), "old", "the old one is back in place");
assert_eq!(side_leftovers(&local) + side_leftovers(&root.join("roaming")), 0);
// The next launch merges them.
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("old.json").is_file() && dir.join("new.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
/// A writer that still uses the old path while it is being copied (an older PhotoCraft)
/// starts a new folder there; the move removes only the original it held aside.
#[test]
fn a_write_to_the_old_path_during_a_copy_is_not_removed() {
let root = temp("recovery-old-writer");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "old");
let local = root.join("local");
std::fs::create_dir_all(&local).unwrap();
let dir = local.join("Recovery");
let held = hold(&legacy).unwrap();
let staged = stage_copy(&held, &dir).unwrap();
autosave(&legacy, "new");
publish(&staged, &dir).unwrap();
std::fs::remove_dir_all(&held).unwrap();
assert_eq!(std::fs::read_to_string(dir.join("old.json")).unwrap(), "old");
assert_eq!(std::fs::read_to_string(legacy.join("new.json")).unwrap(), "new", "the new write is untouched");
let _ = std::fs::remove_dir_all(&root);
}
/// What a process killed mid-move left is tidied at the next move: a held original is put
/// back (it may be the only copy) and moved, a staged copy is removed; other dot-files and
/// look-alike names are not touched.
#[test]
fn a_killed_move_is_tidied_next_time() {
let root = temp("recovery-stale");
let roaming = root.join("roaming");
let legacy = roaming.join("Recovery");
autosave(&legacy, "a");
let held = hold(&legacy).unwrap();
assert!(!legacy.exists() && held.is_dir());
let local = root.join("local");
let dir = local.join("Recovery");
let stale = local.join(".Recovery.4294967294-3.moving");
autosave(&stale, "half");
let kept = [local.join(".Recovery.moving"), local.join(".keep.1-x.moving"), local.join("notes.txt")];
for k in &kept {
std::fs::write(k, b"x").unwrap();
}
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert_eq!(std::fs::read_to_string(dir.join("a.json")).unwrap(), "a", "the held original was put back and moved");
assert!(!stale.exists(), "the stale staged copy is gone");
assert_eq!(side_leftovers(&roaming), 0);
assert!(kept.iter().all(|k| k.is_file()), "only side names are touched");
assert_eq!(side_of(".a.json.12-0.moving", STAGED), Some("a.json"));
assert!(side_of("a.json.12-0.moving", STAGED).is_none() && side_of(".a.json.-0.moving", STAGED).is_none());
assert!(side_of(".a.json.12-0.held", STAGED).is_none() && side_of(".a.json.12-0.held", HELD) == Some("a.json"));
let _ = std::fs::remove_dir_all(&root);
}
/// While another process holds the move lock, a launch moves nothing and uses the new folder;
/// once the lock is released, the move happens.
#[test]
fn a_move_waits_for_another_process_holding_the_lock() {
let root = temp("recovery-lock");
let legacy = root.join("roaming").join("Recovery");
autosave(&legacy, "a");
let dir = root.join("local").join("Recovery");
std::fs::create_dir_all(root.join("local")).unwrap();
let held = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(root.join("local").join(MIGRATION_LOCK)).unwrap();
held.lock().unwrap();
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(legacy.join("a.json").is_file() && !dir.exists(), "nothing moved under another's lock");
drop(held);
assert_eq!(adopt_legacy_recovery_waiting(&legacy, &dir, std::time::Duration::ZERO), dir);
assert!(dir.join("a.json").is_file() && !legacy.exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn no_old_folder_or_the_same_folder_changes_nothing() {
let root = temp("recovery-noop");
let dir = root.join("Recovery");
assert_eq!(adopt_legacy_recovery(&root.join("missing"), &dir), dir);
assert!(!dir.exists(), "nothing is created up front");
autosave(&dir, "a");
assert_eq!(adopt_legacy_recovery(&dir, &dir), dir);
assert!(dir.join("a.json").is_file());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
+115 -14
View File
@@ -70,25 +70,61 @@ pub fn start() {
/// Whether the portal's `FileChooser` interface is on the session bus: `Some(false)` only when the
/// bus or the interface is definitely absent, `None` for anything inconclusive (a slow reply).
///
/// Asks the portal object to introspect itself rather than reading the interface's `version`
/// property: xdg-desktop-portal exports `FileChooser` only when a backend offers it, and without
/// one a property read fails with `InvalidArgs` ("No such interface"), an error the old check
/// didn't count as absent (#5739). The interface list answers the question directly.
#[cfg(target_os = "linux")]
fn file_chooser_portal() -> Option<bool> {
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build()) else {
return Some(false);
};
let proxy = zbus::blocking::proxy::Builder::<zbus::blocking::Proxy<'_>>::new(&conn)
.destination("org.freedesktop.portal.Desktop")
.and_then(|b| b.path("/org/freedesktop/portal/desktop"))
.and_then(|b| b.interface("org.freedesktop.portal.FileChooser"))
.and_then(|b| b.cache_properties(zbus::proxy::CacheProperties::No).build())
.ok()?;
match proxy.get_property::<u32>("version") {
Ok(_) => Some(true),
Err(zbus::Error::MethodError(name, _, _)) => {
let name = name.as_str();
let absent = ["ServiceUnknown", "UnknownObject", "UnknownInterface", "UnknownProperty"].iter().any(|n| name.ends_with(n));
absent.then_some(false)
}
Err(_) => None,
let reply = conn.call_method(
Some("org.freedesktop.portal.Desktop"),
"/org/freedesktop/portal/desktop",
Some("org.freedesktop.DBus.Introspectable"),
"Introspect",
&(),
);
match reply {
Ok(message) => match message.body().deserialize::<String>() {
Ok(xml) => Some(interface_listed(&xml)),
Err(_) => None,
},
Err(e) => classify_error(&e),
}
}
/// Whether introspection XML lists the portal's `FileChooser` interface (not a longer name such as
/// `FileChooser2`).
#[cfg(target_os = "linux")]
fn interface_listed(xml: &str) -> bool {
["\"", "'"].iter().any(|quote| xml.contains(&format!("<interface name={quote}org.freedesktop.portal.FileChooser{quote}")))
}
/// What a failed call says about the interface: `Some(false)` when the D-Bus error means the
/// service, object or interface isn't there, `None` for anything else (a timeout, a dropped
/// connection, an unknown error), which counts as present.
#[cfg(target_os = "linux")]
fn classify_error(e: &zbus::Error) -> Option<bool> {
use zbus::fdo::Error as Fdo;
// Names a bus daemon or portal answers with when the thing asked about is absent.
const ABSENT: [&str; 7] = ["ServiceUnknown", "NameHasNoOwner", "UnknownObject", "UnknownInterface", "UnknownMethod", "UnknownProperty", "InvalidArgs"];
match e {
zbus::Error::FDO(fdo) => matches!(
**fdo,
Fdo::ServiceUnknown(_)
| Fdo::NameHasNoOwner(_)
| Fdo::UnknownObject(_)
| Fdo::UnknownInterface(_)
| Fdo::UnknownMethod(_)
| Fdo::UnknownProperty(_)
| Fdo::InvalidArgs(_)
)
.then_some(false),
zbus::Error::MethodError(name, _, _) => ABSENT.iter().any(|n| name.as_str().ends_with(n)).then_some(false),
_ => None,
}
}
@@ -126,4 +162,69 @@ mod tests {
assert!(!on_path("kdialog", &path));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(target_os = "linux")]
mod portal {
use super::super::*;
const WITH_FILE_CHOOSER: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN" "http://www.freedesktop.org/standards/dbus/introspect.dtd">
<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"><property name="version" type="u" access="read"/></interface>
<interface name="org.freedesktop.portal.FileChooser">
<method name="OpenFile"/>
</interface>
</node>"#;
const WITHOUT_FILE_CHOOSER: &str = r#"<node>
<interface name="org.freedesktop.DBus.Peer"/>
<interface name="org.freedesktop.portal.Settings"/>
</node>"#;
fn method_error(name: &'static str) -> zbus::Error {
let call = zbus::message::Message::method_call("/a", "M").unwrap().build(&()).unwrap();
let reply = zbus::message::Message::error(&call.header(), name).unwrap().build(&"detail").unwrap();
zbus::Error::MethodError(name.try_into().unwrap(), Some("detail".into()), reply)
}
#[test]
fn the_interface_list_says_whether_a_file_chooser_is_offered() {
assert!(interface_listed(WITH_FILE_CHOOSER));
assert!(interface_listed("<interface name='org.freedesktop.portal.FileChooser'>"), "single quotes");
assert!(!interface_listed(WITHOUT_FILE_CHOOSER), "portal without a backend");
assert!(!interface_listed(r#"<interface name="org.freedesktop.portal.FileChooser2">"#), "longer name");
assert!(!interface_listed(""));
}
#[test]
fn dbus_errors_for_a_missing_interface_mean_absent() {
// zbus's fdo proxies hand D-Bus errors back as `Error::FDO`, a plain method call as
// `MethodError`; both forms count. The portal answers `InvalidArgs` ("No such interface")
// when no backend offers the interface (#5739).
for e in [
zbus::fdo::Error::InvalidArgs("No such interface".into()),
zbus::fdo::Error::UnknownInterface("x".into()),
zbus::fdo::Error::ServiceUnknown("x".into()),
zbus::fdo::Error::NameHasNoOwner("x".into()),
zbus::fdo::Error::UnknownObject("x".into()),
] {
let desc = format!("{e:?}");
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(e))), Some(false), "{desc}");
}
}
#[test]
fn method_error_names_still_count_as_absent() {
for name in ["org.freedesktop.DBus.Error.ServiceUnknown", "org.freedesktop.DBus.Error.InvalidArgs", "org.freedesktop.DBus.Error.UnknownMethod"] {
assert_eq!(classify_error(&method_error(name)), Some(false), "{name}");
}
assert_eq!(classify_error(&method_error("org.freedesktop.DBus.Error.AccessDenied")), None);
}
#[test]
fn inconclusive_errors_are_not_absent() {
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::NoReply("slow".into())))), None);
assert_eq!(classify_error(&zbus::Error::FDO(Box::new(zbus::fdo::Error::Failed("x".into())))), None);
assert_eq!(classify_error(&zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::other("gone")))), None);
}
}
}
+174 -12
View File
@@ -4,7 +4,9 @@
//! lost device, the crash guard's panic report) vanished. A launch from a desktop menu has no
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/photocraft.log`
//! (see `app_dirs`). Each start moves the previous log to `photocraft.1.log` (and that one to
//! `.2`), so the log of a run that crashed survives the next launch.
//! `.2`), so the log of a run that crashed survives the next launch. A start while another
//! instance is still running leaves that instance's log alone and writes `photocraft-<pid>.log`
//! (see [`rotate`]).
//!
//! Levels: `info` for PhotoCraft's own crates, `warn` for everything else (wgpu and naga are
//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`,
@@ -15,7 +17,7 @@
//! written once the file is attached. Writing never panics: a file that can't be created or
//! written leaves standard error as the only sink.
use std::fs::File;
use std::fs::{File, OpenOptions, TryLockError};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
@@ -116,10 +118,76 @@ fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("photocraft.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// Held by the instance that writes [`LOG_FILE`], from its start until it exits. A separate lock
/// file rather than a lock on the log itself: Windows locks are mandatory, and a locked log could
/// not be opened for a bug report while the app runs.
pub const OWNER_LOCK: &str = "photocraft.lock";
/// Held while one start rotates and prunes the logs, so two starts at once can't interleave.
pub const ROTATE_LOCK: &str = "rotate.lock";
/// The log of an instance started while another one was writing [`LOG_FILE`].
fn instance_log(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.log"))
}
/// The lock file that marks [`instance_log`] as in use.
fn instance_lock(dir: &Path, pid: u32) -> PathBuf {
dir.join(format!("photocraft-{pid}.lock"))
}
/// The process id in an [`instance_log`] file name.
fn instance_pid(name: &str) -> Option<u32> {
let digits = name.strip_prefix("photocraft-")?.strip_suffix(".log")?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
fn open_lock(path: &Path) -> std::io::Result<File> {
OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)
}
/// Take `file`'s lock without waiting: `Ok(true)` holds it, `Ok(false)` when another process holds it.
fn try_hold(file: &File) -> std::io::Result<bool> {
match file.try_lock() {
Ok(()) => Ok(true),
Err(TryLockError::WouldBlock) => Ok(false),
Err(TryLockError::Error(e)) => Err(e),
}
}
/// Open a fresh, empty log file in `dir` (created if missing).
///
/// Normally the previous logs shift up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest
/// dropped). While another instance is still writing [`LOG_FILE`] (it holds [`OWNER_LOCK`]), the
/// chain is left alone and this start writes `photocraft-<pid>.log` instead: rotating would move
/// that instance's live log away and, a few launches later, delete it (#6432). Such side-by-side
/// logs are pruned to the newest [`KEEP`] once their instances have exited. On a file system
/// without locks every start rotates, as before.
pub fn rotate(dir: &Path) -> std::io::Result<LogFile> {
std::fs::create_dir_all(dir)?;
// Waits only for another start's few file operations; without locking support, go on.
let guard = open_lock(&dir.join(ROTATE_LOCK)).ok().filter(|g| g.lock().is_ok());
let log = rotate_locked(dir);
drop(guard);
log
}
fn rotate_locked(dir: &Path) -> std::io::Result<LogFile> {
prune_instance_logs(dir);
let owner = open_lock(&dir.join(OWNER_LOCK))?;
let state = try_hold(&owner);
if let Ok(false) = state {
let pid = std::process::id();
let lock = open_lock(&instance_lock(dir, pid))?;
let held = try_hold(&lock).unwrap_or(false);
let path = instance_log(dir, pid);
let file = File::create(&path)?;
return Ok(LogFile { path, file, owner: held.then_some(lock) });
}
// This start holds the owner lock (or the file system has no locks).
let held = state.unwrap_or(false);
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
@@ -128,7 +196,40 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
Ok(LogFile { path, file, owner: held.then_some(owner) })
}
/// Remove the side-by-side logs of instances that have exited, all but the newest [`KEEP`].
/// A log whose lock file is held belongs to a running instance and is never touched. Failures
/// are ignored: pruning only saves disk space.
fn prune_instance_logs(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut finished: Vec<(SystemTime, u32)> = Vec::new();
for entry in entries.flatten() {
let Some(pid) = entry.file_name().to_str().and_then(instance_pid) else { continue };
// A missing lock file means nobody holds it; an unknown lock state counts as running.
let running = match OpenOptions::new().read(true).write(true).open(instance_lock(dir, pid)) {
Ok(lock) => !try_hold(&lock).unwrap_or(false),
Err(_) => false,
};
if !running {
let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
finished.push((modified, pid));
}
}
finished.sort_unstable_by(|a, b| b.cmp(a));
for &(_, pid) in finished.iter().skip(KEEP) {
let _ = std::fs::remove_file(instance_log(dir, pid));
let _ = std::fs::remove_file(instance_lock(dir, pid));
}
}
/// A log file opened by [`rotate`].
pub struct LogFile {
pub path: PathBuf,
pub file: File,
/// The held lock that marks `path` as in use; `None` where locking isn't supported.
owner: Option<File>,
}
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
@@ -205,17 +306,20 @@ pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
/// The lock that marks the attached log file as in use (see [`rotate`]); held until exit.
owner: Mutex<Option<File>>,
}
impl AppLogger {
pub fn new(filter: Filter, stderr: bool) -> AppLogger {
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)) }
AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), owner: Mutex::new(None) }
}
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let LogFile { path, file, owner } = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
*self.owner.lock().unwrap_or_else(PoisonError::into_inner) = owner;
Ok(path)
}
}
@@ -318,7 +422,7 @@ mod tests {
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
let LogFile { path, file: _file, owner: _owner } = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(std::fs::read_to_string(&path).expect("read"), "");
std::fs::write(&path, format!("run {run}")).expect("write");
@@ -330,6 +434,64 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// #6432: a second launch moved the running instance's live log to `.1.log`, a third to
/// `.2.log`, and a fourth deleted it.
#[test]
fn a_second_start_leaves_the_running_instance_log_alone() {
let dir = temp_dir("second");
let mut first = rotate(&dir).expect("first start");
assert_eq!(first.path, dir.join(LOG_FILE));
first.file.write_all(b"first, before\n").expect("write");
for _ in 0..KEEP + 1 {
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
assert_eq!(read(&second.path), "");
}
first.file.write_all(b"first, after\n").expect("write");
assert_eq!(read(&dir.join(LOG_FILE)), "first, before\nfirst, after\n");
assert!(!dir.join("photocraft.1.log").exists(), "the running instance's log was rotated");
// Once the first instance is gone, the next start rotates as before.
drop(first);
let third = rotate(&dir).expect("third start");
assert_eq!(third.path, dir.join(LOG_FILE));
assert_eq!(read(&dir.join("photocraft.1.log")), "first, before\nfirst, after\n");
drop(third);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn instance_logs_are_pruned_only_when_no_one_holds_them() {
let dir = temp_dir("prune");
let first = rotate(&dir).expect("first start");
// Earlier side-by-side runs that have exited, oldest first; the oldest has no lock file
// (as if its lock file had been removed), the others an unlocked one.
let now = SystemTime::now();
for (age, pid) in [(400u64, 1u32), (300, 2), (200, 3), (100, 4)] {
let path = instance_log(&dir, pid);
std::fs::write(&path, format!("run {pid}")).expect("write");
File::options().write(true).open(&path).expect("open").set_modified(now - Duration::from_secs(age)).expect("mtime");
if pid != 1 {
std::fs::write(instance_lock(&dir, pid), "").expect("lock file");
}
}
// One older still running: its log is never removed, however old.
let live = instance_log(&dir, 5);
std::fs::write(&live, "live").expect("write");
File::options().write(true).open(&live).expect("open").set_modified(now - Duration::from_secs(900)).expect("mtime");
let held = File::create(instance_lock(&dir, 5)).expect("lock file");
held.try_lock().expect("hold");
let second = rotate(&dir).expect("second start");
assert_eq!(second.path, instance_log(&dir, std::process::id()));
// The newest KEEP finished ones stay, the rest go with their lock files.
assert!(instance_log(&dir, 4).exists() && instance_log(&dir, 3).exists());
assert!(!instance_log(&dir, 2).exists() && !instance_log(&dir, 1).exists());
assert!(!instance_lock(&dir, 2).exists());
assert_eq!(read(&live), "live");
drop((first, second, held));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
@@ -355,7 +517,7 @@ mod tests {
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
@@ -368,7 +530,7 @@ mod tests {
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
@@ -384,7 +546,7 @@ mod tests {
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let LogFile { path, file, .. } = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
+97 -20
View File
@@ -1,9 +1,14 @@
//! Photocraft desktop app.
//! PhotoCraft desktop app.
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [--safe-gpu] [--in-window-menus] [--present-mode <mode>]
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [files…]`
//! [--frame-latency <n>] [--stroke-trace <stderr|file.csv>] [--new-instance] [--] [files…]`
//! (`photocraft --help` prints [`USAGE`]; `--version` prints the version).
//!
//! Any other argument starting with `-` is a usage error (exit status 2, issue #6431), so a typo
//! such as `--safegpu` doesn't open the window and then fail to open the flag as a file. A file
//! whose name starts with `-` goes after `--`.
//!
//! On Windows and Linux a launch hands its files to the PhotoCraft that is already running (new
//! tabs in its window) and exits; `--new-instance` starts a separate copy instead, as does any
@@ -58,6 +63,9 @@ mod photoshop_settings;
#[cfg(target_os = "linux")]
mod portal_save;
mod presentation;
// The print portal needs the session bus (zbus, Linux only); its pure helpers are tested everywhere.
#[cfg(any(target_os = "linux", test))]
mod print_portal;
mod screen_color;
mod services;
// macOS gets one instance from LaunchServices (open-documents Apple events).
@@ -149,21 +157,63 @@ fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
/// Process exit status for the collected usage errors (a bad `--control` /
/// `PHOTOCRAFT_CONTROL_PORT`, an unknown option): `None` when there are none, else 2 (a
/// command-line usage error), never 0.
fn usage_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
/// `photocraft --help`. Keep in step with the options `main` accepts and the module docs.
const USAGE: &str = "\
Usage: photocraft [options] [--] [files…]
Opens PhotoCraft with the given files.
Options:
-h, --help Print this help and exit
--version Print the version, commit and build date and exit
--safe-gpu Start with the CPU renderer for this launch (after a graphics driver crash)
--in-window-menus macOS: keep the menus inside the window instead of the menu bar
--new-instance Windows and Linux: start a separate PhotoCraft instead of opening
the files in the one already running
--present-mode <mode> How frames reach the display: fifo, autovsync or novsync
--frame-latency <n> Frames queued for the display: 1 to 3
--stroke-trace <stderr|file.csv>
Time each brush stroke's frames
--control <port> Start the localhost JSON-lines control server on this port
--control-token <64-hex> Control server token (default: a new random one, printed)
--control-token-file <path> Read the control server token from this file, or create it
with a new one
--automation-read-root <dir> Directory the control server may read files from
--automation-write-root <dir>
Directory the control server may write files to
-- Treat every later argument as a file, even one starting with `-`
A bad --present-mode, --frame-latency or --stroke-trace value is reported and ignored.
Environment: PHOTOCRAFT_CONTROL_PORT, PHOTOCRAFT_CONTROL_TOKEN, PHOTOCRAFT_CONTROL_TOKEN_FILE,
PHOTOCRAFT_AUTOMATION_READ_ROOT, PHOTOCRAFT_AUTOMATION_WRITE_ROOT, PHOTOCRAFT_PRESENT_MODE,
PHOTOCRAFT_FRAME_LATENCY, PHOTOCRAFT_STROKE_TRACE, PHOTOCRAFT_IN_WINDOW_MENUS, PHOTOCRAFT_NEW_INSTANCE,
PHOTOCRAFT_CONFIG_DIR.
";
/// Print to standard output for `--help` / `--version`. Unlike `println!`, never panics when
/// stdout is closed or a pipe (`photocraft --help | head -1`).
fn print_stdout(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
let _ = out.write_all(text.as_bytes()).and_then(|()| out.flush());
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
use super::{parse_control_port, usage_exit_code};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
assert_eq!(usage_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
assert_eq!(usage_exit_code(&errors), Some(2));
}
#[test]
@@ -191,11 +241,11 @@ fn main() -> eframe::Result {
// The system font scan can take seconds (minutes on some Macs): run it off the UI thread.
photocraft_ui_egui::type_tool::warm_fonts();
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
let mut usage_errors: Vec<String> = Vec::new();
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
Err(error) => usage_errors.push(error),
}
}
let mut control_token = None;
@@ -214,18 +264,32 @@ fn main() -> eframe::Result {
let (mut present_mode_arg, mut frame_latency_arg, mut stroke_trace_arg): (Option<String>, Option<String>, Option<String>) = (None, None, None);
let mut in_window_menus = std::env::var_os("PHOTOCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut args = std::env::args_os().skip(1);
// After `--`, every argument is a file, even one that starts with `-`.
let mut only_files = false;
while let Some(a) = args.next() {
if only_files {
match a.into_string() {
Ok(s) => files.push(s),
Err(raw) => unreadable_paths.push(raw.to_string_lossy().into_owned()),
}
continue;
}
match a.to_str() {
Some("--") => only_files = true,
Some("--help" | "-h") => {
print_stdout(USAGE);
return Ok(());
}
Some("--control") => match args.next() {
Some(value) => match parse_control_port(&value.to_string_lossy(), "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
Err(error) => usage_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
None => usage_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
Some("--control-token") => match args.next().map(std::ffi::OsString::into_string) {
Some(Ok(token)) => control_token = Some(token),
Some(Err(raw)) => control_arg_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
Some(Err(raw)) => usage_errors.push(format!("--control-token: value is not valid Unicode (`{}`)", raw.to_string_lossy())),
None => control_token = None,
},
Some("--control-token-file") => control_token_file = args.next().map(std::path::PathBuf::from),
@@ -264,23 +328,28 @@ fn main() -> eframe::Result {
}
Some("--new-instance") => new_instance = true,
Some("--version") => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
print_stdout(&format!("photocraft {}\n", photocraft_engine::build_info::long_version()));
return Ok(());
}
// Old macOS passes a process serial number when launched from Finder.
Some(s) if s.starts_with("-psn_") => {}
// A typo such as `--safegpu`, or `--help` spelt another way, must not open the window
// and then fail to open the flag as a file (#6431).
Some(s) if s.starts_with('-') => usage_errors.push(format!("unknown option `{s}` (put `--` before a file name that starts with `-`)")),
Some(s) => files.push(s.to_owned()),
None => unreadable_paths.push(a.to_string_lossy().into_owned()),
}
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
// A malformed control port must not silently drop the control server (issue #701), and an
// unknown option must not open the window (#6431): name the bad argument and fail the launch,
// like `photocraft-cli` does for the same typos. Exit status 2 is the usual command-line usage
// error, so a launcher sees the failure.
if let Some(code) = usage_exit_code(&usage_errors) {
for error in &usage_errors {
eprintln!("photocraft: {error}");
}
eprintln!("Run `photocraft --help` for usage.");
std::process::exit(code);
}
@@ -499,7 +568,15 @@ fn main() -> eframe::Result {
services::xwayland_command(var("FLATPAK_ID").as_deref(), var("APPIMAGE").as_deref(), std::env::var_os("DISPLAY").is_some());
}
}
let mut app = PhotocraftApp::new(Session::new(), services);
#[cfg(target_os = "linux")]
let session = {
let mut session = Session::new();
session.print_spooler = print_portal::spooler();
session
};
#[cfg(not(target_os = "linux"))]
let session = Session::new();
let mut app = PhotocraftApp::new(session, services);
app.min_frame_ms = uncapped.then_some(1000.0 / 240.0);
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = custom_titlebar;
+239
View File
@@ -0,0 +1,239 @@
//! Printing through the XDG Print portal (`org.freedesktop.portal.Print`): the system print
//! dialog, which a sandboxed app can reach without a CUPS socket. The engine prints over IPP
//! when it can reach a print server (`photocraft-ipp`); this is its fallback when it cannot, and
//! the only route with `PHOTOCRAFT_PRINT_PORTAL=1` (`0` disables it). `PreparePrint` shows the
//! dialog seeded with the job's copies and paper; `Print` hands it the PDF. Both answer through a
//! `Request` object's `Response` signal, which this module waits for on its own thread so the
//! window keeps painting while the dialog is open; the outcome goes to the log.
//! https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Print.html
#[cfg(target_os = "linux")]
use photocraft_engine::print_cmds::{PrintJob, Spooler};
/// The spooler to install on the session, unless `PHOTOCRAFT_PRINT_PORTAL=0`. The portal itself
/// is looked up per job, so a missing one costs nothing at start-up.
#[cfg(target_os = "linux")]
pub fn spooler() -> Option<Spooler> {
let setting = std::env::var("PHOTOCRAFT_PRINT_PORTAL").ok();
if setting.as_deref() == Some("0") {
return None;
}
Some(Spooler { name: "portal", preferred: setting.as_deref() == Some("1"), run: std::sync::Arc::new(print) })
}
/// Opens the system print dialog for the job and returns as soon as it is up.
#[cfg(target_os = "linux")]
fn print(job: &PrintJob) -> Result<String, String> {
let conn = zbus::blocking::connection::Builder::session()
.and_then(|b| b.method_timeout(std::time::Duration::from_secs(5)).build())
.map_err(|e| format!("no session bus for the print portal ({e}); print to PDF instead"))?;
let proxy = portal(&conn)?;
let version: u32 = proxy.get_property("version").map_err(|e| format!("no print portal on this desktop ({e}); print to PDF instead"))?;
// The document goes to a private temporary file (0600, created atomically by `tempfile`),
// not a guessable name in a shared /tmp. The guard owns the file: it lives on the portal
// thread until the dialog is done, and a failure to start that thread drops it here.
let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(|e| format!("cannot create the print file: {e}"))?;
std::io::Write::write_all(&mut file, &job.pdf).map_err(|e| format!("cannot write the print file: {e}"))?;
let (title, settings, page_setup) = (job.title.clone(), settings(job), page_setup(job));
std::thread::Builder::new()
.name("print-portal".into())
.spawn(move || {
let outcome = dialog(&conn, &title, &settings, &page_setup, file.path());
drop(file);
match outcome {
Ok(what) => log::info!("print portal (v{version}): {what}"),
Err(e) => log::warn!("print portal (v{version}): {e}"),
}
})
.map_err(|e| format!("cannot start the print thread: {e}"))?;
Ok("the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn portal(conn: &zbus::blocking::Connection) -> Result<zbus::blocking::Proxy<'static>, String> {
zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Print")
.map_err(|e| e.to_string())
}
/// `PreparePrint` then `Print`, each waited for on its request's `Response` signal.
#[cfg(target_os = "linux")]
fn dialog(
conn: &zbus::blocking::Connection,
title: &str,
settings: &[(&'static str, Seed)],
page_setup: &[(&'static str, Seed)],
pdf: &std::path::Path,
) -> Result<String, String> {
use std::collections::HashMap;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
type Dict<'a> = HashMap<&'a str, Value<'a>>;
fn as_dict(pairs: &[(&'static str, Seed)]) -> Dict<'static> {
pairs
.iter()
.map(|(k, v)| {
let value = match v {
Seed::Text(t) => Value::from(t.clone()),
Seed::Mm(mm) => Value::from(*mm),
};
(*k, value)
})
.collect()
}
let proxy = portal(conn)?;
let sender = conn.unique_name().map(|n| n.as_str().to_string()).ok_or("the bus connection has no name")?;
let wait = |token: &str| -> Result<zbus::blocking::proxy::SignalIterator<'static>, String> {
let path = request_path(&sender, token);
let request = zbus::blocking::Proxy::new(conn, "org.freedesktop.portal.Desktop", path, "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
request.receive_signal("Response").map_err(|e| e.to_string())
};
let response = |mut signals: zbus::blocking::proxy::SignalIterator<'static>| -> Result<HashMap<String, OwnedValue>, String> {
let message = signals.next().ok_or("the print portal went away")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = message.body().deserialize().map_err(|e| format!("odd portal reply: {e}"))?;
match code {
0 => Ok(results),
1 => Err("cancelled in the print dialog".into()),
_ => Err("the print dialog failed".into()),
}
};
let token = format!("photocraft_prepare_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
let _: OwnedObjectPath =
proxy.call("PreparePrint", &("", title, as_dict(settings), as_dict(page_setup), options)).map_err(|e| format!("PreparePrint failed: {e}"))?;
let results = response(signals)?;
let prepared = results.get("token").and_then(|v| u32::try_from(v.clone()).ok()).ok_or("the print dialog gave no token")?;
let file = std::fs::File::open(pdf).map_err(|e| format!("cannot reopen the print file: {e}"))?;
let token = format!("photocraft_print_{}", nonce());
let signals = wait(&token)?;
let mut options: Dict<'_> = HashMap::new();
options.insert("handle_token", Value::from(token.as_str()));
options.insert("token", Value::from(prepared));
let fd = zbus::zvariant::Fd::from(std::os::fd::AsFd::as_fd(&file));
let _: OwnedObjectPath = proxy.call("Print", &("", title, fd, options)).map_err(|e| format!("Print failed: {e}"))?;
response(signals)?;
Ok("printed through the system print dialog".into())
}
#[cfg(target_os = "linux")]
fn nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0);
t ^ N.fetch_add(1, Ordering::Relaxed)
}
/// The request object the portal answers on: the sender's unique name without its colon and
/// with dots as underscores, then the handle token.
fn request_path(unique_name: &str, token: &str) -> String {
format!("/org/freedesktop/portal/desktop/request/{}/{token}", unique_name.trim_start_matches(':').replace('.', "_"))
}
/// A value seeded into the dialog: GtkPrintSettings are strings; a GtkPageSetup's sizes are
/// doubles in millimetres (`gtk_paper_size_new_from_gvariant` ignores anything else).
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
enum Seed {
Text(String),
Mm(f64),
}
fn text(s: &str) -> Seed {
Seed::Text(s.to_string())
}
/// The standard paper sizes, as (PWG name, PPD name, width mm, height mm), portrait. GTK's
/// print settings take the PWG name; its page setup finds the size by the PPD name.
const PAPERS: [(&str, &str, f64, f64); 8] = [
("na_letter", "Letter", 215.9, 279.4),
("na_legal", "Legal", 215.9, 355.6),
("na_ledger", "Ledger", 279.4, 431.8),
("iso_a3", "A3", 297.0, 420.0),
("iso_a4", "A4", 210.0, 297.0),
("iso_a5", "A5", 148.0, 210.0),
("na_index-4x6", "4x6", 101.6, 152.4),
("na_5x7", "5x7", 127.0, 177.8),
];
const MM_PER_PT: f64 = 25.4 / 72.0;
type Paper = (&'static str, &'static str, f64, f64);
/// The job's paper: its row in `PAPERS` when it is a standard size, and the portrait size in
/// millimetres.
fn paper(paper_pt: (f64, f64)) -> (Option<&'static Paper>, f64, f64) {
let (w, h) = (paper_pt.0.min(paper_pt.1) * MM_PER_PT, paper_pt.0.max(paper_pt.1) * MM_PER_PT);
let row = PAPERS.iter().find(|(_, _, pw, ph)| (pw - w).abs() < 1.0 && (ph - h).abs() < 1.0);
(row, w, h)
}
fn orientation(landscape: bool) -> Seed {
text(if landscape { "landscape" } else { "portrait" })
}
/// `PreparePrint`'s settings (GtkPrintSettings keys, string values).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn settings_for(copies: u32, paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("n-copies", text(&copies.to_string())), ("orientation", orientation(landscape))];
match row {
Some((pwg, _, _, _)) => v.push(("paper-format", text(pwg))),
None => v.extend([("paper-width", text(&format!("{w:.2}"))), ("paper-height", text(&format!("{h:.2}")))]),
}
v
}
/// `PreparePrint`'s page setup (GtkPageSetup keys): the size in millimetres, found by its PPD
/// name when it has one, else a custom size named by its dimensions.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn page_setup_for(paper_pt: (f64, f64), landscape: bool) -> Vec<(&'static str, Seed)> {
let (row, w, h) = paper(paper_pt);
let mut v = vec![("Orientation", orientation(landscape)), ("Width", Seed::Mm(w)), ("Height", Seed::Mm(h))];
match row {
Some((_, ppd, _, _)) => v.push(("PPDName", text(ppd))),
None => v.push(("Name", text(&format!("custom_{w:.0}x{h:.0}mm")))),
}
v
}
#[cfg(target_os = "linux")]
fn settings(job: &PrintJob) -> Vec<(&'static str, Seed)> {
settings_for(job.copies, job.paper, job.landscape)
}
#[cfg(target_os = "linux")]
fn page_setup(job: &PrintJob) -> Vec<(&'static str, Seed)> {
page_setup_for(job.paper, job.landscape)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_path_follows_the_portal_rule() {
assert_eq!(request_path(":1.42", "photocraft_prepare_7"), "/org/freedesktop/portal/desktop/request/1_42/photocraft_prepare_7");
}
#[test]
fn standard_papers_get_their_pwg_and_ppd_names_and_custom_ones_their_size() {
// A4 landscape as the engine lays it out: width > height, still A4.
let s = settings_for(2, (841.89, 595.28), true);
assert_eq!(s, vec![("n-copies", text("2")), ("orientation", text("landscape")), ("paper-format", text("iso_a4"))]);
let p = page_setup_for((841.89, 595.28), true);
assert_eq!(p[0], ("Orientation", text("landscape")));
let Seed::Mm(w) = p[1].1 else { panic!("width is millimetres") };
let Seed::Mm(h) = p[2].1 else { panic!("height is millimetres") };
assert!((w - 210.0).abs() < 0.01 && (h - 297.0).abs() < 0.01, "{w} x {h}");
assert_eq!(p[3], ("PPDName", text("A4")));
assert_eq!(settings_for(1, (612.0, 792.0), false)[2], ("paper-format", text("na_letter")));
assert_eq!(page_setup_for((612.0, 792.0), false)[3], ("PPDName", text("Letter")));
assert_eq!(settings_for(1, (288.0, 432.0), false)[2], ("paper-format", text("na_index-4x6")));
// 100 x 200 mm is nobody's standard.
let custom = settings_for(1, (283.46, 566.93), false);
assert_eq!(&custom[2..], &[("paper-width", text("100.00")), ("paper-height", text("200.00"))]);
assert_eq!(page_setup_for((283.46, 566.93), false)[3], ("Name", text("custom_100x200mm")));
}
}
+10 -3
View File
@@ -264,8 +264,8 @@ fn block_on<T>(future: impl Future<Output = T>) -> T {
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
/// portable mode, else the platform convention. Everything the app persists lives under it; see
/// [`crate::app_dirs`].
/// portable mode, else the platform convention. Everything the app persists lives under it
/// except, on Windows, crash-recovery autosaves; see [`crate::app_dirs`].
pub fn config_dir() -> Option<PathBuf> {
crate::app_dirs::config_dir()
}
@@ -280,8 +280,10 @@ pub fn presets_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Presets"))
}
/// Crash-recovery autosaves: under the settings directory, except on Windows, where they stay
/// out of the roaming profile (see [`crate::app_dirs::recovery_dir`]).
fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
crate::app_dirs::recovery_dir()
}
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
@@ -504,6 +506,11 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
save_file: Some(Arc::new(save_file)),
file_dialog: Some(Box::new(show_file_dialog)),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
file_stamp: Some(Box::new(|path: &str| {
// Follows a symlink, as Save writes through it to the file it points at.
let meta = std::fs::metadata(path).ok()?;
Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() })
})),
automation_read,
automation_write,
automation_open_pcraft,
@@ -0,0 +1,95 @@
//! `photocraft --help` and unknown options must not open the window (#6431): before the fix,
//! any argument the loop didn't know was taken as a file, so `--help` or a typo such as
//! `--safegpu` launched the GUI and logged "Couldn't open --help".
//!
//! These run the real binary, and every case must exit before the window opens: Windows and
//! macOS runners have a desktop, so a launch that gets that far hangs until the test times out.
//! Cases whose arguments are valid add a bad `--control` value, which fails the launch with the
//! other usage errors, so stderr shows whether those arguments were taken as unknown options.
/// Run `photocraft` with `args` in an isolated config dir; return the exit
/// status, stdout and stderr.
fn run_photocraft(args: &[&str]) -> (std::process::ExitStatus, String, String) {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("photocraft-usage-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let output = std::process::Command::new(env!("CARGO_BIN_EXE_photocraft"))
.args(args)
.env("PHOTOCRAFT_CONFIG_DIR", &dir)
.env_remove("PHOTOCRAFT_CONTROL_PORT")
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.output()
.expect("failed to run photocraft binary");
std::fs::remove_dir_all(&dir).ok();
(output.status, String::from_utf8_lossy(&output.stdout).into_owned(), String::from_utf8_lossy(&output.stderr).into_owned())
}
#[test]
fn help_prints_usage_and_exits_0() {
for flag in ["--help", "-h"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(0), "{flag} must exit 0, stderr: {stderr}");
assert!(stdout.starts_with("Usage: photocraft"), "{flag} stdout: {stdout}");
for option in ["--safe-gpu", "--new-instance", "--control <port>", "--version", "--"] {
assert!(stdout.contains(option), "{flag} usage must list {option}: {stdout}");
}
assert!(!stderr.contains("Couldn't open"), "{flag} was taken as a file: {stderr}");
}
}
#[test]
fn help_wins_over_later_arguments() {
let (status, stdout, _) = run_photocraft(&["--help", "picture.psd", "--safegpu"]);
assert_eq!(status.code(), Some(0));
assert!(stdout.starts_with("Usage: photocraft"), "stdout: {stdout}");
}
#[test]
fn unknown_options_exit_2_naming_them() {
for flag in ["--safegpu", "-x", "--help=yes"] {
let (status, stdout, stderr) = run_photocraft(&[flag]);
assert_eq!(status.code(), Some(2), "{flag} must be a usage error, stderr: {stderr}");
assert!(stderr.contains(&format!("unknown option `{flag}`")), "stderr: {stderr}");
assert!(stderr.contains("photocraft --help"), "stderr should point at --help: {stderr}");
assert!(stdout.is_empty(), "stdout: {stdout}");
}
}
#[test]
fn unknown_option_errors_are_reported_with_control_port_errors() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--safegpu"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(stderr.contains("unknown option `--safegpu`"), "stderr: {stderr}");
}
#[test]
fn arguments_after_double_dash_are_files() {
// `--` ends the options: `--help` and `-x` after it are file names, so neither prints usage
// nor is an unknown option; only the bad port is reported.
let (status, stdout, stderr) = run_photocraft(&["--control", "nope", "--", "--help", "-x"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stdout.contains("Usage:"), "stdout: {stdout}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn macos_process_serial_number_is_still_ignored() {
// Old macOS passes `-psn_<n>` when launching from Finder; it is neither a file nor an error.
let (status, _, stderr) = run_photocraft(&["--control", "nope", "-psn_0_12345"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
#[test]
fn new_instance_is_a_known_option() {
let (status, _, stderr) = run_photocraft(&["--control", "nope", "--new-instance"]);
assert_eq!(status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("`nope` is not a valid port"), "stderr: {stderr}");
assert!(!stderr.contains("unknown option"), "stderr: {stderr}");
}
+13
View File
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="12" rx="10" ry="7" stroke-dasharray="3.5 3.23" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

+4 -4
View File
@@ -9,8 +9,8 @@
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m11 10 3 3" />
<path d="M6.5 21A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z" />
<path d="M9.969 17.031 21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031" />
<path d="M6.5 12a5.5 5.5 0 1 0 5.5 5.5" stroke-dasharray="2 3" />
<path d="M14 3H7a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h7" stroke-dasharray="2 3" />
<path d="m14 10 2.1 2.1" />
<path d="M10.85 17.7A2.45 2.45 0 1 0 8.4 15.25a1.834 1.834 0 0 1-.496 1.254A.7 .7 0 0 0 8.4 17.7z" />
<path d="M13.278 14.922 21.265 6.937a.7 .7 0 0 0-2.101-2.101L11.177 12.822" />
</svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 502 B

+7
View File
@@ -162,3 +162,10 @@ sync_github_name = true
[people.sidney]
real_name = "Sidney Markowitz"
sync_github_name = true
[people.1Git2Clone]
sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+2 -5
View File
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
}
image_data = true;
}
b"IEND" => {
if !image_data {
return Err(Error::Malformed("PNG has no image data"));
}
}
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
b"IEND" => {}
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
return Err(Error::Unsupported("unknown critical PNG chunk"));
}
@@ -0,0 +1,35 @@
By downloading, copying, installing or using the software you agree to this license.
If you do not agree to this license, do not download, install,
copy or use the software.
License Agreement For libfacedetection
(3-clause BSD License)
Copyright (c) 2015-2019, Shiqi Yu, all rights reserved.
shiqi.yu@gmail.com
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the names of the copyright holders nor the names of the contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
This software is provided by the copyright holders and contributors "as is" and
any express or implied warranties, including, but not limited to, the implied
warranties of merchantability and fitness for a particular purpose are disclaimed.
In no event shall copyright holders or contributors be liable for any direct,
indirect, incidental, special, exemplary, or consequential damages
(including, but not limited to, procurement of substitute goods or services;
loss of use, data, or profits; or business interruption) however caused
and on any theory of liability, whether in contract, strict liability,
or tort (including negligence or otherwise) arising in any way out of
the use of this software, even if advised of the possibility of such damage.
Binary file not shown.
+126 -14
View File
@@ -79,23 +79,48 @@ fn mirror(i: isize, n: usize) -> usize {
const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
/// Planes smaller than this run their rows on one thread: the filter builds a whole pyramid per
/// reference (up to 96), and splitting its small levels into one-row tasks cost more in rayon's
/// scheduling than the rows themselves (a fifth of a Shadows render on 36 MP, measured).
const PAR_MIN: usize = 1 << 15;
/// [`par_rows`] for a plane, on one thread when it is small (see [`PAR_MIN`]).
fn rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
if buf.len() < PAR_MIN {
buf.chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| f(y, row));
} else {
par_rows(buf, w, 1, f);
}
}
/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution).
fn blur_step(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
// Columns first at the kept rows, then rows at the kept columns.
// Columns first at the kept rows, then rows at the kept columns. Each sum adds the same terms
// in the same order as a `mirror` per tap would, so the result is bit for bit the same; the
// source rows are found once per row and the columns mirrored only near the edges.
let mut tmp = vec![0.0f32; p.w * h2];
par_rows(&mut tmp, p.w, 1, |y2, row| {
rows(&mut tmp, p.w, |y2, row| {
let y = (y2 * step) as isize;
let src: [&[f32]; 5] = std::array::from_fn(|k| {
let r = mirror(y + k as isize - 2, p.h);
&p.v[r * p.w..(r + 1) * p.w]
});
for (x, o) in row.iter_mut().enumerate() {
*o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum();
*o = (0..5).map(|k| TAPS[k] * src[k][x]).sum();
}
});
let mut out = vec![0.0f32; w2 * h2];
par_rows(&mut out, w2, 1, |y2, row| {
rows(&mut out, w2, |y2, row| {
let src = &tmp[y2 * p.w..(y2 + 1) * p.w];
for (x2, o) in row.iter_mut().enumerate() {
let x = (x2 * step) as isize;
*o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum();
let x = x2 * step;
*o = if x >= 2 && x + 2 < p.w {
let s = &src[x - 2..x + 3];
(0..5).map(|k| TAPS[k] * s[k]).sum()
} else {
(0..5).map(|k| TAPS[k] * src[mirror(x as isize + k as isize - 2, p.w)]).sum()
};
}
});
Plane { w: w2, h: h2, v: out }
@@ -107,14 +132,45 @@ fn down(p: &Plane) -> Plane {
}
/// `p` brought up to `w × h`: zeros between samples, then the blur times four.
///
/// Three samples in four are zero, so their taps are skipped instead of blurring a zero-filled
/// plane: the nonzero terms are added in the same order (skipping `+0.0` terms changes at most the
/// sign of a zero, and the filter only adds and exponentiates these values).
fn up(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
let (lh, lw) = (p.h.min(h.div_ceil(2)), p.w.min(w.div_ceil(2)));
// The source sample behind index `i` of a zero-stuffed axis of length `n` holding `lim` samples.
let src = |i: isize, n: usize, lim: usize| {
let m = mirror(i, n);
(m.is_multiple_of(2) && m / 2 < lim).then_some(m / 2)
};
// Columns first, only at the even columns (the odd ones are all zero).
let mut tmp = vec![0.0f32; lw * h];
if lw > 0 {
rows(&mut tmp, lw, |y, row| {
let taps: [Option<&[f32]>; 5] = std::array::from_fn(|k| src(y as isize + k as isize - 2, h, lh).map(|r| &p.v[r * p.w..r * p.w + lw]));
for (j, o) in row.iter_mut().enumerate() {
*o = (0..5).filter_map(|k| taps[k].map(|s| TAPS[k] * (s[j] * 4.0))).sum();
}
});
}
blur_step(&z, 1)
let mut out = vec![0.0f32; w * h];
rows(&mut out, w, |y, row| {
let t = &tmp[y * lw..(y + 1) * lw];
for (x, o) in row.iter_mut().enumerate() {
*o = if x >= 2 && x + 2 < w {
// Away from the edges no tap mirrors.
(0..5)
.filter_map(|k| {
let i = x + k - 2;
(i.is_multiple_of(2) && i / 2 < lw).then(|| TAPS[k] * t[i / 2])
})
.sum()
} else {
(0..5).filter_map(|k| src(x as isize + k as isize - 2, w, lw).map(|j| TAPS[k] * t[j])).sum()
};
}
});
Plane { w, h, v: out }
}
/// Levels down to about 2 pixels on the short side.
@@ -154,7 +210,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for k in 0..refs {
let r = lo + step * k as f32;
let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() };
par_rows(&mut m.v, u.w, 1, |_, row| {
rows(&mut m.v, u.w, |_, row| {
for v in row.iter_mut() {
*v = remap(*v, r);
}
@@ -163,7 +219,7 @@ fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane {
for l in 0..n - 1 {
let coarse = up(&gr[l + 1], gr[l].w, gr[l].h);
let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w);
par_rows(&mut lap[l], lw, 1, |y, row| {
rows(&mut lap[l], lw, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * lw + x;
let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0);
@@ -337,6 +393,62 @@ mod tests {
const SRGB: Encoding = Encoding(0.0);
/// The plain blur: a `mirror` per tap over the whole plane, the reference for [`blur_step`].
fn blur_step_reference(p: &Plane, step: usize) -> Plane {
let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step));
let mut tmp = vec![0.0f32; p.w * h2];
for y2 in 0..h2 {
for x in 0..p.w {
tmp[y2 * p.w + x] = (0..5).map(|k| TAPS[k] * p.v[mirror((y2 * step) as isize + k as isize - 2, p.h) * p.w + x]).sum();
}
}
let mut out = vec![0.0f32; w2 * h2];
for y2 in 0..h2 {
for x2 in 0..w2 {
out[y2 * w2 + x2] = (0..5).map(|k| TAPS[k] * tmp[y2 * p.w + mirror((x2 * step) as isize + k as isize - 2, p.w)]).sum();
}
}
Plane { w: w2, h: h2, v: out }
}
/// The plain upsampling: a zero-stuffed plane blurred whole, the reference for [`up`].
fn up_reference(p: &Plane, w: usize, h: usize) -> Plane {
let mut z = Plane { w, h, v: vec![0.0; w * h] };
for y in 0..p.h.min(h.div_ceil(2)) {
for x in 0..p.w.min(w.div_ceil(2)) {
z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0;
}
}
blur_step_reference(&z, 1)
}
fn noise_plane(w: usize, h: usize, seed: u32) -> Plane {
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
let v = (0..w * h)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % 20_000) as f32 / 1000.0 - 16.0
})
.collect();
Plane { w, h, v }
}
#[test]
fn the_fast_pyramid_matches_the_plain_one_exactly() {
// Odd, even, tiny and large-enough-to-run-in-parallel sizes, as the pyramid's levels are.
for (i, (w, h)) in [(1, 1), (1, 7), (2, 2), (3, 5), (5, 3), (8, 6), (37, 23), (64, 64), (301, 211)].into_iter().enumerate() {
let p = noise_plane(w, h, i as u32 + 1);
for step in [1, 2] {
assert_eq!(blur_step(&p, step).v, blur_step_reference(&p, step).v, "blur {w}×{h} step {step}");
}
for (uw, uh) in [(w * 2, h * 2), (w * 2 - 1, h * 2 - 1), (w * 2, h * 2 - 1), (w.max(2) * 2 - 3, h * 2)] {
assert_eq!(up(&p, uw, uh).v, up_reference(&p, uw, uh).v, "up {w}×{h} → {uw}×{uh}");
}
}
}
fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect()
}
+164
View File
@@ -566,6 +566,133 @@ pub fn level_transform(ch: usize, from: &[f32], to: &[f32], a: Adapt) -> Vec<(f3
.collect()
}
/// One stroke of the Content-Aware Fill workspace's Sampling Brush: a hard round brush of
/// diameter `size` along `points` (document px), adding to the sampling area or taking from it.
#[derive(Clone, Debug, PartialEq)]
pub struct SamplingStroke {
pub add: bool,
pub size: f64,
pub points: Vec<[f64; 2]>,
}
impl SamplingStroke {
/// Bounds of the pixels the stroke covers.
pub fn bounds(&self) -> Rect {
let r = (self.size / 2.0).max(0.5);
self.points.iter().fold(Rect::EMPTY, |acc, p| {
let b = Rect::new(
(p[0] - r).floor() as i32,
(p[1] - r).floor() as i32,
((p[0] + r).ceil() as i32).saturating_add(1),
((p[1] + r).ceil() as i32).saturating_add(1),
);
if acc.is_empty() { b } else { acc.union(&b) }
})
}
}
/// Squared distance from `p` to the segment `a`–`b`.
fn seg_dist2(p: [f64; 2], a: [f64; 2], b: [f64; 2]) -> f64 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l2 = dx * dx + dy * dy;
let t = if l2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (qx, qy) = (a[0] + t * dx - p[0], a[1] + t * dy - p[1]);
qx * qx + qy * qy
}
/// Paint `strokes` in order into `mask` (row-major over `area`): a pixel whose centre lies within
/// half the brush size of a stroke's path is set (adding) or cleared (subtracting). A mask that
/// does not cover `area` exactly is left as it is.
pub fn paint_strokes(mask: &mut [bool], area: Rect, strokes: &[SamplingStroke]) {
let w = area.width() as usize;
if mask.len() != w * area.height() as usize {
return;
}
for s in strokes {
let r = (s.size / 2.0).max(0.5);
let r2 = r.powi(2);
let segs: Vec<([f64; 2], [f64; 2])> = match s.points.as_slice() {
[] => continue,
[p] => vec![(*p, *p)],
pts => pts.windows(2).map(|p| (p[0], p[1])).collect(),
};
// A stroke covers the union of its segments' pixels: each segment only looks at its own
// bounds.
for (a, b) in segs {
let bounds = Rect::new(
(a[0].min(b[0]) - r).floor() as i32,
(a[1].min(b[1]) - r).floor() as i32,
((a[0].max(b[0]) + r).ceil() as i32).saturating_add(1),
((a[1].max(b[1]) + r).ceil() as i32).saturating_add(1),
)
.intersect(&area);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let c = [f64::from(x) + 0.5, f64::from(y) + 0.5];
if seg_dist2(c, a, b) <= r2
&& let Some(m) = mask.get_mut((y - area.y0) as usize * w + (x - area.x0) as usize)
{
*m = s.add;
}
}
}
}
}
}
/// The pixels of `area` (row-major) whose centres lie inside the polygon `pts` (even-odd rule):
/// what a lasso outlines.
pub fn polygon_mask(area: Rect, pts: &[[f64; 2]]) -> Vec<bool> {
let (w, h) = (area.width() as usize, area.height() as usize);
let mut out = vec![false; w * h];
if pts.len() < 3 {
return out;
}
let mut xs: Vec<f64> = Vec::new();
for row in 0..h {
let cy = f64::from(area.y0) + row as f64 + 0.5;
xs.clear();
for i in 0..pts.len() {
let (a, b) = (pts[i], pts[(i + 1) % pts.len()]);
if (a[1] <= cy) != (b[1] <= cy) {
xs.push(a[0] + (cy - a[1]) / (b[1] - a[1]) * (b[0] - a[0]));
}
}
xs.sort_by(f64::total_cmp);
for pair in xs.as_chunks::<2>().0 {
// Pixel centres x + 0.5 within [pair0, pair1).
let x0 = ((pair[0] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
let x1 = ((pair[1] - 0.5).ceil() as i64 - i64::from(area.x0)).clamp(0, w as i64) as usize;
out[row * w + x0..row * w + x1.max(x0)].iter_mut().for_each(|v| *v = true);
}
}
out
}
/// Grow (`by` > 0) or shrink (`by` < 0) a mask by `|by|` pixels (Euclidean): the lasso's Expand
/// and Contract.
pub fn grow_mask(mask: &[bool], w: usize, h: usize, by: i32) -> Vec<bool> {
if by == 0 || w == 0 || h == 0 {
return mask.to_vec();
}
let r = f32::from(u16::try_from(by.unsigned_abs()).unwrap_or(u16::MAX));
if by > 0 {
let d = crate::selection::edt(mask, w, h);
d.iter().map(|v| *v <= r).collect()
} else {
let outside: Vec<bool> = mask.iter().map(|v| !v).collect();
let d = crate::selection::edt(&outside, w, h);
d.iter().map(|v| *v > r).collect()
}
}
/// The Sampling Brush's starting size for a selection with bounds `hole`: a tenth of the sampling
/// window's side, as Photoshop opens the workspace (20 px for a 50 px selection, 40 for 100).
pub fn default_brush_size(hole: Rect) -> f64 {
let (w, h) = (f64::from(hole.width().max(50)), f64::from(hole.height().max(50)));
(0.4 * (w * h).sqrt()).round()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -577,6 +704,43 @@ mod tests {
(img, hole)
}
#[test]
fn strokes_paint_their_path_and_leave_a_mismatched_mask_alone() {
let area = Rect::new(10, 20, 50, 40);
let (w, h) = (40, 20);
let strokes = [
SamplingStroke { add: true, size: 6.0, points: vec![[12.0, 25.0], [40.0, 25.0], [45.0, 35.0]] },
SamplingStroke { add: false, size: 3.0, points: vec![[30.0, 25.0]] },
SamplingStroke { add: true, size: 2.0, points: Vec::new() },
];
let mut mask = vec![false; w * h];
paint_strokes(&mut mask, area, &strokes);
// The same pixels as testing every pixel against every segment of each stroke in turn.
let mut want = vec![false; w * h];
for s in &strokes {
let r2 = (s.size / 2.0).max(0.5).powi(2);
let segs: Vec<_> = if s.points.len() == 1 { vec![(s.points[0], s.points[0])] } else { s.points.windows(2).map(|p| (p[0], p[1])).collect() };
for (i, m) in want.iter_mut().enumerate() {
let c = [f64::from(area.x0) + (i % w) as f64 + 0.5, f64::from(area.y0) + (i / w) as f64 + 0.5];
if segs.iter().any(|(a, b)| seg_dist2(c, *a, *b) <= r2) {
*m = s.add;
}
}
}
assert_eq!(mask, want);
assert!(mask[5 * w + 10] && !mask[5 * w + 20], "added along the path, cleared under the subtracting dab");
// A mask shorter or longer than the area is not touched (and nothing panics).
for len in [0, 7, w * h - 1, w * h + 3] {
let mut short = vec![false; len];
paint_strokes(&mut short, area, &strokes);
assert!(short.iter().all(|v| !v), "len {len}");
}
// Hostile coordinates stay inside the area.
let mut m = vec![false; w * h];
let wild = [SamplingStroke { add: true, size: 5000.0, points: vec![[f64::MAX, -1e300], [f64::NAN, 1e7]] }];
paint_strokes(&mut m, area, &wild);
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
+36 -19
View File
@@ -28,32 +28,47 @@ pub(crate) fn centre(b: Rect) -> (f32, f32, f32) {
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w.min(h) / 2.0)
}
/// The bounds' centre and half width and height: Twirl, Pinch and Spherize act inside the ellipse
/// inscribed in the bounds, as Photoshop's do, so on a wide or tall image the effect reaches all
/// four edges instead of stopping at a circle fitted to the shorter side (#5643). A point maps to
/// unit-circle coordinates `(u, v)` and back.
fn ellipse(b: Rect) -> (f32, f32, f32, f32) {
let (w, h) = (b.width() as f32, b.height() as f32);
(b.x0 as f32 + w / 2.0, b.y0 as f32 + h / 2.0, w / 2.0, h / 2.0)
}
pub(crate) fn twirl(src: &Image, out: Rect, ctx: &Ctx, angle: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = angle.to_radians();
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || rr <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let t = a * (1.0 - r / rr);
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 {
return (x, y);
}
let t = a * (1.0 - r);
let (s, c) = t.sin_cos();
(cx + dx * c - dy * s, cy + dx * s + dy * c)
(cx + (u * c - v * s) * hx, cy + (u * s + v * c) * hy)
})
}
pub(crate) fn pinch(src: &Image, out: Rect, ctx: &Ctx, amount: f32) -> Vec<f32> {
let (cx, cy, rr) = centre(ctx.bounds);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * (r / rr).powf(1.0 - a * 0.5);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = ((x - cx) / hx, (y - cy) / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = r.powf(1.0 - a * 0.5) / r;
(cx + u * k * hx, cy + v * k * hy)
})
}
@@ -64,20 +79,22 @@ fn sphere_1d(rn: f32, a: f32) -> f32 {
}
pub(crate) fn spherize(src: &Image, out: Rect, ctx: &Ctx, amount: f32, mode: SpherizeMode) -> Vec<f32> {
let b = ctx.bounds;
let (cx, cy, rr) = centre(b);
let (hx, hy) = (b.width() as f32 / 2.0, b.height() as f32 / 2.0);
let (cx, cy, hx, hy) = ellipse(ctx.bounds);
let a = (amount / 100.0).clamp(-1.0, 1.0);
remap(src, out, ctx, Edge::Repeat, |x, y| {
let (dx, dy) = (x - cx, y - cy);
match mode {
SpherizeMode::Normal => {
let r = (dx * dx + dy * dy).sqrt();
if r >= rr || r <= 0.0 {
if hx <= 0.0 || hy <= 0.0 {
return (x, y);
}
let rs = rr * sphere_1d(r / rr, a);
(cx + dx * rs / r, cy + dy * rs / r)
let (u, v) = (dx / hx, dy / hy);
let r = (u * u + v * v).sqrt();
if r >= 1.0 || r <= 0.0 {
return (x, y);
}
let k = sphere_1d(r, a) / r;
(cx + u * k * hx, cy + v * k * hy)
}
SpherizeMode::HorizontalOnly => {
if dx.abs() >= hx {
+90
View File
@@ -0,0 +1,90 @@
//! Face detection: a port of libfacedetection's CNN (Shiqi Yu, BSD-3; `data/LICENSE-libfacedetection.txt`).
//!
//! The network is the YuNet design: a backbone of 1×1 pointwise and 3×3 depthwise convolutions with
//! 2×2 max pooling, a feature pyramid at strides 8, 16 and 32, and per-cell heads for the face score,
//! the box and five landmarks (eyes, nose tip, mouth corners). The weights (`data/libfacedetection.bin`,
//! converted to half precision from the library's `facedetectcnn-data.cpp`) are about 100 KB, and
//! inference is plain Rust: no runtime, so it works offline.
//!
//! [`detect`] takes straight RGBA in `0..=1` and returns faces in pixel coordinates of that image.
//!
//! The browser build (wasm32) leaves the network and its weights out, as `photocraft-raw` does with
//! its JPEG XL decoder: they would push the wasm over the per-file cap of static hosts
//! (packaging/web/package.sh). There [`AVAILABLE`] is false and [`detect`] finds nothing.
#[cfg(not(target_arch = "wasm32"))]
mod cnn;
/// Whether this build can detect faces (not the browser build: see the module docs).
pub const AVAILABLE: bool = cfg!(not(target_arch = "wasm32"));
/// Longest side the network looks at; larger images are scaled down first (faces smaller than
/// about 10 px at this scale are missed).
pub const MAX_SIDE: usize = 1280;
/// One detected face, in pixels of the image passed to [`detect`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Face {
/// Confidence `0..=1` (the library reports faces above 0.5 as reliable).
pub score: f32,
/// Left, top, width and height.
pub rect: [f32; 4],
/// Right eye, left eye (as seen in the image: the first is on the image's left), nose tip,
/// right mouth corner, left mouth corner.
pub landmarks: [[f32; 2]; 5],
}
/// Faces in `px` (straight RGBA `0..=1`, `w × h`, row-major) scoring at least `min_score`, best
/// first. Transparent pixels count as white. `None` when `px` doesn't match the size.
#[cfg(not(target_arch = "wasm32"))]
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Option<Vec<Face>> {
if w == 0 || h == 0 || px.len() != w.checked_mul(h)? {
return None;
}
// Scale down (box filter) so the longer side is at most MAX_SIDE.
let step = w.max(h).div_ceil(MAX_SIDE).max(1);
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
let mut bgr = vec![[0.0f32; 3]; sw * sh];
for y in 0..sh {
for x in 0..sw {
let (mut acc, mut n) = ([0.0f32; 3], 0.0f32);
for yy in y * step..((y + 1) * step).min(h) {
for xx in x * step..((x + 1) * step).min(w) {
let [r, g, b, a] = px[yy * w + xx];
let a = a.clamp(0.0, 1.0);
for (k, v) in [b, g, r].into_iter().enumerate() {
acc[k] += v.clamp(0.0, 1.0) * a + (1.0 - a);
}
n += 1.0;
}
}
bgr[y * sw + x] = acc.map(|v| (v / n.max(1.0) * 255.0).round());
}
}
let s = step as f32;
let faces = cnn::network(&bgr, sw, sh)?
.into_iter()
.filter(|f| f.score >= min_score)
.map(|f| Face { score: f.score, rect: f.rect.map(|v| v * s), landmarks: f.landmarks.map(|[x, y]| [x * s, y * s]) })
.collect();
Some(faces)
}
/// The browser build has no detector (see the module docs): always `None`.
#[cfg(target_arch = "wasm32")]
pub fn detect(_px: &[[f32; 4]], _w: usize, _h: usize, _min_score: f32) -> Option<Vec<Face>> {
None
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
#[test]
fn no_faces_in_flat_or_tiny_images_and_bad_sizes_are_refused() {
assert_eq!(detect(&vec![[0.5, 0.5, 0.5, 1.0]; 64 * 48], 64, 48, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0, 1.0, 1.0, 1.0]], 1, 1, 0.5), Some(vec![]));
assert_eq!(detect(&[[1.0; 4]; 3], 2, 2, 0.5), None);
assert_eq!(detect(&[], 0, 0, 0.5), None);
}
}
+323
View File
@@ -0,0 +1,323 @@
//! The network itself (native builds only: see the parent module's docs).
use super::Face;
use crate::par;
/// The weights: `PCFACE1\0`, the layer count (u16), then per layer its input channels (u16), filters
/// (u16) and kind (u8: 0 pointwise, 1 depthwise), the weights and the biases, all little-endian f16.
static WEIGHTS: &[u8] = include_bytes!("../../data/libfacedetection.bin");
const MAGIC: &[u8] = b"PCFACE1\0";
const LAYERS: usize = 53;
/// Faces whose boxes overlap more than this (intersection over union) are one face.
const NMS_OVERLAP: f32 = 0.45;
/// Candidates kept before non-maximum suppression, as the library does.
const TOP_K: usize = 1000;
struct Layer {
channels: usize,
filters: usize,
depthwise: bool,
weights: Vec<f32>,
biases: Vec<f32>,
}
/// A feature map: `rows × cols` cells of `channels` values each.
#[derive(Clone)]
struct Blob {
rows: usize,
cols: usize,
channels: usize,
data: Vec<f32>,
}
impl Blob {
fn new(rows: usize, cols: usize, channels: usize) -> Self {
Blob { rows, cols, channels, data: vec![0.0; rows * cols * channels] }
}
}
fn f16_to_f32(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let exp = i32::from((h >> 10) & 0x1f);
let frac = f32::from(h & 0x3ff);
match exp {
0 => sign * frac * (2.0f32).powi(-24),
31 => {
if frac == 0.0 {
sign * f32::INFINITY
} else {
f32::NAN
}
}
_ => sign * (1.0 + frac / 1024.0) * (2.0f32).powi(exp - 15),
}
}
fn parse(bytes: &[u8]) -> Option<Vec<Layer>> {
let rest = bytes.strip_prefix(MAGIC)?;
let mut at = 0usize;
let u16_at = |n: &mut usize| -> Option<u16> {
let b = rest.get(*n..*n + 2)?;
*n += 2;
Some(u16::from_le_bytes([b[0], b[1]]))
};
let count = usize::from(u16_at(&mut at)?);
if count != LAYERS {
return None;
}
let mut layers = Vec::with_capacity(count);
for _ in 0..count {
let channels = usize::from(u16_at(&mut at)?);
let filters = usize::from(u16_at(&mut at)?);
let depthwise = *rest.get(at)? == 1;
at += 1;
let nw = if depthwise { 9 * channels } else { filters * channels };
let mut read = |n: usize| -> Option<Vec<f32>> { (0..n).map(|_| u16_at(&mut at).map(f16_to_f32)).collect() };
let weights = read(nw)?;
let biases = read(filters)?;
if depthwise && filters != channels {
return None;
}
layers.push(Layer { channels, filters, depthwise, weights, biases });
}
(at == rest.len()).then_some(layers)
}
/// A 1×1 or 3×3 depthwise convolution with bias, then ReLU when `relu`.
fn conv(x: &Blob, l: &Layer, relu: bool) -> Option<Blob> {
if x.channels != l.channels {
return None;
}
let mut out = Blob::new(x.rows, x.cols, l.filters);
let (rows, cols, cin, cout) = (x.rows, x.cols, x.channels, l.filters);
par::chunks_mut(&mut out.data, cols * cout, rows * cols * cout > 1 << 16, |row, line| {
for col in 0..cols {
let o = &mut line[col * cout..(col + 1) * cout];
if l.depthwise {
o.copy_from_slice(&l.biases);
for r in row.saturating_sub(1)..(row + 2).min(rows) {
for c in col.saturating_sub(1)..(col + 2).min(cols) {
let k = (r + 1 - row) * 3 + (c + 1 - col);
let input = &x.data[(r * cols + c) * cin..(r * cols + c + 1) * cin];
let w = &l.weights[k * cin..(k + 1) * cin];
for ((o, i), w) in o.iter_mut().zip(input).zip(w) {
*o += i * w;
}
}
}
} else {
let input = &x.data[(row * cols + col) * cin..(row * cols + col + 1) * cin];
for (f, o) in o.iter_mut().enumerate() {
let w = &l.weights[f * cin..(f + 1) * cin];
*o = l.biases[f] + input.iter().zip(w).map(|(i, w)| i * w).sum::<f32>();
}
}
if relu {
for v in o.iter_mut() {
*v = v.max(0.0);
}
}
}
});
Some(out)
}
/// Pointwise then depthwise (ReLU after the depthwise one when `relu`).
fn conv_dp(x: &Blob, p: &Layer, d: &Layer, relu: bool) -> Option<Blob> {
conv(&conv(x, p, false)?, d, relu)
}
fn unit(x: &Blob, l: &[Layer]) -> Option<Blob> {
let [p1, d1, p2, d2] = l else { return None };
conv_dp(&conv_dp(x, p1, d1, true)?, p2, d2, true)
}
fn max_pool(x: &Blob) -> Blob {
let rows = x.rows.saturating_sub(3).div_ceil(2) + 1;
let cols = x.cols.saturating_sub(3).div_ceil(2) + 1;
let mut out = Blob::new(rows, cols, x.channels);
for r in 0..rows {
for c in 0..cols {
let o = (r * cols + c) * x.channels;
for ch in 0..x.channels {
let mut m = f32::NEG_INFINITY;
for rr in r * 2..(r * 2 + 2).min(x.rows) {
for cc in c * 2..(c * 2 + 2).min(x.cols) {
m = m.max(x.data[(rr * x.cols + cc) * x.channels + ch]);
}
}
out.data[o + ch] = m;
}
}
}
out
}
/// `small` upsampled ×2 (nearest) plus `big`.
fn up_add(small: &Blob, big: &Blob) -> Option<Blob> {
if small.rows * 2 != big.rows || small.cols * 2 != big.cols || small.channels != big.channels {
return None;
}
let mut out = big.clone();
let ch = big.channels;
for r in 0..big.rows {
for c in 0..big.cols {
let s = ((r / 2) * small.cols + c / 2) * ch;
let o = (r * big.cols + c) * ch;
for k in 0..ch {
out.data[o + k] += small.data[s + k];
}
}
}
Some(out)
}
/// The network's input: each cell holds the 3×3 neighbourhood (stride 2) of BGR values `0..=255`,
/// so the first, 3×3 stride-2 convolution is a pointwise one. The size is padded to 32.
fn input(bgr: &[[f32; 3]], w: usize, h: usize) -> Blob {
let rows = h.div_ceil(32) * 16;
let cols = w.div_ceil(32) * 16;
let mut b = Blob::new(rows, cols, 32);
for r in 0..rows {
for c in 0..cols {
let cell = (r * cols + c) * 32;
for fy in 0..3 {
let Some(y) = (r * 2 + fy).checked_sub(1).filter(|&y| y < h) else { continue };
for fx in 0..3 {
let Some(x) = (c * 2 + fx).checked_sub(1).filter(|&x| x < w) else { continue };
let o = cell + (fy * 3 + fx) * 3;
b.data[o..o + 3].copy_from_slice(&bgr[y * w + x]);
}
}
}
}
b
}
/// The heads of one pyramid level, decoded to candidate faces (in network pixels).
fn candidates(feat: &Blob, l: &[Layer], heads: [usize; 4], stride: f32, out: &mut Vec<Face>) -> Option<()> {
let head = |i: usize| conv_dp(feat, l.get(i)?, l.get(i + 1)?, false);
let (cls, reg, obj, kps) = (head(heads[0])?, head(heads[1])?, head(heads[2])?, head(heads[3])?);
if cls.channels != 1 || obj.channels != 1 || reg.channels != 4 || kps.channels != 10 {
return None;
}
let sigmoid = |v: f32| 1.0 / (1.0 + (-v.clamp(-88.0, 88.0)).exp());
for r in 0..feat.rows {
for c in 0..feat.cols {
let i = r * feat.cols + c;
let score = (sigmoid(cls.data[i]) * sigmoid(obj.data[i])).sqrt();
if score < 0.2 {
continue;
}
let (px, py) = (c as f32 * stride, r as f32 * stride);
let b = &reg.data[i * 4..i * 4 + 4];
let (cx, cy) = (b[0] * stride + px, b[1] * stride + py);
let (bw, bh) = (b[2].exp() * stride, b[3].exp() * stride);
let k = &kps.data[i * 10..i * 10 + 10];
let mut landmarks = [[0.0; 2]; 5];
for (n, lm) in landmarks.iter_mut().enumerate() {
*lm = [k[2 * n] * stride + px, k[2 * n + 1] * stride + py];
}
out.push(Face { score, rect: [cx - bw / 2.0, cy - bh / 2.0, bw, bh], landmarks });
}
}
Some(())
}
fn iou(a: &[f32; 4], b: &[f32; 4]) -> f32 {
let w = ((a[0] + a[2]).min(b[0] + b[2]) - a[0].max(b[0])).max(0.0);
let h = ((a[1] + a[3]).min(b[1] + b[3]) - a[1].max(b[1])).max(0.0);
let i = w * h;
if i <= 0.0 { 0.0 } else { i / (a[2] * a[3] + b[2] * b[3] - i) }
}
/// The heads (cls, reg, obj, kps: their first layer) of the stride 8, 16 and 32 levels.
const HEADS: [[usize; 4]; 3] = [[29, 35, 41, 47], [31, 37, 43, 49], [33, 39, 45, 51]];
/// The backbone and feature pyramid: the stride 8, 16 and 32 feature maps the heads read.
fn features(bgr: &[[f32; 3]], w: usize, h: usize, l: &[Layer]) -> Option<[Blob; 3]> {
let x = conv(&input(bgr, w, h), l.first()?, true)?;
let x = max_pool(&conv_dp(&x, l.get(1)?, l.get(2)?, true)?);
let x = unit(&unit(&x, l.get(3..7)?)?, l.get(7..11)?)?;
let f8 = unit(&max_pool(&x), l.get(11..15)?)?;
let f16 = unit(&max_pool(&f8), l.get(15..19)?)?;
let f32_ = unit(&max_pool(&f16), l.get(19..23)?)?;
let f32_ = conv_dp(&f32_, l.get(27)?, l.get(28)?, true)?;
let f16 = conv_dp(&up_add(&f32_, &f16)?, l.get(25)?, l.get(26)?, true)?;
let f8 = conv_dp(&up_add(&f16, &f8)?, l.get(23)?, l.get(24)?, true)?;
Some([f8, f16, f32_])
}
pub(super) fn network(bgr: &[[f32; 3]], w: usize, h: usize) -> Option<Vec<Face>> {
let l = parse(WEIGHTS)?;
let levels = features(bgr, w, h, &l)?;
let mut faces = Vec::new();
// Heads (cls, reg, obj, kps) per level, as in the library's model.
for ((feat, heads), stride) in levels.iter().zip(HEADS).zip([8.0, 16.0, 32.0]) {
candidates(feat, &l, heads, stride, &mut faces)?;
}
faces.sort_by(|a, b| b.score.total_cmp(&a.score));
faces.truncate(TOP_K);
let mut kept: Vec<Face> = Vec::new();
for f in faces {
if kept.iter().all(|k| iou(&k.rect, &f.rect) <= NMS_OVERLAP) {
kept.push(f);
}
}
Some(kept)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn weights_parse() {
let l = parse(WEIGHTS).expect("bundled weights");
assert_eq!(l.len(), LAYERS);
assert_eq!((l[0].channels, l[0].filters, l[0].depthwise), (32, 16, false));
assert_eq!((l[2].channels, l[2].filters, l[2].depthwise), (16, 16, true));
assert!(l.iter().all(|l| l.weights.iter().chain(&l.biases).all(|v| v.is_finite())));
// Damaged weights are refused, not read past their end.
assert!(parse(&WEIGHTS[..WEIGHTS.len() - 1]).is_none());
assert!(parse(b"nope").is_none());
}
/// The sum of every head's raw output on a fixed test image matches libfacedetection's own C++
/// (computed with its f32 weights; ours are f16, hence the tolerance).
#[test]
fn heads_match_the_reference_implementation() {
let (w, h) = (96usize, 72usize);
let bgr: Vec<[f32; 3]> = (0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let (r, g, b) = ((x * 7 + y * 3) % 256, (x * x + y * 5) % 256, (x * y * 3 + 11) % 256);
[b as f32, g as f32, r as f32]
})
.collect();
let l = parse(WEIGHTS).unwrap();
let levels = features(&bgr, w, h, &l).unwrap();
let reference =
[27.647039, 549.579923, -1645.299678, 914.784787, -6.149318, 35.705980, -454.742148, 163.847111, 1.267630, 28.415509, -85.662257, 62.294208];
let mut got = Vec::new();
for (feat, heads) in levels.iter().zip(HEADS) {
for first in heads {
let o = conv_dp(feat, &l[first], &l[first + 1], false).unwrap();
got.push(o.data.iter().map(|&v| f64::from(v)).sum::<f64>());
}
}
for (g, r) in got.iter().zip(reference) {
assert!((g - r).abs() <= 0.01 * r.abs().max(10.0), "{got:?}");
}
}
#[test]
fn f16_decoding() {
assert_eq!(f16_to_f32(0x3c00), 1.0);
assert_eq!(f16_to_f32(0xc000), -2.0);
assert_eq!(f16_to_f32(0x0000), 0.0);
assert_eq!(f16_to_f32(0x3555), 0.333_251_95);
assert_eq!(f16_to_f32(0x0001), 2.0f32.powi(-24));
}
}
+262 -54
View File
@@ -60,25 +60,98 @@ impl Rng {
}
}
/// Columns per band of the vertical blur pass: 64 f32 = 256 bytes = four cache lines per row, so
/// walking down a band touches whole lines, and the band's working set stays in cache.
const BLUR_BAND: usize = 64;
/// Box blur of radius `r` (edges clamped): a running f32 sum along rows, then along columns.
///
/// Both passes are parallel and give bit for bit what the one-line-at-a-time version gave: every
/// output sums its window in the same order. Rows are independent. For the column pass, the
/// image is cut into bands of columns; each column keeps its own f32 running sum, started from
/// its first window summed in order and updated in the same order, so only the loop nest changes
/// (the inner loop runs across a band's columns and vectorises), never any column's arithmetic.
fn blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
let mut dst = vec![0.0f32; src.len()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
if w == 0 || h == 0 {
return Vec::new();
}
let den = (2 * r + 1) as f32;
let mut tmp = vec![0.0f32; w * h];
par_rows(&mut tmp, w, |line, dst| {
let src = &v[line * w..(line + 1) * w];
let at = |k: i64| src[k.clamp(0, w as i64 - 1) as usize];
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for (k, o) in dst.iter_mut().enumerate() {
*o = acc / den;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
});
let mut dst = vec![0.0f32; w * h];
// One list of row segments per band, each segment a `&mut` slice of `dst`: no `unsafe`.
let mut bands: Vec<Vec<&mut [f32]>> = (0..w.div_ceil(BLUR_BAND)).map(|_| Vec::with_capacity(h)).collect();
for row in dst.chunks_exact_mut(w) {
for (band, seg) in bands.iter_mut().zip(row.chunks_mut(BLUR_BAND)) {
band.push(seg);
}
}
let src = &tmp;
let run = |bi: usize, segs: &mut [&mut [f32]], acc: &mut Vec<f32>| {
let (x0, bw) = (bi * BLUR_BAND, segs.first().map_or(0, |s| s.len()));
let row = |k: i64| {
let o = (k.clamp(0, h as i64 - 1) as usize) * w + x0;
&src[o..o + bw]
};
// The first window, summed in order from the same start as `Iterator::sum` uses.
acc.clear();
acc.resize(bw, std::iter::empty::<f32>().sum());
for k in -(r as i64)..=r as i64 {
for (t, &x) in acc.iter_mut().zip(row(k)) {
*t += x;
}
}
for (k, seg) in segs.iter_mut().enumerate() {
let (add, sub) = (row(k as i64 + r as i64 + 1), row(k as i64 - r as i64));
for (((o, t), &p), &m) in seg.iter_mut().zip(acc.iter_mut()).zip(add).zip(sub) {
*o = *t / den;
*t += p - m;
}
}
dst
};
pass(&pass(v, true), false)
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
bands.into_par_iter().enumerate().for_each_init(Vec::new, |acc, (bi, mut segs)| run(bi, &mut segs, acc));
}
#[cfg(target_arch = "wasm32")]
{
let mut acc = Vec::new();
for (bi, mut segs) in bands.into_iter().enumerate() {
run(bi, &mut segs, &mut acc);
}
}
dst
}
/// Runs `f(y, row)` over the `w`-wide rows of `buf`, in parallel on native targets.
fn par_rows(buf: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
buf.par_chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
#[cfg(target_arch = "wasm32")]
buf.chunks_mut(w).enumerate().for_each(|(y, row)| f(y, row));
}
/// [`par_rows`] over three row-aligned buffers in lockstep.
fn par_rows3(a: &mut [f32], b: &mut [f32], c: &mut [f32], w: usize, f: impl Fn(usize, &mut [f32], &mut [f32], &mut [f32]) + Sync + Send) {
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
a.par_chunks_mut(w).zip(b.par_chunks_mut(w)).zip(c.par_chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
#[cfg(target_arch = "wasm32")]
a.chunks_mut(w).zip(b.chunks_mut(w)).zip(c.chunks_mut(w)).enumerate().for_each(|(y, ((a, b), c))| f(y, a, b, c));
}
/// Harris corners: at most `max` corners at least `min_dist` pixels apart, strongest first,
@@ -88,28 +161,40 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
return Vec::new();
}
let (mut xx, mut yy, mut xy) = (vec![0.0f32; w * h], vec![0.0f32; w * h], vec![0.0f32; w * h]);
for y in 1..h - 1 {
// Rows are independent (each output pixel reads only `img`), so they run in parallel.
par_rows3(&mut xx, &mut yy, &mut xy, w, |y, xxr, yyr, xyr| {
if y == 0 || y + 1 == h {
return;
}
for x in 1..w - 1 {
let p = |dx: i64, dy: i64| img[((y as i64 + dy) as usize) * w + (x as i64 + dx) as usize];
// Sobel.
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
let i = y * w + x;
xx[i] = gx * gx;
yy[i] = gy * gy;
xy[i] = gx * gy;
xxr[x] = gx * gx;
yyr[x] = gy * gy;
xyr[x] = gx * gy;
}
}
});
// Two box passes approximate the Gaussian window.
let (xx, yy, xy) = (blur(w, h, &blur(w, h, &xx, 1), 1), blur(w, h, &blur(w, h, &yy, 1), 1), blur(w, h, &blur(w, h, &xy, 1), 1));
let r: Vec<f32> = (0..w * h).map(|i| xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2)).collect();
let mut r = vec![0.0f32; w * h];
par_rows(&mut r, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = y * w + x;
*o = xx[i] * yy[i] - xy[i] * xy[i] - 0.04 * (xx[i] + yy[i]).powi(2);
}
});
let peak = r.iter().copied().fold(0.0f32, f32::max);
if peak <= 0.0 {
return Vec::new();
}
let m = PATCH_RADIUS + 1;
let mut cand: Vec<Corner> = Vec::new();
for y in m..h - m {
// Rows are scanned in parallel and joined in row order; the sort below fully orders the
// candidates anyway (score, then y, then x), so the order they arrive in cannot matter.
let rows = par_map((h - m).saturating_sub(m), |i| {
let y = m + i;
let mut found: Vec<Corner> = Vec::new();
for x in m..w - m {
let v = r[y * w + x];
if v < peak * 0.01 {
@@ -125,9 +210,11 @@ pub fn harris(w: usize, h: usize, img: &[f32], max: usize, min_dist: f32, valid:
continue;
}
}
cand.push(Corner { x: x as f32, y: y as f32, score: v });
found.push(Corner { x: x as f32, y: y as f32, score: v });
}
}
found
});
let mut cand: Vec<Corner> = rows.into_iter().flatten().collect();
cand.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.y.total_cmp(&b.y)).then(a.x.total_cmp(&b.x)));
// Minimum spacing via a coarse occupancy grid.
let cell = min_dist.max(1.0);
@@ -176,37 +263,37 @@ pub fn describe(w: usize, h: usize, img: &[f32], corners: &[Corner], upright: bo
let (xi, yi) = ((x.round() as i64).clamp(0, w as i64 - 1) as usize, (y.round() as i64).clamp(0, h as i64 - 1) as usize);
smooth[yi * w + xi]
};
corners
.iter()
.map(|c| {
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
// Every descriptor depends only on its own corner and the smoothed image: computed in
// parallel, kept in corner order.
par_map(corners.len(), |ci| {
let c = &corners[ci];
let (s, co) = if upright {
(0.0, 1.0)
} else {
let (mut m10, mut m01) = (0.0f32, 0.0f32);
let r = PATCH_RADIUS as i64;
for dy in -r..=r {
for dx in -r..=r {
if dx * dx + dy * dy <= r * r {
let v = at(c.x + dx as f32, c.y + dy as f32);
m10 += dx as f32 * v;
m01 += dy as f32 * v;
}
}
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
.collect()
m01.atan2(m10).sin_cos()
};
let mut d = [0u64; 4];
for (i, p) in pairs().iter().enumerate() {
let rot = |x: f32, y: f32| (c.x + x * co - y * s, c.y + x * s + y * co);
let (ax, ay) = rot(p[0], p[1]);
let (bx, by) = rot(p[2], p[3]);
if at(ax, ay) < at(bx, by) {
d[i / 64] |= 1 << (i % 64);
}
}
d
})
}
pub fn hamming(a: &Descriptor, b: &Descriptor) -> u32 {
@@ -560,3 +647,124 @@ mod tests {
assert_eq!(hamming(&[0, 0, 0, 0], &[1, 3, 0, u64::MAX]), 67);
}
}
/// The row / column-band parallel `blur`, `harris` and `describe` against the serial code they
/// replaced. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
/// The serial blur exactly as it was before the parallelisation.
fn ref_blur(w: usize, h: usize, v: &[f32], r: usize) -> Vec<f32> {
let pass = |src: &[f32], horizontal: bool| -> Vec<f32> {
let mut dst = vec![0.0f32; src.len()];
let (n, m) = if horizontal { (h, w) } else { (w, h) };
for line in 0..n {
let at = |k: i64| {
let k = k.clamp(0, m as i64 - 1) as usize;
if horizontal { src[line * w + k] } else { src[k * w + line] }
};
let mut acc: f32 = (-(r as i64)..=r as i64).map(at).sum();
for k in 0..m {
let i = if horizontal { line * w + k } else { k * w + line };
dst[i] = acc / (2 * r + 1) as f32;
acc += at(k as i64 + r as i64 + 1) - at(k as i64 - r as i64);
}
}
dst
};
pass(&pass(v, true), false)
}
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Integer-only data with negatives, signed zeros and large values, so a different order of
/// additions cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
_ => v,
}
})
.collect()
}
/// Blocks of random brightness: lots of strong corners, no libm in the making. Only the
/// recorded-hash test below uses it, and that one is Linux x86_64 only.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn blocks(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h).map(|i| (mix(seed + ((i % w) / 8) as u64 * 4096 + ((i / w) / 8) as u64) >> 40) as f32 / 16_777_216.0).collect()
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn fnv(parts: &[u64]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
h ^= p;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[test]
fn blur_matches_the_serial_code() {
// Widths around the 64-column band (1 band, band + 1, several bands, a ragged last one),
// 1-wide and 1-high images, and radii 0, 1, 2, larger than the image.
let widths = [1usize, 2, 3, 31, 63, 64, 65, 127, 128, 129, 200];
let heights = [1usize, 2, 3, 5, 17, 40];
for (a, &w) in widths.iter().enumerate() {
for (b, &h) in heights.iter().enumerate() {
let v = noise(w * h, (a * 10 + b) as u64);
for r in [0usize, 1, 2, 3, 7, 50] {
let (got, want) = (blur(w, h, &v, r), ref_blur(w, h, &v, r));
let (g, e): (Vec<u32>, Vec<u32>) = (got.iter().map(|x| x.to_bits()).collect(), want.iter().map(|x| x.to_bits()).collect());
assert_eq!(g, e, "blur {w}x{h} r={r}");
}
}
}
assert!(blur(0, 0, &[], 1).is_empty());
}
// `harris` and `describe` go through the platform's hypot / ln / cos / atan2 / sin_cos, so the
// hashes recorded from the old code only hold where those are the same functions.
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
fn harris_and_describe_hashes_recorded_from_the_serial_code() {
// Sizes: a whole number of 64-column bands and a ragged one; with and without a validity mask.
for (w, h, max, md, want_corners, want_desc) in
[(211usize, 157usize, 300usize, 6.0f32, 0xeccc6928d3ae3b8cu64, 0xd1e6caae4d68f253u64), (128, 97, 60, 3.0, 0xe3f6615117fa7da7, 0x818ae7e965ecf88f)]
{
let img = blocks(w, h, 7);
let valid: Vec<bool> = (0..w * h).map(|i| !((i % w) > w / 2 && (i / w) < h / 3)).collect();
let mut parts = Vec::new();
for v in [None, Some(valid.as_slice())] {
let c = harris(w, h, &img, max, md, v);
for k in &c {
parts.extend([k.x.to_bits() as u64, k.y.to_bits() as u64, k.score.to_bits() as u64]);
}
parts.push(c.len() as u64);
}
assert_eq!(fnv(&parts), want_corners, "harris {w}x{h}");
let c = harris(w, h, &img, max, md, None);
assert!(c.len() > 10, "{} corners", c.len());
let mut parts = Vec::new();
for upright in [false, true] {
for d in describe(w, h, &img, &c, upright) {
parts.extend(d);
}
}
assert_eq!(fnv(&parts), want_desc, "describe {w}x{h}");
}
}
}
+1
View File
@@ -32,6 +32,7 @@ mod distort;
mod distort2;
pub mod erase;
pub mod exif;
pub mod faces;
pub mod features;
mod fxutil;
mod gallery;
+7 -3
View File
@@ -8,7 +8,8 @@ use crate::image::Image;
use crate::{Ctx, HsbModel};
/// Custom: `Σ kernel·pixel / scale + offset/255` per colour channel (alpha
/// kept). Kernels shorter than 25 entries are zero-padded.
/// kept). Kernels shorter than 25 entries are zero-padded. CMYK samples are
/// ink, so there it runs on `1 - ink` and a positive offset brightens.
pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f32, offset: f32) -> Vec<f32> {
let n = src.ch;
let cc = ncol(ctx, n);
@@ -16,6 +17,7 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
(0..25).filter_map(|i| kernel.get(i).copied().filter(|v| *v != 0.0).map(|v| (i as i32 % 5 - 2, i as i32 / 5 - 2, v))).collect();
let s = if scale == 0.0 { 1.0 } else { scale };
let off = offset / 255.0;
let sub = crate::fxutil::subtractive(ctx);
let mut res = src.crop(out);
if k.is_empty() {
return res;
@@ -23,8 +25,10 @@ pub(crate) fn custom(src: &Image, out: Rect, ctx: &Ctx, kernel: &[f32], scale: f
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
for (c, v) in px.iter_mut().enumerate().take(cc) {
let sum: f32 = k.iter().map(|&(dx, dy, w)| src.get(x + dx, y + dy, c) * w).sum();
*v = sum / s + off;
// CMYK: convolve brightness (1 - ink), then back to ink.
let lum = |v: f32| if sub { 1.0 - v } else { v };
let sum: f32 = k.iter().map(|&(dx, dy, w)| lum(src.get(x + dx, y + dy, c)) * w).sum();
*v = lum(sum / s + off);
}
}
res
+587 -90
View File
@@ -35,7 +35,7 @@
#![allow(clippy::needless_range_loop)] // index loops mirror the maths (pixels × channels)
use crate::features::{self, Descriptor, Model};
use crate::photo_util::{Normal, Rng, par_map, solve_dense};
use crate::photo_util::{Normal, Rng, par_map, par_rows, solve_dense};
use crate::transform::Homography;
/// Photomerge layouts.
@@ -996,58 +996,99 @@ pub fn blend_levels(w: usize, h: usize) -> usize {
n
}
/// `a[i] ⊕ b[i]` for every `i` (the shorter length wins, like `zip`), in parallel on native
/// targets. Purely elementwise, so the result is the serial result bit for bit.
fn par_zip(a: &[f32], b: &[f32], f: impl Fn(f32, f32) -> f32 + Sync + Send) -> Vec<f32> {
const CHUNK: usize = 1 << 14;
let mut out = vec![0.0f32; a.len().min(b.len())];
let fill = |o: &mut [f32], a: &[f32], b: &[f32]| {
for ((o, &a), &b) in o.iter_mut().zip(a).zip(b) {
*o = f(a, b);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
out.par_chunks_mut(CHUNK).zip(a.par_chunks(CHUNK)).zip(b.par_chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
}
#[cfg(target_arch = "wasm32")]
out.chunks_mut(CHUNK).zip(a.chunks(CHUNK)).zip(b.chunks(CHUNK)).for_each(|((o, a), b)| fill(o, a, b));
out
}
/// `f(a[i])` for every `i`, in parallel on native targets (elementwise, so bit for bit serial).
fn par_map1(a: &[f32], f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
par_zip(a, a, |x, _| f(x))
}
// `reduce`, `expand` and `push_pull` run one pyramid level for one image at a time, and the
// images themselves only fill part of a many-core machine, so each of them also runs its rows
// in parallel. Every output element is computed by the same expression, in the same order, as
// the one-thread loops they replace (rows never read each other's output), so the result is
// bit for bit unchanged.
fn reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
par_rows(&mut tmp, nw, ch, |y, row| {
let line = &src[y * w * ch..(y + 1) * w * ch];
for x in 0..nw {
// The clamped source column of each tap is the same for every channel.
let xs: [usize; 5] = std::array::from_fn(|t| (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize * ch);
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
for (kv, xx) in k.iter().zip(xs) {
s += kv * line[xx + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
row[x * ch + c] = s / 16.0;
}
}
}
});
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
par_rows(&mut out, nw, ch, |y, row| {
// Each output row is five source rows added in tap order: the same sum per element as
// before (`0 + k0·a + k1·b + …`), but over whole rows so it vectorises.
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
for (o, &v) in row.iter_mut().zip(&tmp[yy * nw * ch..(yy + 1) * nw * ch]) {
*o += kv * v;
}
}
}
for o in row.iter_mut() {
*o /= 16.0;
}
});
(nw, nh, out)
}
fn expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
// Source columns and weights depend on x only: worked out once instead of for every row.
// The clamped position is never negative, so truncating is `floor` (and spares the libm call).
let cols: Vec<(usize, usize, f32)> = (0..w)
.map(|x| {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
let x0 = fx as usize;
(x0 * ch, (x0 + 1).min(sw - 1) * ch, fx - x0 as f32)
})
.collect();
par_rows(&mut out, w, ch, |y, row| {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let y0 = fy as usize;
let ty = fy - y0 as f32;
let y1 = (y0 + 1).min(sh - 1);
let (r0, r1) = (&src[y0 * sw * ch..(y0 + 1) * sw * ch], &src[y1 * sw * ch..(y1 + 1) * sw * ch]);
for (px, &(x0, x1, tx)) in row.chunks_exact_mut(ch.max(1)).zip(&cols) {
for (c, o) in px.iter_mut().enumerate() {
let top = r0[x0 + c] + (r0[x1 + c] - r0[x0 + c]) * tx;
let bot = r1[x0 + c] + (r1[x1 + c] - r1[x0 + c]) * tx;
*o = top + (bot - top) * ty;
}
}
}
});
out
}
@@ -1058,41 +1099,75 @@ fn push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
// Coarse sums. A coarse row is fed by two fine rows; walking them in raster order adds each
// coarse cell's (up to) four fine pixels in the order the serial scatter-add did, from zero.
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
par_rows(&mut cw, nw, 1, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for (x, &a) in wt[y * w..(y + 1) * w].iter().enumerate() {
row[x / 2] += a;
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
});
let px_in: &[f32] = px;
par_rows(&mut cpx, nw, ch, |cy, row| {
for y in 2 * cy..(2 * cy + 2).min(h) {
for x in 0..w {
let (i, j) = (y * w + x, x / 2);
let a = wt[i];
for c in 0..ch {
row[j * ch + c] += a * px_in[i * ch + c];
}
}
cw[j] = 1.0;
}
});
// Normalise by the weight (this reads `cw` before it is reset to 0 / 1 below).
par_rows(&mut cpx, nw, ch, |cy, row| {
for cx in 0..nw {
let wj = cw[cy * nw + cx];
if wj > 0.0 {
for c in 0..ch {
row[cx * ch + c] /= wj;
}
}
}
});
for v in cw.iter_mut() {
if *v > 0.0 {
*v = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
par_rows(px, w, ch, |y, row| {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
row[x * ch + c] = a * row[x * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
});
}
/// One image's pyramid in [`multiband`]: the grid origin `(gx0, gy0)` at level 0, the Laplacian
/// levels `(w, h, colours)` and the Gaussian weight levels.
type PyramidBand = (i32, i32, Vec<(usize, usize, Vec<f32>)>, Vec<Vec<f32>>);
/// The part of `band` on panorama row `py` of level `l`: the panorama column its first pixel
/// lands on, its width, its weight row and its colour row. `None` when it misses the row.
fn band_row(band: &PyramidBand, l: usize, ch: usize, py: usize) -> Option<(usize, usize, &[f32], &[f32])> {
let (ox, oy) = ((band.0 >> l) as usize, (band.1 >> l) as usize);
let (lw, lh, colours) = &band.2[l];
(py >= oy && py - oy < *lh).then(|| {
let y = py - oy;
(ox, *lw, &band.3[l][y * lw..(y + 1) * lw], &colours[y * lw * ch..(y + 1) * lw * ch])
})
}
/// Multi-band blend of `imgs` into a `w × h` panorama (origin 0,0) with one weight map per image
@@ -1119,22 +1194,37 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
// Each grid pixel takes the image pixel that lands on it (every image pixel lands on at
// most one), so rows of the grid are independent and fill in parallel.
let src_of = |gx: usize, gy: usize| -> Option<usize> {
let (x, y) = (gx as i64 + gx0 as i64 - im.x0 as i64, gy as i64 + gy0 as i64 - im.y0 as i64);
(x >= 0 && y >= 0 && (x as usize) < im.w && (y as usize) < im.h).then(|| y as usize * im.w + x as usize)
};
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
par_rows(&mut px, gw, ch, |gy, row| {
for gx in 0..gw {
if let Some(i) = src_of(gx, gy) {
row[gx * ch..(gx + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
});
let mut cov = vec![0.0f32; gw * gh];
par_rows(&mut cov, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = im.alpha[i];
}
}
});
let mut wt = vec![0.0f32; gw * gh];
par_rows(&mut wt, gw, 1, |gy, row| {
for (gx, o) in row.iter_mut().enumerate() {
if let Some(i) = src_of(gx, gy) {
*o = weights[k][i] * im.alpha[i].min(1.0);
}
}
});
let bin: Vec<f32> = par_map1(&cov, |a| if a > 0.5 { 1.0 } else { 0.0 });
push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
@@ -1148,7 +1238,7 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
let (nw, nh, down) = reduce(cw, chh, ch, &cur);
let up = expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
lap.push((cw, chh, par_zip(&cur, &up, |a, b| a - b)));
gau.push(cwt.clone());
let (_, _, wdown) = reduce(cw, chh, 1, &cwt);
cw = nw;
@@ -1158,41 +1248,57 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
// Accumulate the bands. Each pixel adds the bands in the same order as the serial loop (per
// level, bands one after another), but rows of a level are independent, so they run in
// parallel: every row walks the bands itself.
for l in 0..levels {
let pw = sizes[l].0;
par_rows(&mut wsum[l], pw, 1, |py, row| {
for b in &bands {
let Some((ox, lw, g, _)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
let k = g[x];
if k <= 0.0 {
continue;
}
row[px] += k;
}
}
});
par_rows(&mut acc[l], pw, ch, |py, row| {
for b in &bands {
let Some((ox, lw, g, band)) = band_row(b, l, ch, py) else { continue };
for x in 0..lw {
let px = ox + x;
if px >= pw {
break;
}
let k = g[x];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
row[px * ch + c] += k * band[x * ch + c];
}
}
}
}
});
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
let pw = sizes[l].0;
let ws = &wsum[l];
par_rows(&mut acc[l], pw, ch, |py, row| {
for x in 0..pw {
let k = ws[py * pw + x];
for c in 0..ch {
row[x * ch + c] = if k > 1e-8 { row[x * ch + c] / k } else { 0.0 };
}
}
}
});
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
@@ -1201,22 +1307,27 @@ pub fn multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], le
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
cur = par_zip(&acc[l], &up, |a, b| a + b);
}
// Coverage: union of the images.
// Coverage: union of the images. Rows are independent; each walks the images in order, so a
// pixel sees the same sequence of `max` calls as in the serial loop.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
par_rows(&mut cov, w, 1, |py, row| {
for im in imgs {
let y = py as i64 - im.y0 as i64;
if y < 0 || y as usize >= im.h {
continue;
}
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
let px = im.x0 + x as i32;
if px < 0 || px as usize >= w {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
let p = px as usize;
row[p] = row[p].max(im.alpha[y as usize * im.w + x]);
}
}
}
});
(cur, cov)
}
@@ -1431,3 +1542,389 @@ mod tests {
assert!(cov.iter().all(|c| *c == 1.0));
}
}
/// The row-parallel `reduce` / `expand` / `push_pull` / `multiband` against the serial code they
/// replaced, kept below verbatim as the reference. Every output must stay bit for bit the same.
#[cfg(test)]
mod parallel_tests {
use super::*;
fn mix(mut z: u64) -> u64 {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Deterministic data from integer arithmetic only (no libm): mostly `0..1`, with negatives,
/// signed zeros and large values so a different order of operations cannot hide.
fn noise(len: usize, seed: u64) -> Vec<f32> {
(0..len as u64)
.map(|i| {
let z = mix(seed.wrapping_mul(0x1234_5678_9ABC).wrapping_add(i));
let v = (z >> 40) as f32 / 16_777_216.0;
match z & 31 {
0 => -v,
1 => -0.0,
2 => v * 1000.0,
3 => 0.0,
_ => v,
}
})
.collect()
}
/// Weights with exact zeros, exact ones and partial values, in blobs and noise.
fn weights(w: usize, h: usize, seed: u64) -> Vec<f32> {
(0..w * h)
.map(|i| {
let (x, y) = (i % w, i / w);
let z = mix(seed + i as u64);
if (x / 5 + y / 3) % 3 == 0 {
0.0
} else if z.is_multiple_of(7) {
((z >> 20) % 100) as f32 / 100.0
} else {
1.0
}
})
.collect()
}
fn bits(v: &[f32]) -> Vec<u32> {
v.iter().map(|x| x.to_bits()).collect()
}
fn fnv(parts: &[&[f32]]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in parts {
for x in *p {
h ^= x.to_bits() as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Sizes that cross the odd / even and tiny cases (1 wide, 1 high, 1×1, odd, power of two + 1).
const SIZES: [(usize, usize); 12] = [(1, 1), (1, 9), (9, 1), (2, 2), (3, 5), (4, 4), (17, 13), (64, 40), (65, 41), (97, 61), (130, 7), (7, 130)];
fn ref_reduce(w: usize, h: usize, ch: usize, src: &[f32]) -> (usize, usize, Vec<f32>) {
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let k = [1.0f32, 4.0, 6.0, 4.0, 1.0];
// Separable 5-tap binomial, then decimate.
let mut tmp = vec![0.0f32; nw * h * ch];
for y in 0..h {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let xx = (2 * x as i64 + t as i64 - 2).clamp(0, w as i64 - 1) as usize;
s += kv * src[(y * w + xx) * ch + c];
}
tmp[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
let mut out = vec![0.0f32; nw * nh * ch];
for y in 0..nh {
for x in 0..nw {
for c in 0..ch {
let mut s = 0.0;
for (t, kv) in k.iter().enumerate() {
let yy = (2 * y as i64 + t as i64 - 2).clamp(0, h as i64 - 1) as usize;
s += kv * tmp[(yy * nw + x) * ch + c];
}
out[(y * nw + x) * ch + c] = s / 16.0;
}
}
}
(nw, nh, out)
}
fn ref_expand(sw: usize, sh: usize, ch: usize, src: &[f32], w: usize, h: usize) -> Vec<f32> {
// Bilinear upsampling at half-pixel-aligned positions.
let mut out = vec![0.0f32; w * h * ch];
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sh - 1) as f32);
let (y0, ty) = (fy.floor() as usize, fy - fy.floor());
let y1 = (y0 + 1).min(sh - 1);
for x in 0..w {
let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (sw - 1) as f32);
let (x0, tx) = (fx.floor() as usize, fx - fx.floor());
let x1 = (x0 + 1).min(sw - 1);
for c in 0..ch {
let p = |xx: usize, yy: usize| src[(yy * sw + xx) * ch + c];
let top = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * tx;
let bot = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * tx;
out[(y * w + x) * ch + c] = top + (bot - top) * ty;
}
}
}
out
}
fn ref_push_pull(w: usize, h: usize, ch: usize, px: &mut [f32], wt: &[f32]) {
if w <= 1 && h <= 1 {
return;
}
let (nw, nh) = (w.div_ceil(2), h.div_ceil(2));
let mut cpx = vec![0.0f32; nw * nh * ch];
let mut cw = vec![0.0f32; nw * nh];
for y in 0..h {
for x in 0..w {
let (i, j) = (y * w + x, (y / 2) * nw + x / 2);
let a = wt[i];
cw[j] += a;
for c in 0..ch {
cpx[j * ch + c] += a * px[i * ch + c];
}
}
}
for j in 0..nw * nh {
if cw[j] > 0.0 {
for c in 0..ch {
cpx[j * ch + c] /= cw[j];
}
cw[j] = 1.0;
}
}
if cw.contains(&0.0) && cw.iter().any(|v| *v > 0.0) {
ref_push_pull(nw, nh, ch, &mut cpx, &cw);
}
for y in 0..h {
for x in 0..w {
let i = y * w + x;
if wt[i] < 1.0 {
let j = (y / 2) * nw + x / 2;
let a = wt[i];
for c in 0..ch {
px[i * ch + c] = a * px[i * ch + c] + (1.0 - a) * cpx[j * ch + c];
}
}
}
}
}
fn ref_multiband(w: usize, h: usize, imgs: &[RoiImage], weights: &[Vec<f32>], levels: usize) -> (Vec<f32>, Vec<f32>) {
let ch = imgs.first().map_or(1, |i| i.ch);
let levels = levels.max(1);
let align = 1i32 << (levels - 1);
// Level sizes of the panorama.
let mut sizes = vec![(w, h)];
for _ in 1..levels {
let Some(&(pw, ph)) = sizes.last() else { break };
sizes.push((pw.div_ceil(2), ph.div_ceil(2)));
}
let mut acc: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh * ch]).collect();
let mut wsum: Vec<Vec<f32>> = sizes.iter().map(|&(lw, lh)| vec![0.0; lw * lh]).collect();
let margin = align * 4;
// Each image on its ROI grown by a margin and aligned to the coarsest level's grid.
let bands = par_map(imgs.len(), |k| {
let im = &imgs[k];
let gx0 = ((im.x0 - margin).max(0) / align) * align;
let gy0 = ((im.y0 - margin).max(0) / align) * align;
let gx1 = (im.x0 + im.w as i32 + margin).min(w as i32);
let gy1 = (im.y0 + im.h as i32 + margin).min(h as i32);
let (gw, gh) = ((gx1 - gx0).max(1) as usize, (gy1 - gy0).max(1) as usize);
let mut px = vec![0.0f32; gw * gh * ch];
let mut cov = vec![0.0f32; gw * gh];
let mut wt = vec![0.0f32; gw * gh];
for y in 0..im.h {
for x in 0..im.w {
let (gx, gy) = (im.x0 + x as i32 - gx0, im.y0 + y as i32 - gy0);
if gx < 0 || gy < 0 || gx as usize >= gw || gy as usize >= gh {
continue;
}
let (i, g) = (y * im.w + x, gy as usize * gw + gx as usize);
cov[g] = im.alpha[i];
wt[g] = weights[k][i] * im.alpha[i].min(1.0);
px[g * ch..(g + 1) * ch].copy_from_slice(&im.px[i * ch..(i + 1) * ch]);
}
}
let bin: Vec<f32> = cov.iter().map(|a| if *a > 0.5 { 1.0 } else { 0.0 }).collect();
ref_push_pull(gw, gh, ch, &mut px, &bin);
// Laplacian of the colours, Gaussian of the weights.
let mut lap: Vec<(usize, usize, Vec<f32>)> = Vec::new();
let mut gau: Vec<Vec<f32>> = Vec::new();
let (mut cw, mut chh, mut cur, mut cwt) = (gw, gh, px, wt);
for l in 0..levels {
if l + 1 == levels {
lap.push((cw, chh, cur.clone()));
gau.push(cwt.clone());
break;
}
let (nw, nh, down) = ref_reduce(cw, chh, ch, &cur);
let up = ref_expand(nw, nh, ch, &down, cw, chh);
lap.push((cw, chh, cur.iter().zip(&up).map(|(a, b)| a - b).collect()));
gau.push(cwt.clone());
let (_, _, wdown) = ref_reduce(cw, chh, 1, &cwt);
cw = nw;
chh = nh;
cur = down;
cwt = wdown;
}
(gx0, gy0, lap, gau)
});
for (gx0, gy0, lap, gau) in &bands {
for (l, ((lw, lh, band), g)) in lap.iter().zip(gau).enumerate() {
let (ox, oy) = ((gx0 >> l) as usize, (gy0 >> l) as usize);
let (pw, ph) = sizes[l];
for y in 0..*lh {
let py = oy + y;
if py >= ph {
break;
}
for x in 0..*lw {
let px = ox + x;
if px >= pw {
break;
}
let (i, p) = (y * lw + x, py * pw + px);
let k = g[i];
if k <= 0.0 {
continue;
}
wsum[l][p] += k;
for c in 0..ch {
acc[l][p * ch + c] += k * band[i * ch + c];
}
}
}
}
}
for l in 0..levels {
let (pw, ph) = sizes[l];
for p in 0..pw * ph {
let k = wsum[l][p];
for c in 0..ch {
acc[l][p * ch + c] = if k > 1e-8 { acc[l][p * ch + c] / k } else { 0.0 };
}
}
}
// Collapse.
// `levels ≥ 1`, so there is always a coarsest level.
let Some(mut cur) = acc.pop() else { return (vec![0.0; w * h * ch], vec![0.0; w * h]) };
for l in (0..levels - 1).rev() {
let (pw, ph) = sizes[l];
let (sw, sh) = sizes[l + 1];
let up = ref_expand(sw, sh, ch, &cur, pw, ph);
cur = acc[l].iter().zip(&up).map(|(a, b)| a + b).collect();
}
// Coverage: union of the images.
let mut cov = vec![0.0f32; w * h];
for im in imgs {
for y in 0..im.h {
for x in 0..im.w {
let (px, py) = (im.x0 + x as i32, im.y0 + y as i32);
if px < 0 || py < 0 || px as usize >= w || py as usize >= h {
continue;
}
let p = py as usize * w + px as usize;
cov[p] = cov[p].max(im.alpha[y * im.w + x]);
}
}
}
(cur, cov)
}
#[test]
fn reduce_and_expand_match_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3, 4] {
let src = noise(w * h * ch, n as u64 * 7 + ch as u64);
let (nw, nh, got) = reduce(w, h, ch, &src);
let (rw, rh, want) = ref_reduce(w, h, ch, &src);
assert_eq!((nw, nh), (rw, rh));
assert_eq!(bits(&got), bits(&want), "reduce {w}x{h}x{ch}");
// Expand back to the original size and to sizes that are not twice the source's.
for (ew, eh) in [(w, h), (2 * nw, 2 * nh), (2 * nw - 1, 2 * nh + 1), (w + 3, h + 5)] {
let got = expand(nw, nh, ch, &want, ew, eh);
let want_up = ref_expand(nw, nh, ch, &want, ew, eh);
assert_eq!(bits(&got), bits(&want_up), "expand {nw}x{nh}x{ch} to {ew}x{eh}");
}
}
}
}
#[test]
fn push_pull_matches_the_serial_code() {
for (n, &(w, h)) in SIZES.iter().enumerate() {
for ch in [1usize, 3] {
for variant in 0..3u64 {
let wt = match variant {
0 => weights(w, h, n as u64),
1 => vec![1.0; w * h],
// A single covered pixel: every level of the recursion has to fill from it.
_ => (0..w * h).map(|i| if i == (w * h) / 3 { 1.0 } else { 0.0 }).collect(),
};
let px = noise(w * h * ch, 100 + n as u64);
let (mut got, mut want) = (px.clone(), px);
push_pull(w, h, ch, &mut got, &wt);
ref_push_pull(w, h, ch, &mut want, &wt);
assert_eq!(bits(&got), bits(&want), "push_pull {w}x{h}x{ch} variant {variant}");
}
}
}
}
/// ROIs of assorted sizes and offsets (some sticking out of the canvas), with holes in the alpha.
fn rois(w: usize, h: usize, ch: usize, seed: u64) -> (Vec<RoiImage>, Vec<Vec<f32>>) {
let specs =
[(0i32, 0i32, w * 2 / 3, h), (w as i32 / 3, -(h as i32) / 5, w * 2 / 3 + 1, h * 3 / 4 + 1), (-(w as i32) / 4, h as i32 / 2, w / 2 + 1, h / 2 + 3)];
let imgs: Vec<RoiImage> = specs
.iter()
.enumerate()
.map(|(k, &(x0, y0, iw, ih))| {
let (iw, ih) = (iw.max(1), ih.max(1));
let mut alpha = vec![1.0f32; iw * ih];
for (i, a) in alpha.iter_mut().enumerate() {
let z = mix(seed + k as u64 * 1000 + i as u64);
if (i % iw) / 4 % 5 == 2 && (i / iw) % 6 < 3 {
*a = 0.0;
} else if z.is_multiple_of(11) {
*a = 0.3;
}
}
RoiImage { x0, y0, w: iw, h: ih, ch, px: noise(iw * ih * ch, seed + k as u64), alpha }
})
.collect();
let wts = imgs.iter().enumerate().map(|(k, r)| weights(r.w, r.h, seed + 50 + k as u64)).collect();
(imgs, wts)
}
#[test]
fn multiband_matches_the_serial_code() {
for (n, &(w, h)) in [(1usize, 1usize), (5, 3), (40, 30), (97, 61), (130, 70), (64, 64), (201, 129)].iter().enumerate() {
for ch in [1usize, 3] {
let (imgs, wts) = rois(w, h, ch, n as u64 * 31 + ch as u64);
for levels in [1usize, 2, 3, 5, 7] {
let got = multiband(w, h, &imgs, &wts, levels);
let want = ref_multiband(w, h, &imgs, &wts, levels);
assert_eq!(bits(&got.0), bits(&want.0), "colours {w}x{h}x{ch} levels {levels}");
assert_eq!(bits(&got.1), bits(&want.1), "coverage {w}x{h}x{ch} levels {levels}");
}
}
}
// No images at all, and zero levels.
assert_eq!(multiband(9, 7, &[], &[], 3).0.len(), ref_multiband(9, 7, &[], &[], 3).0.len());
}
/// Hashes recorded from the code before it was made parallel (plain f32 arithmetic only, so
/// they hold on every platform).
#[test]
fn multiband_hashes_recorded_from_the_serial_code() {
let (imgs, wts) = rois(97, 61, 3, 5);
let (px, cov) = multiband(97, 61, &imgs, &wts, 5);
assert_eq!(fnv(&[&px, &cov]), 0xf89a5726082ebb6c, "97x61 rgb, 5 levels");
let (imgs, wts) = rois(130, 70, 1, 9);
let (px, cov) = multiband(130, 70, &imgs, &wts, 7);
assert_eq!(fnv(&[&px, &cov]), 0xb7d7ddc59d201730, "130x70 grey, 7 levels");
let wt = weights(65, 41, 3);
let mut px = noise(65 * 41 * 3, 4);
push_pull(65, 41, 3, &mut px, &wt);
assert_eq!(fnv(&[&px]), 0x50ea36a84ca9a68f, "push_pull 65x41 rgb");
let (nw, nh, r) = reduce(65, 41, 3, &px);
assert_eq!(fnv(&[&r, &expand(nw, nh, 3, &r, 65, 41)]), 0x51823acfb4206dd8, "reduce + expand 65x41 rgb");
}
}
+3 -2
View File
@@ -146,8 +146,9 @@ pub(crate) fn lens_flare(src: &Image, out: Rect, ctx: &Ctx, brightness: f32, cen
let mut c = rgba(ctx, px);
for k in 0..3 {
let a = (f[k] * bright).max(0.0);
// Screen keeps highlights below white for display-referred values.
c[k] = 1.0 - (1.0 - c[k]) * (1.0 - a.min(1.0));
// Screen (c + a·(1 − c)) keeps display-referred values below white; a 32-bit
// value already above white is left as is rather than pulled down towards it.
c[k] += a.min(1.0) * (1.0 - c[k]).max(0.0);
}
set_rgba(ctx, px, c);
}
+391 -50
View File
@@ -23,85 +23,184 @@ pub enum Stat {
Variance,
}
fn stat(v: &mut [f32], s: Stat) -> f32 {
/// Reusable per-chunk scratch for [`Stat::Entropy`]: an 8-bit histogram plus the list of bins the
/// current pixel touched. Allocated once per parallel chunk instead of one `HashMap` per channel
/// per pixel (the old cost: ~4.6 s for 3 × 24 MP).
struct EntropyScratch {
counts: [u32; 256],
touched: Vec<u8>,
}
impl EntropyScratch {
fn new() -> Self {
Self { counts: [0; 256], touched: Vec::new() }
}
}
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
for x in v {
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
let c = &mut scratch.counts[usize::from(bin)];
if *c == 0 {
scratch.touched.push(bin);
}
*c += 1;
}
// Sum the bins in ascending order. The old HashMap summed in its (randomly seeded) iteration
// order, and float addition is not associative, so the old result could differ in the last
// bit from run to run; a fixed order makes it deterministic. Sorting only the few touched
// bins keeps this O(samples), not O(256), per pixel.
scratch.touched.sort_unstable();
let h: f32 = scratch
.touched
.iter()
.map(|&bin| {
let p = scratch.counts[usize::from(bin)] as f32 / n;
-p * p.log2()
})
.sum();
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
for &bin in &scratch.touched {
scratch.counts[usize::from(bin)] = 0;
}
scratch.touched.clear();
h / n.log2()
}
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
/// result has the same bits on every target. On Windows (MSVC), `f32::powi` with an exponent the
/// compiler cannot see as a constant calls the C runtime's `powf`, which rounds differently.
fn ipow(x: f32, k: u32) -> f32 {
let (mut base, mut k, mut acc) = (x, k, 1.0f32);
loop {
if k & 1 != 0 {
acc *= base;
}
k >>= 1;
if k == 0 {
return acc;
}
base *= base;
}
}
fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
let n = v.len() as f32;
if v.is_empty() {
return 0.0;
}
// Modes that need neither the mean nor the moments return before computing them (the old code
// computed the mean for every mode and threw it away; skipping it changes no result).
match s {
Stat::Summation => return v.iter().sum::<f32>(),
Stat::Minimum => return v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
// sort gives exactly the stable sort's result.
v.sort_unstable_by(f32::total_cmp);
let k = v.len();
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
return entropy(v, scratch);
}
_ => {}
}
let mean = v.iter().sum::<f32>() / n;
let m = |k: i32| v.iter().map(|x| (x - mean).powi(k)).sum::<f32>() / n;
let m = |k: u32| v.iter().map(|x| ipow(x - mean, k)).sum::<f32>() / n;
match s {
Stat::Mean => mean,
Stat::Summation => v.iter().sum::<f32>(),
Stat::Minimum => v.iter().copied().fold(f32::MAX, f32::min),
Stat::Maximum => v.iter().copied().fold(f32::MIN, f32::max),
Stat::Range => v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
Stat::Median => {
v.sort_by(f32::total_cmp);
let k = v.len();
if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 }
}
Stat::Variance => m(2),
Stat::StandardDeviation => m(2).sqrt(),
Stat::Skewness => {
let sd = m(2).sqrt();
// Map −2..2 to 0..1 (0.5 = symmetric).
if sd < 1e-6 { 0.5 } else { (m(3) / sd.powi(3) / 4.0 + 0.5).clamp(0.0, 1.0) }
if sd < 1e-6 { 0.5 } else { (m(3) / ipow(sd, 3) / 4.0 + 0.5).clamp(0.0, 1.0) }
}
Stat::Kurtosis => {
let var = m(2);
// 0..10 → 0..1.
if var < 1e-12 { 0.0 } else { (m(4) / (var * var) / 10.0).clamp(0.0, 1.0) }
}
Stat::Entropy => {
if v.len() < 2 {
return 0.0;
}
let mut counts: std::collections::HashMap<u8, u32> = std::collections::HashMap::new();
for x in v.iter() {
*counts.entry((x.clamp(0.0, 1.0) * 255.0).round() as u8).or_default() += 1;
}
let h: f32 = counts
.values()
.map(|&c| {
let p = c as f32 / n;
-p * p.log2()
})
.sum();
h / n.log2()
}
// Handled above; a no-op keeps the match exhaustive without a panic path.
Stat::Summation | Stat::Minimum | Stat::Maximum | Stat::Range | Stat::Median | Stat::Entropy => 0.0,
}
}
/// Output pixels per parallel task: big enough to amortise the per-task scratch buffers and
/// rayon's scheduling, small enough (6000 tasks for 24 MP) to balance across many cores.
const CHUNK: usize = 4096;
/// Combines `layers` (each `len` pixels) with statistic `s`. Colour results retain their float
/// range; quantisation/clipping belongs to the destination surface, not the statistic.
pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
// Pick the statistic once, outside the pixel loop: each arm passes `stat` a constant mode, so
// the compiler builds one loop per statistic with the `match` folded away. Matching on `s`
// three times per pixel inside one shared loop made one thread 5–15% slower than the old
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
// instructions by a fifth.
match s {
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
Stat::Median => combine_with(layers, |v, sc| stat(v, Stat::Median, sc)),
Stat::Minimum => combine_with(layers, |v, sc| stat(v, Stat::Minimum, sc)),
Stat::Range => combine_with(layers, |v, sc| stat(v, Stat::Range, sc)),
Stat::Skewness => combine_with(layers, |v, sc| stat(v, Stat::Skewness, sc)),
Stat::StandardDeviation => combine_with(layers, |v, sc| stat(v, Stat::StandardDeviation, sc)),
Stat::Summation => combine_with(layers, |v, sc| stat(v, Stat::Summation, sc)),
Stat::Variance => combine_with(layers, |v, sc| stat(v, Stat::Variance, sc)),
}
}
fn combine_with(layers: &[Vec<[f32; 4]>], f: impl Fn(&mut [f32], &mut EntropyScratch) -> f32 + Sync + Send) -> Vec<[f32; 4]> {
let len = layers.iter().map(Vec::len).max().unwrap_or(0);
let mut out = vec![[0.0f32; 4]; len];
let mut samples: [Vec<f32>; 3] = Default::default();
for (i, o) in out.iter_mut().enumerate() {
for v in samples.iter_mut() {
v.clear();
}
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c].push(p[c]);
// Every output pixel depends only on the input pixels at the same index, so chunks of the
// output are independent: `par_rows` over a `CHUNK`-wide "row" with one channel (it runs
// serially on wasm). The per-pixel arithmetic is unchanged, hence the result is bit-identical
// to the serial loop for every mode (Entropy: see [`entropy`]).
crate::photo_util::par_rows(&mut out, CHUNK, 1, |chunk, outs| {
// Scratch is per chunk, not per pixel: the old loop's three `Vec`s were already reused
// across pixels, so keep that, and give each task its own set.
// Sized to the layer count up front and filled by index (`k` samples per pixel): a
// `push` per sample re-checked the capacity and stored the length every time; indexing
// cuts the loop's instructions by another third.
let mut samples: [Vec<f32>; 3] = [0, 1, 2].map(|_| vec![0.0; layers.len()]);
let mut scratch = EntropyScratch::new();
let base = chunk * CHUNK;
for (j, o) in outs.iter_mut().enumerate() {
let i = base + j;
let mut k = 0;
let mut alpha = 0.0f32;
for l in layers {
if let Some(p) = l.get(i)
&& p[3] > 0.0
{
for c in 0..3 {
samples[c][k] = p[c];
}
k += 1;
alpha = alpha.max(p[3]);
}
alpha = alpha.max(p[3]);
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = f(&mut samples[c][..k], &mut scratch);
}
o[3] = alpha;
}
if alpha <= 0.0 {
continue;
}
for c in 0..3 {
o[c] = stat(&mut samples[c], s);
}
o[3] = alpha;
}
});
out
}
@@ -168,4 +267,246 @@ mod tests {
let layers = vec![px(0.5), px(0.5), px(1.0)];
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
}
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
fn fixture() -> Vec<Vec<[f32; 4]>> {
vec![
vec![[0.10, 0.20, 0.30, 1.0], [0.5, 0.5, 0.5, 1.0], [0.9, 0.9, 0.9, 0.0], [-2.0, 4.0, 0.0, 1.0], [0.7, 0.2, 0.1, 0.5], [0.25, 0.25, 0.25, 1.0]],
vec![[0.90, 0.60, 0.30, 1.0], [0.0, 0.0, 0.0, 0.0], [0.8, 0.8, 0.8, 0.0], [3.0, -1.0, 0.5, 0.75], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.25, 1.0]],
vec![[0.20, 0.90, 0.30, 0.5], [0.1, 0.2, 0.3, 1.0], [0.7, 0.7, 0.7, 0.0], [0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.5, 0.75, 0.25, 0.25]],
vec![[0.30, 0.05, 0.30, 0.25], [0.3, 0.4, 0.5, 0.5], [0.6, 0.6, 0.6, 0.0], [1.5, 1.5, 1.5, 1.0], [0.0, 0.0, 0.0, 0.0], [0.25, 0.75, 0.5, 1.0]],
vec![[0.10, 0.70, 0.99, 1.0], [0.9, 0.1, 0.2, 1.0], [0.5, 0.5, 0.5, 0.0], [0.0, 0.0, 0.0, 1.0]],
]
}
fn bits(px: &[[f32; 4]]) -> Vec<[u32; 4]> {
px.iter().map(|p| p.map(f32::to_bits)).collect()
}
/// Every mode except Entropy is bit-for-bit what the old serial loop produced. The expected
/// bit patterns were recorded by running the unmodified `combine` on [`fixture`].
#[test]
fn statistics_are_bit_identical_to_the_serial_implementation() {
let layers = fixture();
let expected: Vec<(Stat, Vec<[u32; 4]>)> = vec![
(
Stat::Kurtosis,
[
[0x3e96956b, 0x3e182e3f, 0x3ea66664, 0x3f800000],
[0x3e3d004c, 0x3e0b4396, 0x3e030e1f, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3e594eb2, 0x3e6ef420, 0x3e7ffffe, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e6eeeee, 0x3e6eeeee, 0x3e6eeeee, 0x3f800000],
]
.to_vec(),
),
(
Stat::Maximum,
[
[0x3f666666, 0x3f666666, 0x3f7d70a4, 0x3f800000],
[0x3f666666, 0x3f000000, 0x3f000000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40800000, 0x3fc00000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3f000000, 0x3f400000, 0x3f000000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Mean,
[
[0x3ea3d70a, 0x3efae148, 0x3ee0418a, 0x3f800000],
[0x3ee66666, 0x3e99999a, 0x3ec00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f19999a, 0x3f800000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3ea00000, 0x3f200000, 0x3ea00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Median,
[
[0x3e4ccccd, 0x3f19999a, 0x3e99999a, 0x3f800000],
[0x3ecccccd, 0x3e99999a, 0x3ecccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3f400000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Minimum,
[
[0x3dcccccd, 0x3d4ccccd, 0x3e99999a, 0x3f800000],
[0x3dcccccd, 0x3dcccccd, 0x3e4ccccd, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0xc0000000, 0xbf800000, 0x00000000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3e800000, 0x3e800000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Range,
[
[0x3f4ccccc, 0x3f599999, 0x3f30a3d7, 0x3f800000],
[0x3f4ccccc, 0x3ecccccd, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40a00000, 0x40a00000, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3e800000, 0x3f000000, 0x3e800000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Skewness,
[
[0x3f522026, 0x3ee7cf76, 0x3f600000, 0x3f800000],
[0x3f1bd152, 0x3efffffe, 0x3ee4a11b, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3eedb65a, 0x3f2ea754, 0x3f3a6c7a, 0x3f800000],
[0x3f000000, 0x3f000000, 0x3f000000, 0x3f000000],
[0x3f49e69e, 0x3e58658a, 0x3f49e69e, 0x3f800000],
]
.to_vec(),
),
(
Stat::StandardDeviation,
[
[0x3e99421e, 0x3ea23b6b, 0x3e8d4fdf, 0x3f800000],
[0x3e97739e, 0x3e21e89b, 0x3e050581, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x3fd3e0b1, 0x3fd9f9e5, 0x3f0c378c, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3dddb3d7, 0x3e5db3d7, 0x3dddb3d7, 0x3f800000],
]
.to_vec(),
),
(
Stat::Summation,
[
[0x3fcccccd, 0x401ccccd, 0x400c28f6, 0x3f800000],
[0x3fe66666, 0x3f99999a, 0x3fc00000, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x40400000, 0x40a00000, 0x40200000, 0x3f800000],
[0x3f333333, 0x3e4ccccd, 0x3dcccccd, 0x3f000000],
[0x3fa00000, 0x40200000, 0x3fa00000, 0x3f800000],
]
.to_vec(),
),
(
Stat::Variance,
[
[0x3db78033, 0x3dcd9e83, 0x3d9c022a, 0x3f800000],
[0x3db33332, 0x3ccccccd, 0x3c8a3d70, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x00000000],
[0x402f5c29, 0x4039999a, 0x3e99999a, 0x3f800000],
[0x00000000, 0x00000000, 0x00000000, 0x3f000000],
[0x3c400000, 0x3d400000, 0x3c400000, 0x3f800000],
]
.to_vec(),
),
];
for (mode, want) in expected {
assert_eq!(bits(&combine(&layers, mode)), want, "{mode:?}");
}
}
/// Entropy against values worked out by hand (bits of the 8-bit histogram, over log2(N)).
#[test]
fn entropy_matches_hand_computed_values() {
let out = combine(&fixture(), Stat::Entropy);
let h = |ps: &[f64]| -> f64 {
let n: f64 = ps.iter().sum();
ps.iter().map(|c| -(c / n) * (c / n).log2()).sum::<f64>() / n.log2()
};
// Pixel 1: four distinct values -> 2 bits / log2(4) = exactly 1.
assert_eq!(out[1][0], 1.0);
// Pixel 2: every sample transparent -> untouched.
assert_eq!(out[2], [0.0; 4]);
// Pixel 3, red: -2 and 0 land in bin 0, 3 and 1.5 clamp into bin 255, 0.5 is alone.
assert!((f64::from(out[3][0]) - h(&[2.0, 2.0, 1.0])).abs() < 1e-6, "{}", out[3][0]);
// Pixel 4: a single contributing sample has no entropy.
assert_eq!(out[4], [0.0, 0.0, 0.0, 0.5]);
// Pixel 5: three samples share a bin, one is alone, in every channel.
for (c, v) in out[5].iter().take(3).enumerate() {
assert!((f64::from(*v) - h(&[3.0, 1.0])).abs() < 1e-6, "channel {c}: {v}");
}
}
/// The old HashMap summed in a random order, so Entropy could differ by an ulp between runs.
/// Now the bins are summed in ascending order: repeated runs (and any thread count) agree.
#[test]
fn entropy_is_deterministic() {
let layers: Vec<Vec<[f32; 4]>> = (0..7)
.map(|k| {
(0..3 * CHUNK + 17).map(|i| [((i * 7 + k * 13) % 11) as f32 / 10.0, ((i + k) % 5) as f32 / 4.0, ((i * k) % 9) as f32 / 8.0, 1.0]).collect()
})
.collect();
let first = bits(&combine(&layers, Stat::Entropy));
for _ in 0..8 {
assert_eq!(bits(&combine(&layers, Stat::Entropy)), first);
}
}
/// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal
/// length) gives, pixel by pixel, what combining that pixel on its own gives.
#[test]
fn chunked_result_equals_per_pixel_result() {
let len = 2 * CHUNK + 5;
let layer = |k: usize, n: usize| -> Vec<[f32; 4]> {
(0..n)
.map(|i| {
[
((i * 31 + k * 7) % 101) as f32 / 100.0,
((i * 17 + k) % 53) as f32 / 52.0 - 0.2,
((i + 3 * k) % 29) as f32 / 28.0,
if (i + k).is_multiple_of(4) { 0.0 } else { 0.5 + ((i + k) % 3) as f32 / 4.0 },
]
})
.collect()
};
let layers = vec![layer(1, len), layer(2, CHUNK + 3), layer(3, len - 1), layer(4, 0), layer(5, len)];
for mode in [
Stat::Entropy,
Stat::Kurtosis,
Stat::Maximum,
Stat::Mean,
Stat::Median,
Stat::Minimum,
Stat::Range,
Stat::Skewness,
Stat::StandardDeviation,
Stat::Summation,
Stat::Variance,
] {
let whole = combine(&layers, mode);
assert_eq!(whole.len(), len);
for i in (0..len).step_by(97).chain([CHUNK - 1, CHUNK, CHUNK + 1, 2 * CHUNK - 1, 2 * CHUNK, len - 1]) {
let one: Vec<Vec<[f32; 4]>> = layers.iter().map(|l| l.get(i).map(|p| vec![*p]).unwrap_or_default()).collect();
assert_eq!(whole[i].map(f32::to_bits), combine(&one, mode)[0].map(f32::to_bits), "{mode:?} pixel {i}");
}
}
}
#[test]
fn no_layers_gives_an_empty_result() {
assert!(combine(&[], Stat::Median).is_empty());
}
/// [`ipow`] multiplies in the order `x·x²` for the cube and `x²·x²` for the fourth power.
#[test]
fn ipow_squares_and_multiplies() {
for x in [0.58f32, -0.12, -2.6, 2.4, -0.6, 1.0e-3, 0.0] {
assert_eq!(ipow(x, 0).to_bits(), 1.0f32.to_bits());
assert_eq!(ipow(x, 1).to_bits(), x.to_bits());
assert_eq!(ipow(x, 2).to_bits(), (x * x).to_bits());
assert_eq!(ipow(x, 3).to_bits(), (x * (x * x)).to_bits());
assert_eq!(ipow(x, 4).to_bits(), ((x * x) * (x * x)).to_bits());
}
}
}
+18
View File
@@ -414,6 +414,24 @@ fn twirl_keeps_centre_and_outside() {
assert_ne!(out.pixel(20, 12), s.pixel(20, 12));
}
/// #5643: on a wide image Twirl, Pinch and Spherize fill the ellipse inscribed in the bounds, as
/// Photoshop's do, not just a circle fitted to the height: a point near the left and right edges
/// on the middle row changes, the corners don't, and a square still uses its inscribed circle.
#[test]
fn round_distortions_fill_the_inscribed_ellipse() {
let b = Rect::new(0, 0, 80, 40);
let s = pattern(SampleType::F32, b);
for p in [FilterParams::Twirl { angle: 180.0 }, FilterParams::Pinch { amount: 80.0 }, FilterParams::Spherize { amount: 80.0, mode: SpherizeMode::Normal }] {
let out = apply(&s, &p, b, b, None);
// 30 px left of centre on the middle row: outside the old 20 px circle, inside the ellipse.
assert_ne!(out.pixel(10, 21), s.pixel(10, 21), "{p:?}");
assert_ne!(out.pixel(69, 18), s.pixel(69, 18), "{p:?}");
for (x, y) in [(0, 0), (79, 0), (0, 39), (79, 39)] {
assert_eq!(out.pixel(x, y), s.pixel(x, y), "{p:?}: corner ({x}, {y}) is outside the ellipse");
}
}
}
#[test]
fn motion_blur_spreads_along_angle_only() {
let mut s = flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]);
+51
View File
@@ -435,6 +435,35 @@ fn lens_flare_is_brightest_at_its_centre() {
assert!(out.pixel(38, 2)[0] >= 0.2 - 1e-6, "screen never darkens");
}
#[test]
fn lens_flare_never_darkens_32_bit_values_above_one() {
// #5785: the screen formula turned 2.0 into 1.5 (and 1.0 at the core).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let out = run(&flat(SampleType::F32, R, [2.0, 2.0, 2.0, 1.0]), &p);
for px in out.read_region(R).as_chunks::<4>().0 {
assert!(px[..3].iter().all(|&v| v >= 2.0), "{px:?}");
}
}
#[test]
fn lens_flare_8_bit_result_is_the_screen_blend() {
// The flare amount per pixel, read from a black 32-bit image (screen over 0 is the amount).
let p = FilterParams::LensFlare { brightness: 150.0, center_x: 0.25, center_y: 0.5, lens: LensType::Zoom };
let amount = run(&flat(SampleType::F32, R, [0.0, 0.0, 0.0, 1.0]), &p).read_region(R);
let src = pattern(SampleType::U8, R);
let base = src.read_region(R);
let expected: Vec<f32> = base
.as_chunks::<4>()
.0
.iter()
.zip(amount.as_chunks::<4>().0)
.flat_map(|(c, a)| [0, 1, 2].map(|k| 1.0 - (1.0 - c[k]) * (1.0 - a[k].min(1.0))).into_iter().chain([c[3]]))
.collect();
let mut want = Surface::new(fmt(SampleType::U8));
want.write_region(R, &expected);
assert_eq!(run(&src, &p).read_region(R), want.read_region(R));
}
#[test]
fn lighting_point_light_falls_off() {
let s = flat(SampleType::F32, R, [0.8, 0.8, 0.8, 1.0]);
@@ -608,6 +637,28 @@ fn custom_kernel_shifts_and_offsets() {
assert!((off.pixel(5, 5)[0] - 0.4).abs() < 1e-5);
}
/// CMYK samples are ink: Custom's positive offset brightens (removes ink), as in Photoshop
/// (identity, scale 1, offset +50: white stays white, C=100% becomes C≈80%). Alpha is untouched.
/// Photoshop's CMYK depths; float is unclamped on both polarities (RGB white + offset exceeds 1).
#[test]
fn custom_offset_brightens_cmyk() {
let mut id = vec![0.0; 25];
id[12] = 1.0;
let p = FilterParams::Custom { kernel: id, scale: 1.0, offset: 50.0 };
for depth in [SampleType::U8, SampleType::U16] {
let f = PixelFormat::new(ColorMode::Cmyk, depth, true);
let mut s = Surface::new(f);
s.fill_rect(R, &[0.0, 0.0, 0.0, 0.0, 0.6]);
let w = run(&s, &p).pixel(5, 5);
assert!(w[..4].iter().all(|v| v.abs() < 1e-6) && (w[4] - 0.6).abs() < 0.01, "{depth:?} white: {w:?}");
let mut s = Surface::new(f);
s.fill_rect(R, &[1.0, 0.5, 0.0, 0.0, 1.0]);
let c = run(&s, &p).pixel(5, 5);
let want = [1.0 - 50.0 / 255.0, 0.5 - 50.0 / 255.0, 0.0, 0.0, 1.0];
assert!(c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} C100: {c:?}");
}
}
#[test]
fn hsb_filter_encodes_and_decodes() {
let s = flat(SampleType::F32, R, [1.0, 0.0, 0.0, 1.0]);
+52 -4
View File
@@ -231,14 +231,37 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option<Homography> {
/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners
/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to
/// infinity (the result is then empty). Lets a caller price a warp before allocating it.
/// infinity (the result is then empty). Use [`warp_output_size`] to price the full result before
/// admitting a transform: clipping these bounds must not hide an excessive scale.
pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option<Rect> {
corner_bounds(&warp_corners(src_rect, h)?)
}
/// Unclipped output dimensions, including the same filter support as [`warp_output_bounds`].
/// Position does not affect the price, so translating a small layer far away stays cheap.
/// Unrepresentable or non-finite dimensions saturate at `u64::MAX`, allowing a caller to refuse
/// the warp with a saturating pixel-budget comparison. Empty and singular inputs have no output.
pub fn warp_output_size(src_rect: Rect, h: &Homography) -> Option<(u64, u64)> {
let corners = warp_corners(src_rect, h)?;
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite()) {
return Some((u64::MAX, u64::MAX));
}
let x0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor();
let y0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor();
let x1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil();
let y1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil();
// Float-to-integer casts saturate, including when the finite endpoints' difference overflows.
Some((((x1 - x0) as u64).saturating_add(2), ((y1 - y0) as u64).saturating_add(2)))
}
fn warp_corners(src_rect: Rect, h: &Homography) -> Option<[(f64, f64); 4]> {
if src_rect.is_empty() || h.inverse().is_none() {
return None;
}
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64));
corner_bounds(&corners)
Some(
[(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
.map(|(x, y)| h.apply(x as f64, y as f64)),
)
}
fn corner_bounds(corners: &[(f64, f64); 4]) -> Option<Rect> {
@@ -510,6 +533,31 @@ fn warp(
mod tests {
use super::*;
#[test]
fn output_size_prices_the_unclipped_extent_independently_of_position() {
let src = Rect::new(0, 0, 24, 16);
for dx in [0.0, 900_000.0, 10_000_000.0] {
let h = Homography([1.0, 0.0, dx, 0.0, 1.0, -dx, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((26, 18)));
}
for sx in [1e7, -1e7] {
let h = Homography([sx, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &h), Some((240_000_002, 18)));
}
}
#[test]
fn output_size_saturates_overflows_and_preserves_filter_support() {
let src = Rect::new(0, 0, 24, 16);
let fractional = Homography([0.5, 0.0, 0.25, 0.0, 1.0, 0.75, 0.0, 0.0, 1.0]);
let bounds = warp_output_bounds(src, &fractional).unwrap();
assert_eq!(warp_output_size(src, &fractional), Some((u64::from(bounds.width()), u64::from(bounds.height()))));
let huge = Homography([1e308, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(warp_output_size(src, &huge), Some((u64::MAX, u64::MAX)));
assert_eq!(warp_output_size(Rect::EMPTY, &Homography::IDENTITY), None);
assert_eq!(warp_output_size(src, &Homography([0.0; 9])), None);
}
#[test]
fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() {
let mut s = Surface::new(PixelFormat::RGBA8);
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "photocraft-automation"
description = "MCP server (rmcp) over the Photocraft engine, plus a bridge to a running GUI"
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
version.workspace = true
edition.workspace = true
license.workspace = true
+42 -1
View File
@@ -135,7 +135,27 @@ pub fn repeat_auth_reply(line: &str) -> Option<Value> {
}
fn read_token_file(path: &Path) -> Result<String, AutomationError> {
let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
// Validate what we actually opened, not just the name: an existing control-token file must be
// a regular file that no other local principal can read. A freshly created one is 0600
// (`create_token_file`); an operator-supplied one is checked here so a group- or world-readable
// token is refused rather than trusted as the control credential (#4966).
let mut file = std::fs::File::open(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let meta = file.metadata().map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
if !meta.is_file() {
return Err(AutomationError::Io(format!("{}: control token file is not a regular file", path.display())));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.mode() & 0o077 != 0 {
return Err(AutomationError::Io(format!(
"{}: control token file is readable by group or others (mode {:o}); restrict it to its owner (e.g. chmod 600)",
path.display(),
meta.mode() & 0o777
)));
}
}
let token = std::io::read_to_string(&mut file).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let token = token.trim().to_owned();
validate_token(&token)?;
Ok(token)
@@ -433,6 +453,27 @@ mod tests {
sender.join().unwrap();
}
#[cfg(unix)]
#[test]
fn an_existing_token_file_must_be_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("photocraft-token-perm-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("token.txt");
let token = "0123456789abcdef".repeat(4); // 64 hex chars, passes validate_token
std::fs::write(&path, &token).unwrap();
// A group- or world-readable token is refused: the control credential must not be shared (#4966).
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(read_token_file(&path).is_err(), "a 0644 token file must be refused");
// Owner-only is opened, read and validated.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(read_token_file(&path).unwrap(), token, "a 0600 token file must be read");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn connection_limiter_releases_capacity() {
let limiter = ConnectionLimiter::new(1);
+4 -3
View File
@@ -4,14 +4,15 @@
use photocraft_automation::Headless;
use serde_json::json;
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0).
/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). Krita writes a raw tile
/// (flag 0) as plain interleaved pixels; only LZF tiles are split into byte planes.
fn tiles(bgra: [u8; 4]) -> Vec<u8> {
let n = 64 * 64;
let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec();
out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes());
out.push(0);
for b in bgra {
out.extend(std::iter::repeat_n(b, n));
for _ in 0..n {
out.extend(bgra);
}
out
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Photocraft colour management: a pure-Rust ICC v2/v4 colour management module.
//! PhotoCraft colour management: a pure-Rust ICC v2/v4 colour management module.
//!
//! * [`Profile`] parses ICC profiles (matrix/TRC RGB, gray TRC, `mft1`/`mft2`/`mAB `/`mBA `
//! LUT profiles incl. CMYK↔Lab, `curv`/`para` curves) and writes ICC v4 profiles.
+1 -1
View File
@@ -245,7 +245,7 @@ fn smooth(x: f32) -> f32 {
x * x * (3.0 - 2.0 * x)
}
/// Generated looks shipped with Photocraft (procedural, CC0): `(id, label)`.
/// Generated looks shipped with PhotoCraft (procedural, CC0): `(id, label)`.
pub const BUILTIN: [(&str, &str); 8] = [
("warm", "Warm Filter"),
("cool", "Cool Filter"),
+1 -1
View File
@@ -2,7 +2,7 @@
//!
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
+1 -1
View File
@@ -38,7 +38,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| WebP | yes | yes | U8 | RGB, RGBA | yes | yes | yes | yes | no | no | yes (VP8, when `webp_lossless` is off) | `image-webp` / built-in VP8 |
| GIF | yes | yes | U8 | RGBA | 1-bit | no | no | no | no | no | yes (256-colour palette) | `image` |
| BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` |
| TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` (read) / built in (RLE write, one scanline per packet) |
| ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` |
| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in |
| Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in |
+4 -15
View File
@@ -15,7 +15,7 @@ use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name};
use crate::image::{ChannelLayout, DecodeWarning, ExrChannelInfo, ExrPartInfo, Image, SampleType, base_channel_name, exr_color_channels};
use crate::options::{EncodeOptions, ExrCompression, Limits};
const F: Format = Format::OpenExr;
@@ -123,20 +123,9 @@ pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result<Layer<AnyChannels
fn layer_to_image(layer: &Layer<AnyChannels<FlatSamples>>) -> Result<Image, CodecError> {
let (w, h) = (layer.size.0, layer.size.1);
let channels = &layer.channel_data.list;
let find = |n: &str| channels.iter().find(|c| base_channel_name(&c.name.to_string()) == n);
let (color, layout): (Vec<_>, ChannelLayout) = if let (Some(r), Some(g), Some(b)) = (find("R"), find("G"), find("B")) {
match find("A") {
Some(a) => (vec![r, g, b, a], ChannelLayout::Rgba),
None => (vec![r, g, b], ChannelLayout::Rgb),
}
} else if let Some(y) = find("Y").or_else(|| (channels.len() == 1).then(|| &channels[0])) {
match find("A") {
Some(a) => (vec![y, a], ChannelLayout::GrayA),
None => (vec![y], ChannelLayout::Gray),
}
} else {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels"));
};
let names: Vec<_> = channels.iter().map(|c| c.name.to_string()).collect();
let (layout, selected) = exr_color_channels(names.iter().map(String::as_str)).ok_or_else(|| CodecError::unsupported(F, "no R/G/B or Y channels"))?;
let color: Vec<_> = selected.iter().map(|i| channels.get(*i).ok_or_else(|| err("selected channel is missing"))).collect::<Result<_, _>>()?;
let all_f16 = color.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
let sample = if all_f16 { SampleType::F16 } else { SampleType::F32 };
let npx = w * h;
+1
View File
@@ -10,6 +10,7 @@ pub(crate) mod jpeg;
pub(crate) mod pcx;
pub(crate) mod png;
pub(crate) mod pnm;
pub(crate) mod tga;
pub(crate) mod tiff;
pub(crate) mod tiff_exif;
pub(crate) mod tiff_ifd;
+16 -1
View File
@@ -66,6 +66,21 @@ impl<'a> Reader<'a> {
}
}
/// PAM ends its header with an LF-terminated line, unlike P4–P6's single separator.
fn pam_endhdr(&mut self) -> Result<(), CodecError> {
while let Some(&c) = self.b.get(self.pos) {
self.pos += 1;
if c == b'\n' {
// Stop at the first LF: whitespace and '#' after it are raster bytes.
return Ok(());
}
if !c.is_ascii_whitespace() {
return Err(err("unexpected data after PAM ENDHDR"));
}
}
Err(err("unterminated PAM ENDHDR line"))
}
fn rest(&self) -> &'a [u8] {
&self.b[self.pos.min(self.b.len())..]
}
@@ -269,7 +284,7 @@ fn decode_pam(r: &mut Reader<'_>, limits: &Limits) -> Result<Image, CodecError>
_ => return Err(err("unknown PAM header field")),
}
}
r.single_ws()?;
r.pam_endhdr()?;
let (w, h, depth, maxval) = match (w, h, depth, maxval) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => return Err(err("incomplete PAM header")),
+90
View File
@@ -0,0 +1,90 @@
//! Targa writer. The `image` crate still reads Targa ([`super::via_image`]); writing is ours
//! because its run-length packets ran on from the end of one row into the next. TGA 2.0 says a
//! packet must not cross a scanline, and readers that decode a row at a time (Pillow, texture
//! converters, game engines) misread such files (#6382).
//!
//! Like Photoshop's Targa: RLE (types 10 and 11), rows stored bottom-up (the bottom-left origin
//! every reader assumes, also those that ignore the origin bits), alpha bits in the descriptor,
//! and the TGA 2.0 footer.
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, Image, SampleType};
/// Most pixels one packet holds (a 7-bit count, plus one).
const MAX_PACKET: usize = 128;
/// The TGA 2.0 footer: no extension or developer area, then the signature.
const FOOTER: &[u8; 26] = b"\0\0\0\0\0\0\0\0TRUEVISION-XFILE.\0";
/// Encodes an 8-bit Gray, Gray+alpha, RGB or RGBA image as a run-length encoded Targa.
pub(crate) fn encode(img: &Image) -> Result<Vec<u8>, CodecError> {
let (w, h) = img.dimensions();
if img.sample_type() != SampleType::U8 {
return Err(CodecError::encode(Format::Tga, format!("{:?} samples (Targa stores 8 bits)", img.sample_type())));
}
let (bpp, image_type, alpha_bits) = match img.layout() {
ChannelLayout::Gray => (1, 11, 0),
ChannelLayout::GrayA => (2, 11, 8),
ChannelLayout::Rgb => (3, 10, 0),
ChannelLayout::Rgba => (4, 10, 8),
l => return Err(CodecError::encode(Format::Tga, format!("no Targa layout for {l:?}"))),
};
let (Ok(w16), Ok(h16)) = (u16::try_from(w), u16::try_from(h)) else {
return Err(CodecError::encode(Format::Tga, format!("dimensions exceed 65535x65535: {w}x{h}")));
};
let row = w as usize * bpp;
let data = img.data();
if data.len() != row * h as usize {
return Err(CodecError::InvalidImage("buffer size mismatch".into()));
}
let mut out = Vec::with_capacity(18 + data.len() + data.len() / MAX_PACKET + FOOTER.len());
out.extend_from_slice(&[0, 0, image_type, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
out.extend_from_slice(&w16.to_le_bytes());
out.extend_from_slice(&h16.to_le_bytes());
// Bits per pixel; the descriptor holds the alpha bits, origin bits 0 (bottom-left).
out.extend_from_slice(&[(bpp * 8) as u8, alpha_bits]);
let mut px = vec![0u8; row];
for y in (0..h as usize).rev() {
px.copy_from_slice(&data[y * row..(y + 1) * row]);
// Targa stores colour as BGR.
if bpp >= 3 {
for p in px.chunks_exact_mut(bpp) {
p.swap(0, 2);
}
}
rle_row(&px, bpp, &mut out);
}
out.extend_from_slice(FOOTER);
Ok(out)
}
/// One scanline as run-length packets that end with it: a run of two or more equal pixels is a
/// run packet, anything else goes out in raw packets.
fn rle_row(row: &[u8], bpp: usize, out: &mut Vec<u8>) {
let px: Vec<&[u8]> = row.chunks_exact(bpp).collect();
let n = px.len();
let mut i = 0;
while i < n {
let mut run = 1;
while i + run < n && run < MAX_PACKET && px[i + run] == px[i] {
run += 1;
}
if run >= 2 {
out.push(0x80 | (run - 1) as u8);
out.extend_from_slice(px[i]);
i += run;
continue;
}
// Raw until two equal pixels start a run, the packet is full or the row ends. The first
// pixel always goes in: it differs from the one after it, or it is the row's last.
let start = i;
while i < n && i - start < MAX_PACKET && !(i > start && i + 1 < n && px[i] == px[i + 1]) {
i += 1;
}
out.push((i - start - 1) as u8);
for p in &px[start..i] {
out.extend_from_slice(p);
}
}
}
+6 -1
View File
@@ -1,5 +1,6 @@
//! Formats delegated to the `image` crate (pure-Rust features only):
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`).
//! GIF, BMP, TGA, ICO, QOI, Radiance HDR, and AVIF encode (feature `avif`). Targa is written by
//! [`super::tga`] and only read here.
use std::io::Cursor;
@@ -163,6 +164,10 @@ pub(crate) fn encode(f: Format, src: &Image, plan: Plan, opts: &EncodeOptions) -
return Err(CodecError::encode(f, format!("dimensions exceed {mw}x{mh}")));
}
let img = src.converted(plan.layout, plan.sample);
// Targa is written by our encoder: the image crate's RLE packets cross scanlines (#6382).
if f == Format::Tga {
return super::tga::encode(&img);
}
let dynimg = to_dynamic(&img)?;
let mut cursor = Cursor::new(Vec::new());
match f {
+64 -9
View File
@@ -321,20 +321,75 @@ pub struct ExrPartInfo {
impl ExrPartInfo {
/// How much this part looks like a colour image the plain [`Image`] decode can show:
/// 4 RGBA, 3 RGB, 2 YA, 1 any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// 4 RGBA, 3 RGB, 2 YA, 1 Y or any-single-channel, 0 anything else (e.g. `Z` beside `A`).
/// The colour and alpha channels must share a complete layer prefix. The highest-ranked
/// group wins; equal groups prefer unprefixed channels, then the first prefix alphabetically.
/// The auto-pick takes the highest rank; ties go to the earlier part.
pub fn color_rank(&self) -> u8 {
let has = |n: &str| self.channels.iter().any(|c| base_channel_name(&c.name) == n);
if has("R") && has("G") && has("B") {
return if has("A") { 4 } else { 3 };
}
if has("Y") {
return if has("A") { 2 } else { 1 };
}
u8::from(self.channels.len() == 1)
exr_color_channels(self.channels.iter().map(|c| c.name.as_str())).map_or(0, |(layout, _)| layout.channels() as u8)
}
}
/// One selection policy for header ranking/probing and flat decoding: never synthesize
/// RGB(A) or YA from separate layers. OpenEXR layers use the full prefix before the last
/// dot, including nested layers; a leading or trailing dot alone does not name a layer.
pub(crate) fn exr_color_channels<'a>(names: impl IntoIterator<Item = &'a str>) -> Option<(ChannelLayout, Vec<usize>)> {
#[derive(Default)]
struct Group {
r: Option<usize>,
g: Option<usize>,
b: Option<usize>,
a: Option<usize>,
y: Option<usize>,
}
// Sorted prefixes make rank ties deterministic independently of channel order, with
// the empty (unprefixed) group first. Unsupported channels cannot complete a group.
let mut groups = std::collections::BTreeMap::<&str, Group>::new();
let mut count = 0usize;
for (index, name) in names.into_iter().enumerate() {
count = count.saturating_add(1);
let (prefix, base) = name.rsplit_once('.').filter(|(prefix, base)| !prefix.is_empty() && !base.is_empty()).unwrap_or(("", name));
if !matches!(base, "R" | "G" | "B" | "A" | "Y") {
continue;
}
let group = groups.entry(prefix).or_default();
let component = match base {
"R" => &mut group.r,
"G" => &mut group.g,
"B" => &mut group.b,
"A" => &mut group.a,
"Y" => &mut group.y,
_ => continue,
};
component.get_or_insert(index);
}
let mut best = None;
let mut best_rank = 0;
for group in groups.values() {
let candidate = if let (Some(r), Some(g), Some(b)) = (group.r, group.g, group.b) {
match group.a {
Some(a) => (ChannelLayout::Rgba, vec![r, g, b, a]),
None => (ChannelLayout::Rgb, vec![r, g, b]),
}
} else if let Some(y) = group.y {
match group.a {
Some(a) => (ChannelLayout::GrayA, vec![y, a]),
None => (ChannelLayout::Gray, vec![y]),
}
} else {
continue;
};
let rank = candidate.0.channels();
if rank > best_rank {
best = Some(candidate);
best_rank = rank;
}
}
best.or_else(|| (count == 1).then(|| (ChannelLayout::Gray, vec![0])))
}
/// Strip an optional `layer.` prefix from a channel name (`diffuse.R` → `R`).
pub(crate) fn base_channel_name(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
+107 -1
View File
@@ -8,7 +8,9 @@ use exr::prelude::{
WritableImage,
};
use photocraft_codecs::f16;
use photocraft_codecs::{ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info};
use photocraft_codecs::{
ChannelLayout, DecodeOptions, DecodeWarning, Format, Limits, SampleType, decode, decode_as_with, decode_exr_part, exr_info, probe_size,
};
fn layer_f32(name: &str, comp: Compression, chans: &[(&str, Vec<f32>)]) -> Layer<AnyChannels<FlatSamples>> {
let n = chans.iter().find(|(_, v)| !v.is_empty()).map_or(0, |(_, v)| v.len());
@@ -226,3 +228,107 @@ fn decoding_one_part_does_not_decode_the_others() {
assert_eq!((img.width(), img.height()), (4, 4));
assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels");
}
fn channel_group_file(channels: &[(&str, f32)]) -> Vec<u8> {
let channels: Vec<_> = channels.iter().map(|(name, value)| (*name, vec![*value; 4])).collect();
write_image(vec![layer_f32("groups", Compression::ZIP1, &channels)])
}
fn assert_channel_group(bytes: &[u8], layout: ChannelLayout, expected: &[f32]) {
assert_eq!(exr_info(bytes, &Limits::default()).unwrap()[0].color_rank(), expected.len() as u8);
assert_eq!(probe_size(bytes).unwrap(), (2, 2));
for img in [decode(bytes).unwrap(), decode_exr_part(bytes, 0, &DecodeOptions::default()).unwrap()] {
assert_eq!(img.layout(), layout);
assert_eq!(img.to_normalized(), expected.repeat(4));
}
}
#[test]
fn flat_decode_keeps_rgb_channels_in_one_group() {
// A partial group sorts before the nested beauty layer, but cannot donate its R.
let bytes = channel_group_file(&[("aaa.R", 9.0), ("beauty.diffuse.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_does_not_borrow_another_groups_alpha() {
for alpha in ["A", "aaa.A", "beauty.other.A"] {
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
let bytes = channel_group_file(&[(alpha, 0.125), ("beauty.Y", 0.5)]);
assert_channel_group(&bytes, ChannelLayout::Gray, &[0.5]);
}
}
#[test]
fn flat_decode_rejects_rgb_split_between_groups() {
for channels in [
[("aaa.R", 0.25), ("bbb.G", 0.5), ("ccc.B", 2.0)],
[("beauty.R", 0.25), ("beauty.diffuse.G", 0.5), ("beauty.diffuse.B", 2.0)],
[(".R", 0.25), ("G", 0.5), ("B", 2.0)],
] {
let bytes = channel_group_file(&channels);
assert_eq!(exr_info(&bytes, &Limits::default()).unwrap()[0].color_rank(), 0);
assert!(probe_size(&bytes).is_err());
assert!(decode(&bytes).is_err());
assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err());
}
}
#[test]
fn auto_pick_and_probe_skip_incoherent_rgb_parts() {
let bytes = write_image(vec![
layer_f32("split", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("bbb.G", vec![8.0; 4]), ("ccc.B", vec![7.0; 4])]),
layer_f32("luminance", Compression::ZIP1, &[("Y", vec![0.5; 16]), ("A", vec![0.75; 16])]),
]);
let parts = exr_info(&bytes, &Limits::default()).unwrap();
assert_eq!(parts.iter().map(|part| part.color_rank()).collect::<Vec<_>>(), [0, 2]);
assert_eq!(probe_size(&bytes).unwrap(), (4, 4));
let img = decode(&bytes).unwrap();
assert_eq!((img.width(), img.height(), img.layout()), (4, 4, ChannelLayout::GrayA));
assert_eq!(img.to_f32_samples().unwrap(), [0.5, 0.75].repeat(16));
}
#[test]
fn flat_decode_prefers_the_highest_ranked_complete_group() {
let bytes =
channel_group_file(&[("aaa.R", 9.0), ("aaa.G", 8.0), ("aaa.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
let bytes = channel_group_file(&[("aaa.Y", 9.0), ("beauty.Y", 0.5), ("beauty.A", 0.75)]);
assert_channel_group(&bytes, ChannelLayout::GrayA, &[0.5, 0.75]);
}
#[test]
fn flat_decode_breaks_group_ties_by_prefix() {
let bytes = channel_group_file(&[("z.R", 9.0), ("z.G", 8.0), ("z.B", 7.0), ("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
// Even a prefix that sorts before the unprefixed channel names loses a rank tie.
let bytes = channel_group_file(&[("0.R", 9.0), ("0.G", 8.0), ("0.B", 7.0), ("R", 0.25), ("G", 0.5), ("B", 2.0)]);
assert_channel_group(&bytes, ChannelLayout::Rgb, &[0.25, 0.5, 2.0]);
}
#[test]
fn flat_decode_preserves_luminance_and_lone_channel_fallbacks() {
for prefix in ["", "beauty."] {
let y = format!("{prefix}Y");
let a = format!("{prefix}A");
assert_channel_group(&channel_group_file(&[(&y, 2.0), (&a, 0.75)]), ChannelLayout::GrayA, &[2.0, 0.75]);
assert_channel_group(&channel_group_file(&[(&y, 2.0)]), ChannelLayout::Gray, &[2.0]);
}
for name in ["depth.Z", "A", "beauty.A"] {
// A lone alpha is one grayscale channel, not the same samples duplicated as YA.
assert_channel_group(&channel_group_file(&[(name, 9.0)]), ChannelLayout::Gray, &[9.0]);
}
}
#[test]
fn half_float_group_does_not_use_unrelated_float_channels() {
let mut layer = layer_f32("mixed", Compression::ZIP1, &[("aaa.R", vec![9.0; 4]), ("aaa.A", vec![0.125; 4])]);
for (name, value) in [("beauty.R", 0.25), ("beauty.G", 0.5), ("beauty.B", 2.0), ("beauty.A", 0.75)] {
layer.channel_data.list.push(AnyChannel::new(name, FlatSamples::F16(vec![f16::from_f32(value); 4])));
}
layer.channel_data = AnyChannels::sort(layer.channel_data.list);
let bytes = write_image(vec![layer]);
assert_channel_group(&bytes, ChannelLayout::Rgba, &[0.25, 0.5, 2.0, 0.75]);
assert_eq!(decode(&bytes).unwrap().sample_type(), SampleType::F16);
}
+82
View File
@@ -0,0 +1,82 @@
use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, decode, decode_as};
const PAM_GRAY: &[u8] = b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 1\nMAXVAL 255\nENDHDR";
const ENDINGS: &[&[u8]] = &[b"\n", b" \n", b"\t \n", b"\r\n", b" \t\r\n"];
#[test]
fn pam_consumes_the_complete_endhdr_line() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
let img = decode(&bytes).unwrap();
assert_eq!(img.dimensions(), (1, 1));
assert_eq!(img.layout(), ChannelLayout::Gray);
assert_eq!(img.data(), &[0x80], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_preserves_every_possible_first_raster_byte() {
// In particular, LF, CR, space, tab and '#' must not be skipped as header syntax.
for ending in ENDINGS {
for first in 0..=255u8 {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(first);
assert_eq!(decode(&bytes).unwrap().data(), &[first], "ENDHDR ending {ending:?}, raster byte {first}");
}
}
}
#[test]
fn pam_crlf_header_preserves_u16_samples_and_alpha() {
for ending in ENDINGS {
let mut bytes = b"P7\r\nWIDTH 1\r\nHEIGHT 1\r\nDEPTH 2\r\nMAXVAL 65535\r\nTUPLTYPE GRAYSCALE_ALPHA\r\nENDHDR".to_vec();
bytes.extend_from_slice(ending);
bytes.extend_from_slice(b" \n#\r");
let img = decode(&bytes).unwrap();
assert_eq!(img.layout(), ChannelLayout::GrayA);
assert_eq!(img.sample_type(), SampleType::U16);
assert_eq!(img.to_u16_samples().unwrap(), vec![0x200a, 0x230d], "ENDHDR ending {ending:?}");
}
}
#[test]
fn pam_rejects_unterminated_or_invalid_endhdr_lines() {
for suffix in [b"".as_slice(), b" ", b"\r", b" \t\r", b" \x80", b" \x80\n\x7f", b"#comment\n\x7f"] {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(suffix);
assert!(matches!(decode_as(Format::Pnm, &bytes), Err(CodecError::Malformed { .. })), "ENDHDR suffix {suffix:?}");
}
}
#[test]
fn pam_truncation_never_uses_header_whitespace_as_pixels() {
for ending in ENDINGS {
let mut bytes = PAM_GRAY.to_vec();
bytes.extend_from_slice(ending);
bytes.push(0x80);
for cut in 0..bytes.len() {
assert!(decode_as(Format::Pnm, &bytes[..cut]).is_err(), "ENDHDR ending {ending:?}, truncated at {cut}");
}
}
}
#[test]
fn binary_pnm_keeps_its_single_whitespace_separator() {
for separator in *b" \t\r\n" {
for first in *b" \t\r\n#" {
for (header, channels) in [(b"P5\n1 1\n255".as_slice(), 1), (b"P6\n1 1\n255", 3)] {
let mut bytes = header.to_vec();
bytes.push(separator);
bytes.extend(std::iter::repeat_n(first, channels));
assert_eq!(decode(&bytes).unwrap().data(), vec![first; channels]);
}
let mut bytes = b"P4\n8 1".to_vec();
bytes.extend_from_slice(&[separator, first]);
let expected: Vec<u8> = (0..8).map(|bit| if first & (0x80 >> bit) == 0 { 255 } else { 0 }).collect();
assert_eq!(decode(&bytes).unwrap().data(), expected);
}
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Targa writing (#6382): run-length packets end with their scanline, rows are stored bottom-up,
//! and the file reads back exactly.
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, decode_as, encode};
/// The packets of a run-length encoded Targa body, as (pixels, is a run), from just after the
/// 18-byte header (no image ID or colour map) up to `pixels` pixels.
fn packets(file: &[u8], bpp: usize, pixels: usize) -> Vec<(usize, bool)> {
let mut at = 18;
let mut seen = 0;
let mut out = Vec::new();
while seen < pixels {
let head = file[at];
let count = usize::from(head & 0x7f) + 1;
let run = head & 0x80 != 0;
at += 1 + if run { bpp } else { count * bpp };
seen += count;
out.push((count, run));
}
out
}
/// A `w`×`h` image whose rows mix long runs (wider than a packet), short ones and noise, and
/// whose last pixel equals the next row's first, so a packet would run on if it could.
fn sample(w: u32, h: u32, layout: ChannelLayout) -> Image {
let n = layout.channels();
let mut data = Vec::new();
for y in 0..h {
for x in 0..w {
let v = match x {
_ if x < 150 => 40,
_ if x % 3 == 0 => (x * 7 + y * 13) as u8,
_ => 200,
};
let px = [v, v.wrapping_add(y as u8), v ^ 0x5a, 255 - v];
data.extend_from_slice(&px[..n]);
}
}
Image::from_u8(w, h, layout, data).unwrap()
}
#[test]
fn rle_packets_end_with_their_scanline() {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
let (w, h) = (300u32, 4u32);
let file = encode(&sample(w, h, layout), Format::Tga, &EncodeOptions::default()).unwrap();
let bpp = layout.channels();
let mut col = 0;
for (count, _) in packets(&file, bpp, (w * h) as usize) {
assert!(count <= 128, "{layout:?}: a packet holds at most 128 pixels");
assert!(col + count <= w as usize, "{layout:?}: a packet runs from column {col} past the row's end");
col = (col + count) % w as usize;
}
// A solid image is one run per row (three packets of 128, 128 and 44), not one long run.
let solid = Image::from_u8(w, h, layout, vec![90; (w * h) as usize * bpp]).unwrap();
let file = encode(&solid, Format::Tga, &EncodeOptions::default()).unwrap();
let p = packets(&file, bpp, (w * h) as usize);
assert_eq!(p.len(), 3 * h as usize, "{layout:?}");
assert!(p.iter().all(|&(_, run)| run));
}
}
#[test]
fn the_file_is_bottom_up_rle_with_its_alpha_bits_and_the_tga2_footer() {
let mut data = vec![0u8; 2 * 2 * 4];
// Top row red, bottom row blue, opaque.
for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
*px = if i < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] };
}
let file = encode(&Image::from_u8(2, 2, ChannelLayout::Rgba, data).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap();
assert_eq!(file[2], 10, "run-length true colour");
assert_eq!((file[16], file[17]), (32, 8), "32 bits per pixel, 8 alpha bits, bottom-left origin");
// The first stored row is the bottom one: a run of two blue pixels, in BGRA.
assert_eq!(&file[18..23], &[0x81, 255, 0, 0, 255]);
assert!(file.ends_with(b"TRUEVISION-XFILE.\0"));
let gray = encode(&Image::from_u8(2, 1, ChannelLayout::Gray, vec![1, 2]).unwrap(), Format::Tga, &EncodeOptions::default()).unwrap();
assert_eq!((gray[2], gray[16], gray[17]), (11, 8, 0), "run-length grayscale, no alpha bits");
}
#[test]
fn every_layout_reads_back_exactly() {
for layout in [ChannelLayout::Gray, ChannelLayout::GrayA, ChannelLayout::Rgb, ChannelLayout::Rgba] {
for (w, h) in [(1, 1), (300, 4), (129, 3), (5, 130)] {
let img = sample(w, h, layout);
let file = encode(&img, Format::Tga, &EncodeOptions::default()).unwrap();
let back = decode_as(Format::Tga, &file).unwrap();
assert_eq!((back.dimensions(), back.layout()), ((w, h), layout), "{layout:?} {w}x{h}");
assert_eq!(back.data(), img.data(), "{layout:?} {w}x{h}");
}
}
}
+14 -5
View File
@@ -30,14 +30,12 @@ impl MeasurementScale {
}
/// Photoshop's "Scale" column text, e.g. `1 pixels = 1.0000 pixels`.
pub fn describe(&self) -> String {
format!("{} pixels = {:.4} {}", trim(self.pixel_length), self.logical_length, self.units)
// f64 Display already drops a whole number's `.0` (`5`, `0.5`) and, unlike an i64 cast,
// prints lengths past i64::MAX in full.
format!("{} pixels = {:.4} {}", self.pixel_length, self.logical_length, self.units)
}
}
fn trim(v: f64) -> String {
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
}
/// One Count tool group: its numbered markers (document pixel coordinates, in click order).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -133,6 +131,17 @@ mod tests {
assert_eq!(MeasurementScale { pixel_length: 0.0, ..Default::default() }.factor(), 1.0);
}
#[test]
fn scale_text_prints_large_and_fractional_pixel_lengths() {
let big = MeasurementScale { pixel_length: 1e20, logical_length: 1.0, units: "mm".into() };
assert_eq!(big.describe(), "100000000000000000000 pixels = 1.0000 mm");
assert_eq!(MeasurementScale::default().describe(), "1 pixels = 1.0000 pixels");
let s = |p: f64| MeasurementScale { pixel_length: p, ..Default::default() }.describe();
assert_eq!(s(5.0), "5 pixels = 1.0000 pixels");
assert_eq!(s(0.5), "0.5 pixels = 1.0000 pixels");
assert_eq!(s(12.25), "12.25 pixels = 1.0000 pixels");
}
#[test]
fn serde_defaults_fill_missing_fields() {
let m: Measurement = serde_json::from_str("{}").unwrap();
+1 -1
View File
@@ -1,4 +1,4 @@
//! The Photocraft document model: pure data, no rendering, no UI.
//! The PhotoCraft document model: pure data, no rendering, no UI.
//!
//! Layer order: inside every group (and the root), `children[0]` is the **bottom** layer, matching
//! compositing order and the PSD file order. UIs display the list reversed.
+17 -1
View File
@@ -74,7 +74,7 @@ impl Duotone {
let density = 1.0 - gray.clamp(0.0, 1.0);
let mut out = [1.0f32; 3];
for ink in &self.inks {
let d = curve_eval(&ink.curve, density);
let d = ink.density(density);
for (o, c) in out.iter_mut().zip(ink.color) {
// Each ink filters light: full density transmits the ink colour.
*o *= 1.0 - d * (1.0 - c);
@@ -209,6 +209,22 @@ mod tests {
assert!(c[0] > c[2], "{c:?}");
}
#[test]
fn duotone_render_clamps_ink_density_like_multichannel() {
// Curves above 100 % (accepted by `image.mode.duotone`) must not push the print out of
// gamut: red + blue at full density is black, not green.
let over = |name: &str, color| {
let mut ink = DuotoneInk::new(name, color);
ink.curve = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 2.0 }];
ink
};
let d = Duotone { inks: vec![over("Red", [1.0, 0.0, 0.0]), over("Blue", [0.0, 0.0, 1.0])], psd_raw: None };
assert_eq!(d.render(0.0), [0.0, 0.0, 0.0]);
for g in [0.0, 0.25, 0.5, 1.0] {
assert!(d.render(g).iter().all(|c| (0.0..=1.0).contains(c)), "{g}: {:?}", d.render(g));
}
}
#[test]
fn stack_mode_ids_roundtrip() {
for m in StackMode::ALL {
+4
View File
@@ -43,6 +43,10 @@ roxmltree = "0.20"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tempfile = "3"
# Printing on Unix: an IPP client for CUPS (pure Rust); the web build cannot print.
[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))'.dependencies]
photocraft-ipp = { workspace = true }
[dev-dependencies]
photocraft-affinity = { workspace = true, features = ["synth"] }
# Synthetic camera raw files for the open tests.
+12
View File
@@ -296,6 +296,18 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
/// Image › Adjustments run as a smart filter (`image.adjustments.<kind>` recorded on a smart
/// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run.
pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<Surface> {
// A Color Lookup filter re-reads its LUT `file`, a path stored in the document: followed only
// as `doc_path` allows, else the filter can't run (like a missing file).
let resolved;
let p = match p.get("file").and_then(Value::as_str).filter(|f| kind == "colorLookup" && !f.is_empty()) {
Some(file) => {
let mut q = p.clone();
q["file"] = json!(crate::doc_path::resolve(file).ok()?.to_str()?);
resolved = q;
&resolved
}
None => p,
};
let mut out = surf.clone();
if kind == "shadowsHighlights" {
let sh = shadows_highlights_params(p);
+7 -3
View File
@@ -1121,8 +1121,12 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result<Value> {
};
// Bottom-left with a margin; the text sits under (or over) the bar.
let margin = (w.min(h) * 0.03).round().max(2.0);
let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0);
let text_h = if show_text { font_size * 1.25 } else { 0.0 };
// `fontSize` is in points; `type.create` renders it at resolution/72 px per point, so lay out
// the bar and label in those pixels (at 300 ppi, 12 pt is 50 px) (#5754).
let dpi = f64::from(d.resolution_dpi);
let font_px = font_size * if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
let bar_h = (font_px / 4.0).round().clamp(2.0, h / 4.0);
let text_h = if show_text { font_px * 1.25 } else { 0.0 };
// Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932).
let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0);
// Likewise pull a bar nearly as wide as the image in from the right edge (#932).
@@ -1143,7 +1147,7 @@ fn place_scale_marker(s: &mut Session, p: &Value) -> Result<Value> {
})?;
let mut text_layer = Value::Null;
if show_text {
let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size };
let baseline = if text_top { f64::from(bar.y0) - font_px * 0.3 } else { f64::from(bar.y1) + font_px };
let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label});
if let Some(f) = &font {
tp["font"] = json!(f);
+22 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use photocraft_doc::ColorMode;
use photocraft_doc::{ColorMode, LayerContent, LayerId};
fn session(w: u32, h: u32, depth: u32) -> Session {
let mut s = Session::new();
@@ -355,6 +355,27 @@ fn place_scale_marker_builds_one_group_in_one_step() {
assert!(d(&s).layers.iter().all(|l| l.name != "Measurement Scale Marker"));
}
#[test]
fn place_scale_marker_lays_out_its_label_in_points_at_high_resolution() {
// `fontSize` is in points and `type.create` renders it at resolution/72 px per point, but the
// layout treated it as pixels: at 300 ppi a 12 pt label (50 px) overlapped the bar.
for res in [72.0, 300.0] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 1200, "height": 900, "resolution": res, "background": "white"})).unwrap();
let r = s.execute("image.analysis.placeScaleMarker", json!({"fontSize": 12, "textPosition": "bottom"})).unwrap();
let bar_bottom = r["barRect"][3].as_f64().unwrap();
let doc = d(&s);
let Some(LayerContent::Text(t)) = doc.layer(LayerId(r["text"].as_u64().unwrap())).map(|l| &l.content) else { panic!("a type layer: {r}") };
assert_eq!(t.size_pt, 12.0, "{res} ppi: type.create still gets points");
let layout = photocraft_text::shared().lock().unwrap().layout(t, doc.resolution_dpi);
let b = layout.bounds().expect("the label has a line");
let ty = t.transform.m[5];
let (top, bottom) = (f64::from(b[1]) + ty, f64::from(b[3]) + ty);
assert!(top >= bar_bottom - 0.5, "{res} ppi: label top {top} overlaps the bar ending at {bar_bottom}: {r}");
assert!(bottom <= 900.5, "{res} ppi: label bottom {bottom} stays on the canvas: {r}");
}
}
#[test]
fn place_scale_marker_on_a_short_document_errors_without_panicking() {
// A document under 8 px tall inverted the bar-height clamp and panicked (#932). Call `run`
+4 -3
View File
@@ -528,7 +528,7 @@ fn chosen_artboards(doc: &Document, p: &Value) -> Vec<LayerId> {
}
}
/// File › Export › Artboards to Files: `<prefix>_<artboard name>.<format>` per board.
/// File › Export › Artboards to Files: `<prefix>_<index:04>_<artboard name>.<format>` per board.
fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export.artboardsToFiles";
let dir = str_param(p, "dir", cmd).or_else(|_| str_param(p, "output", cmd))?.to_string();
@@ -538,8 +538,9 @@ fn artboards_to_files(s: &mut Session, p: &Value) -> Result<Value> {
let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&d.doc.name));
let doc = d.doc.clone();
let mut files = Vec::new();
for one in chosen_documents(&doc, p)? {
let name = if prefix.is_empty() { sanitize(&one.name) } else { format!("{}_{}", sanitize(&prefix), sanitize(&one.name)) };
for (i, one) in chosen_documents(&doc, p)?.into_iter().enumerate() {
// Names may be identical, or collide after sanitizing or filesystem case folding.
let name = if prefix.is_empty() { format!("{i:04}_{}", sanitize(&one.name)) } else { format!("{}_{i:04}_{}", sanitize(&prefix), sanitize(&one.name)) };
let path = join(&dir, &format!("{name}.{format}"));
save_doc(&one, &path, save)?;
files.push(path);
+2 -2
View File
@@ -146,8 +146,8 @@ fn export_to_files_and_pdf() {
let r = s.execute("file.export.artboardsToFiles", json!({"dir": d, "prefix": "site"})).unwrap();
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("site_Empty_One.png"), "{files:?}");
assert!(files[1].ends_with("site_Red Board.png"), "{files:?}");
assert!(files[0].ends_with("site_0000_Empty_One.png"), "{files:?}");
assert!(files[1].ends_with("site_0001_Red Board.png"), "{files:?}");
let img = photocraft_io::import("x.png", &std::fs::read(&files[1]).unwrap()).unwrap().document;
assert_eq!((img.size.width, img.size.height), (10, 10));
let img2 = photocraft_io::import("x.png", &std::fs::read(&files[0]).unwrap()).unwrap().document;
+37 -1
View File
@@ -1986,6 +1986,10 @@ fn fill_from(d: &Descriptor) -> Mapped {
match get_enum(d, "Usng").as_deref() {
Some("FrgC") | None => {}
Some("Pttn") => p["contents"] = json!("pattern"),
Some("Clr") => match get_obj(d, "Clr ").and_then(color_hex) {
Some(c) => p["color"] = json!(c),
None => return Mapped::Unsupported { harmless: false },
},
Some(_) => return Mapped::Unsupported { harmless: false },
}
if let Some(t) = get_num(d, "Tlrn") {
@@ -2032,7 +2036,16 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
if id == "paint.bucket" {
let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?))));
d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true)));
d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" }));
// `source` is the command's older name for `contents`.
d = if p.get("contents").or_else(|| p.get("source")).and_then(Value::as_str) == Some("pattern") {
d.with("Usng", enm("FlCn", "Pttn"))
} else {
// An explicit colour overrides the foreground (#7199): it travels as Fill's `Clr `.
match bucket_color(p.get("color"))? {
Some(c) => d.with("Usng", enm("FlCn", "Clr ")).with("Clr ", D::Descriptor(c)),
None => d.with("Usng", enm("FlCn", "FrgC")),
}
};
if let Some(o) = jnum(p, "opacity") {
d = d.with("Opct", pct(o));
}
@@ -2054,6 +2067,29 @@ fn fill_to(id: &str, p: &Value) -> Option<Descriptor> {
Some(d)
}
/// The Paint Bucket's `color` as `paint.bucket` reads it (`[r, g, b(, a)]` in 0..=1 or
/// `#rrggbb(aa)`): `Some(None)` when it fills with the foreground (no colour, or a string the
/// command doesn't read), `None` when the colour can't be written (not opaque, or outside 0..=1).
fn bucket_color(v: Option<&Value>) -> Option<Option<Descriptor>> {
let rgba = match v {
Some(Value::Array(a)) if a.len() >= 3 => {
let c: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
[*c.first()?, *c.get(1)?, *c.get(2)?, c.get(3).copied().unwrap_or(1.0)]
}
Some(Value::String(h)) => {
let h = h.trim_start_matches('#');
let c = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f64::from(v) / 255.0);
let (Some(r), Some(g), Some(b)) = (c(0), c(2), c(4)) else { return Some(None) };
[r, g, b, c(6).unwrap_or(1.0)]
}
_ => return Some(None),
};
if rgba[3] != 1.0 || !rgba.iter().all(|v| (0.0..=1.0).contains(v)) {
return None;
}
Some(Some(desc("RGBC").with("Rd ", D::Double(rgba[0] * 255.0)).with("Grn ", D::Double(rgba[1] * 255.0)).with("Bl ", D::Double(rgba[2] * 255.0))))
}
const STROKE: &[F] = &[
F("Wdth", "width", Kind::Long),
F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)),
+49
View File
@@ -432,6 +432,55 @@ fn every_mapped_command_exports_and_imports_back() {
}
}
/// `paint.bucket` exported to .atn and imported back → its params, or None when it doesn't export.
fn bucket_round_trip(params: Value) -> Option<Value> {
let item = export_item("paint.bucket", &params, "S")?;
let e = event_for(&item.event)?;
match (e.from)(item.descriptor.as_ref()?) {
Mapped::Steps(steps) => steps.first().map(|(id, p)| {
assert_eq!(id, "paint.bucket");
p.clone()
}),
_ => None,
}
}
/// A Paint Bucket step with an explicit colour keeps it through .atn (#7199): replayed on a fresh
/// document, whose foreground is black, it still fills red. A colour .atn can't hold makes the
/// step not export rather than fill with the foreground.
#[test]
fn bucket_keeps_an_explicit_color_through_atn() {
let fill = |params: &Value| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 8, "background": "#ffffff"})).unwrap();
s.execute("paint.bucket", params.clone()).unwrap();
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(2, 2)
};
let red = json!({"x": 2, "y": 2, "antiAlias": false, "color": "#ff0000"});
let back = bucket_round_trip(red.clone()).unwrap();
assert_eq!(back["color"], "#ff0000", "{back}");
assert_eq!(fill(&back), fill(&red));
assert_eq!(fill(&back), vec![1.0, 0.0, 0.0, 1.0]);
// The colour as an array of channels.
assert_eq!(bucket_round_trip(json!({"x": 1, "y": 1, "color": [0.0, 0.5, 1.0]})).unwrap()["color"], "#0080ff");
// No colour, or one the command can't read: the foreground, as before.
for p in [json!({"x": 1, "y": 1}), json!({"x": 1, "y": 1, "color": "red"})] {
let back = bucket_round_trip(p.clone()).unwrap();
assert!(back.get("color").is_none() && back.get("contents").is_none(), "{p} → {back}");
}
// A pattern fill ignores the colour, also when named with the older `source`.
for key in ["contents", "source"] {
let back = bucket_round_trip(json!({"x": 1, "y": 1, key: "pattern", "color": "#ff0000"})).unwrap();
assert_eq!(back["contents"], "pattern", "{key}: {back}");
assert!(back.get("color").is_none(), "{key}: {back}");
}
// Translucent or out-of-range colours have no .atn form.
for c in [json!("#ff000080"), json!([1.0, 0.0, 0.0, 0.5]), json!([2.0, 0.0, 0.0])] {
assert!(bucket_round_trip(json!({"x": 1, "y": 1, "color": c})).is_none(), "{c}");
}
}
#[test]
fn unsupported_shapes_become_photoshop_steps_not_errors() {
// Image size in two different percentages, a Paint Bucket click in percent of the canvas, a
+17 -4
View File
@@ -793,13 +793,19 @@ fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.tools.presets.len() == before {
return Err(bad(cmd, format!("no brush preset named `{name}`")));
}
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&name)) {
forget_deleted_preset(s, &name);
Ok(json!({ "count": s.tools.presets.len() }))
}
/// After preset `name` was deleted (here or in Edit › Presets › Preset Manager): the current
/// brush no longer comes from a preset, and the library is saved.
pub(crate) fn forget_deleted_preset(s: &mut Session, name: &str) {
if s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(name)) {
s.tools.current_preset = None;
// The remembered brush names the preset it was picked from: the name went away.
s.tools.brush_changed();
}
s.brush_presets_changed();
Ok(json!({ "count": s.tools.presets.len() }))
}
/// Overwrite an existing preset with the current brush (or the current brush plus a `brush`
@@ -863,9 +869,16 @@ fn define_from_selection(s: &mut Session, p: &Value) -> Result<Value> {
}
// Paint amount = darkness × alpha × selection (black = full paint, like Photoshop).
let (w, h) = (area.width() as usize, area.height() as usize);
let mut v = vec![0.0f32; w * h];
// The sampled tip is one f32 buffer over the whole selection; a large selection must fail
// gracefully instead of aborting the process on allocation, so reserve it fallibly. The
// multiply is checked too: on a 32-bit target (wasm32) it can overflow where the rest of
// this command is already fallible.
let len = w.checked_mul(h).ok_or_else(|| crate::allocation::error("defining a brush from the selection", "requested size overflowed"))?;
let mut v = crate::allocation::filled(len, 0.0, "defining a brush from the selection")?;
let sc = sel.channels();
let sv = sel.read_region(area);
// The selection mask is a second buffer of the same size; it must fail gracefully too
// (review of #5469), so read it through the fallible allocator.
let sv = crate::allocation::read_region(sel, area, "defining a brush from the selection")?;
for y in 0..h {
for x in 0..w {
let c = surf.rgba(area.x0 + x as i32, area.y0 + y as i32);
+20
View File
@@ -442,6 +442,26 @@ fn define_brush_from_selection() {
assert!(rgba(&s, 30, 32)[3] > 0.9);
}
#[test]
fn define_brush_from_selection_survives_allocation_failure() {
// The sampled tip is one f32 buffer over the whole selection, and the selection mask is a
// second of the same size. Both are reserved through the fallible allocator, so an allocation
// failure must be a graceful error (brush, presets, history and document untouched), never a
// panic or an OOM abort.
let mut s = session(40, 40);
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 20, "height": 20})).unwrap();
let brush_before = s.tools.brush.clone();
let presets_before = s.tools.presets.clone();
let history_before = s.active().unwrap().history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("brush.defineFromSelection", json!({"name": "Blob"}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
assert!(result.is_err(), "an allocation failure must be reported, not panic");
assert_eq!(s.tools.brush, brush_before, "the current brush is unchanged");
assert_eq!(s.tools.presets, presets_before, "the preset list is unchanged");
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
}
#[test]
fn works_on_cmyk_and_16_bit() {
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
+12 -4
View File
@@ -109,21 +109,29 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.rename";
let i = find(s, p, "name", cmd)?;
let new = text(p, "newName", cmd)?;
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
rename_at(s, i, &new, cmd)?;
Ok(json!({ "name": new }))
}
/// Rename preset `i` (shared by Brush Settings and Edit › Presets › Preset Manager): refuses a
/// name another preset has (case-insensitively), makes the preset the user's and keeps the
/// current preset pointing at it.
pub(crate) fn rename_at(s: &mut Session, i: usize, new: &str, cmd: &str) -> Result<()> {
if index_of(&s.tools.presets, new).is_some_and(|j| j != i) {
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
}
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
if pr.name != new {
let was_current = s.tools.current_preset.as_ref().is_some_and(|c| c.eq_ignore_ascii_case(&pr.name));
pr.name = new.clone();
pr.name = new.to_string();
pr.builtin = false;
if was_current {
s.tools.current_preset = Some(new.clone());
s.tools.current_preset = Some(new.to_string());
s.tools.brush_changed();
}
s.brush_presets_changed();
}
Ok(json!({ "name": new }))
Ok(())
}
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
+2 -3
View File
@@ -64,10 +64,9 @@ fn validate(p: &Value) -> Result<()> {
Some(Value::String(s)) => {
crate::commands::parse_hex(s).ok_or_else(|| bad(format!("`color` must be \"#rrggbb\", not {s:?}")))?;
}
Some(Value::Array(a)) if (3..=4).contains(&a.len()) && a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) => {}
Some(Value::Array(a)) if (3..=4).contains(&a.len()) => {
if !a.iter().all(|v| v.as_f64().is_some_and(|x| x.is_finite() && (0.0..=1.0).contains(&x))) {
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
}
return Err(bad("`color` as an array must hold 3 or 4 numbers in 0..1"));
}
Some(_) => return Err(bad("`color` must be \"#rrggbb\" or [r, g, b] in 0..1")),
}
+34 -7
View File
@@ -117,7 +117,10 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> {
fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result<Value> {
if let Some(d) = s.active() {
let a = t.affine(f64::from(d.doc.size.width), f64::from(d.doc.size.height));
s.set_sampler_map(a);
// Sampler points are pixel indices, not pixel edges: map the pixel's centre, so the point
// lands on the same pixel `Turn::pixel_map` moves the pixels to (`w - 1 - x`).
let half = photocraft_geom::Affine::translate(0.5, 0.5);
s.set_sampler_map(half.then(&a).then(&photocraft_geom::Affine::translate(-0.5, -0.5)));
}
s.edit(label, |doc, _| {
crate::canvas_geom::turn_canvas(doc, t);
@@ -295,7 +298,8 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that
/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays
/// in another document). `None` when the param is absent.
/// in another document; a name that matches no layer exactly matches regardless of case, so
/// `"background"` finds the Background). `None` when the param is absent.
pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>> {
match p.get("layer") {
Some(Value::Number(n)) => {
@@ -303,19 +307,41 @@ pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result<Option<LayerId>>
}
Some(Value::String(name)) => {
let d = s.active().ok_or(EngineError::NoDocument)?;
d.doc
.walk()
.into_iter()
.rev()
let layers = d.doc.walk();
let mut top_down = layers.iter().rev();
top_down
.clone()
.find(|(_, _, l)| l.name == *name)
.or_else(|| top_down.find(|(_, _, l)| l.name.to_lowercase() == name.to_lowercase()))
.map(|(_, _, l)| Some(l.id))
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"")))
.ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\"; {}", layer_listing(&d.doc))))
}
Some(Value::Null) | None => Ok(None),
Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))),
}
}
/// The layers of `doc` as a caller can name them, for an error about a layer that isn't there:
/// `the document's layers are 2 "Background", 3 "Layer 1"` (top-down, at most 20).
pub(crate) fn layer_listing(doc: &Document) -> String {
const MAX: usize = 20;
let layers = doc.walk();
if layers.is_empty() {
return "the document has no layers".into();
}
// Names are unbounded input: each one is cut at NAME characters so the error stays short.
const NAME: usize = 64;
let name = |n: &str| match n.char_indices().nth(NAME) {
Some((cut, _)) => format!("{}…", &n[..cut]),
None => n.to_string(),
};
let mut list: Vec<String> = layers.iter().rev().take(MAX).map(|(_, _, l)| format!("{} {:?}", l.id.0, name(&l.name))).collect();
if layers.len() > MAX {
list.push(format!("… {} more (see `document.inspect`)", layers.len() - MAX));
}
format!("the document's layers are {} (a `layer` param takes an id or a name)", list.join(", "))
}
/// The layer a command targets: [`explicit_layer`], else the active layer.
pub(crate) fn layer_param(s: &Session, p: &Value) -> Result<LayerId> {
match explicit_layer(s, p)? {
@@ -1422,6 +1448,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::edit_menu_cmds::specs());
v.extend(crate::fill_key_cmds::specs());
v.extend(crate::sample_cmds::specs());
v.extend(crate::face_cmds::specs());
v.extend(crate::sampler_cmds::specs());
v.extend(crate::brush_key_cmds::specs());
v.extend(crate::stamp_cmds::specs());
+58
View File
@@ -0,0 +1,58 @@
//! Paths stored in a document reach the file system only through [`resolve`] (built on
//! [`photocraft_io::doc_path::resolve`]): linked smart objects, the Displace map and Color Lookup
//! file a smart filter re-reads, data-set images and video footage. A refused path is handled like
//! a missing file: the layer keeps its last rendering, and commands that need the file say why.
//!
//! Smart-object rendering has no `Session` argument, so the path of the document being worked on
//! travels in a thread-local scope, entered where commands run ([`crate::Session::edit`], command
//! dispatch, background jobs). Outside a scope there is no document: relative paths are refused
//! and only local absolute paths are followed.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
pub use photocraft_io::doc_path::NotFollowed;
thread_local! {
static DOCUMENT: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}
/// Restores the previous document path when dropped.
pub(crate) struct DocumentScope(Option<PathBuf>);
impl Drop for DocumentScope {
fn drop(&mut self) {
let previous = self.0.take();
DOCUMENT.with(|d| *d.borrow_mut() = previous);
}
}
/// Resolve stored paths against `document` (the path of the document being worked on, `None`
/// for one without a file) until the returned scope is dropped.
pub(crate) fn scope(document: Option<&Path>) -> DocumentScope {
let next = document.filter(|d| !d.as_os_str().is_empty()).map(Path::to_path_buf);
DocumentScope(DOCUMENT.with(|d| d.replace(next)))
}
/// The document path of the current scope (to carry it to another thread).
pub(crate) fn current() -> Option<PathBuf> {
DOCUMENT.with(|d| d.borrow().clone())
}
/// The file to open for `stored`, a path stored in the document of the current scope.
pub fn resolve(stored: &str) -> Result<PathBuf, NotFollowed> {
resolve_in(stored, current().as_deref())
}
/// The file to open for `stored`, a path stored in the document saved at `document`.
pub fn resolve_in(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
photocraft_io::doc_path::resolve(stored, document)
}
/// The message for a stored path that is not followed.
pub(crate) fn refusal(what: &str, stored: &str, why: NotFollowed) -> crate::EngineError {
crate::EngineError::Other(format!("{what} {stored} is not opened: {why}"))
}
#[cfg(test)]
mod tests;
+227
View File
@@ -0,0 +1,227 @@
//! Each kind of place that follows a path stored in a document goes through [`super::resolve`]: a
//! link to another computer is never followed, and a relative link next to the document is.
use std::path::{Path, PathBuf};
use photocraft_doc::{LayerContent, LayerId, SmartSource};
use photocraft_geom::Rect;
use serde_json::json;
use crate::Session;
/// A link to a share on a TEST-NET-1 address, which never answers.
const REMOTE: &str = r"\\192.0.2.1\share\x.png";
/// An empty folder of its own (tests run in parallel).
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-doc-path-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A 32×32 PNG whose pixel at column `x` is `rgb(x)`.
fn png(path: &Path, rgb: impl Fn(u32) -> [u8; 3]) {
let mut px = Vec::new();
for _ in 0..32 {
for x in 0..32 {
px.extend(rgb(x));
}
}
let image = photocraft_codecs::Image::from_u8(32, 32, photocraft_codecs::ChannelLayout::Rgb, px).unwrap();
std::fs::write(path, photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
}
/// On Unix `//` followed by an absolute path names that same local file, while the resolver sees a
/// path on another computer: a place that skipped the resolver would read the file.
#[cfg(unix)]
fn network_alias(p: &Path) -> String {
format!("/{}", p.display())
}
/// A 32×32 white document saved (as far as its links know) at `d/doc.pcraft`, with `file` placed
/// linked → (session, layer id).
fn document(d: &Path, file: &Path) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap();
s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned());
s.execute("file.placeLinked", json!({"path": file.to_string_lossy()})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
(s, id)
}
/// Point smart object `id` at `path` without re-rendering it.
fn relink(s: &mut Session, id: LayerId, path: &str) {
s.edit("Relink", |doc, _| {
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
sm.source = SmartSource::Linked { path: path.into() };
}
Ok(())
})
.unwrap();
}
fn centre(s: &Session) -> [f32; 3] {
let p = photocraft_compose::render(&s.active().unwrap().doc, Rect::new(16, 16, 17, 17)).px[0];
[p[0], p[1], p[2]]
}
const RED: [f32; 3] = [1.0, 0.0, 0.0];
const BLUE: [f32; 3] = [0.0, 0.0, 1.0];
fn update(s: &mut Session) -> Result<serde_json::Value, String> {
s.execute("layer.smartObjects.updateModifiedContent", json!({})).map_err(|e| e.to_string())
}
#[test]
fn linked_smart_objects_follow_only_local_files() {
let d = dir("links");
png(&d.join("red.png"), |_| [255, 0, 0]);
png(&d.join("blue.png"), |_| [0, 0, 255]);
let (mut s, id) = document(&d, &d.join("red.png"));
assert_eq!(centre(&s), RED);
relink(&mut s, id, REMOTE);
s.start_link_scan();
assert!(s.link_scan.is_none(), "no look at a file on another computer");
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []}));
let err = update(&mut s).unwrap_err();
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
assert_eq!(centre(&s), RED, "the layer keeps its rendering");
#[cfg(unix)]
{
relink(&mut s, id, &network_alias(&d.join("blue.png")));
assert!(update(&mut s).is_err());
assert_eq!(centre(&s), RED, "the file was not read");
}
// A relative link is taken from the document's folder.
relink(&mut s, id, "blue.png");
update(&mut s).unwrap();
assert_eq!(centre(&s), BLUE);
s.start_link_scan();
assert!(s.link_scan.is_some(), "a followed link is looked at");
}
#[test]
fn package_copies_only_followed_links() {
let d = dir("package");
png(&d.join("red.png"), |_| [255, 0, 0]);
let (mut s, id) = document(&d, &d.join("red.png"));
relink(&mut s, id, "red.png");
#[allow(unused_mut)]
let mut remote = vec![REMOTE.to_string()];
#[cfg(unix)]
remote.push(network_alias(&d.join("red.png")));
for path in &remote {
s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap();
let other = s.active().unwrap().active_layer.unwrap();
relink(&mut s, other, path);
}
let r = s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy()})).unwrap();
let mut missing: Vec<String> = serde_json::from_value(r["missing"].clone()).unwrap();
missing.sort();
remote.sort();
assert_eq!(missing, remote);
let links = r["links"].as_array().unwrap();
assert_eq!(links.len(), 1);
assert!(links[0].as_str().unwrap().ends_with("red.png"), "{links:?}");
}
#[test]
fn psd_saves_embed_only_followed_links() {
let d = dir("psd");
png(&d.join("red.png"), |_| [255, 0, 0]);
let (mut s, id) = document(&d, &d.join("red.png"));
relink(&mut s, id, "red.png");
s.execute("file.placeLinked", json!({"path": d.join("red.png").to_string_lossy()})).unwrap();
let other = s.active().unwrap().active_layer.unwrap();
relink(&mut s, other, REMOTE);
let warnings = |r: serde_json::Value| r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect::<Vec<_>>();
let saved = warnings(s.execute("file.saveACopy", json!({"path": d.join("copy.psd").to_string_lossy()})).unwrap());
assert!(saved.iter().any(|w| w.contains("red.png was embedded")), "{saved:?}");
assert!(saved.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{saved:?}");
// Package as PSD writes the copy of the document, whose remote link stays as it was.
let packaged = warnings(s.execute("file.package", json!({"dir": d.join("pkg").to_string_lossy(), "format": "psd"})).unwrap());
assert!(packaged.iter().any(|w| w.contains("is not opened: the path is on another computer")), "{packaged:?}");
}
/// The pixels of the active document after its smart object `id` re-renders with its first smart
/// filter's params set to `params`.
fn replayed(s: &mut Session, id: LayerId, params: serde_json::Value) -> Vec<[f32; 4]> {
s.edit("Params", |doc, _| {
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
sm.smart_filters[0].params = params;
}
crate::smart_cmds::refresh(doc, id).map(|_| ())
})
.unwrap();
let doc = &s.active().unwrap().doc;
photocraft_compose::render(doc, doc.bounds()).px
}
#[test]
fn smart_filters_reread_only_followed_files() {
let d = dir("filters");
png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]);
png(&d.join("map.png"), |_| [255, 255, 255]);
// An inverting look: corner (r, g, b) of the cube maps to (1-r, 1-g, 1-b), red fastest.
let corners = (0..8).map(|i| format!("{} {} {}\n", 1 - (i & 1), 1 - ((i >> 1) & 1), 1 - ((i >> 2) & 1))).collect::<String>();
std::fs::write(d.join("invert.cube"), format!("LUT_3D_SIZE 2\n{corners}")).unwrap();
let path = |name: &str| d.join(name).to_string_lossy().into_owned();
// Displace re-reads its `mapPath`.
let (mut s, id) = document(&d, &d.join("ramp.png"));
s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path("map.png"), "horizontal": 50, "vertical": 0})).unwrap();
let displaced = replayed(&mut s, id, json!({"mapPath": path("map.png"), "horizontal": 50, "vertical": 0}));
let unfiltered = replayed(&mut s, id, json!({"mapPath": REMOTE, "horizontal": 50, "vertical": 0}));
assert_ne!(displaced, unfiltered, "the map moves the pixels");
assert_eq!(replayed(&mut s, id, json!({"mapPath": "map.png", "horizontal": 50, "vertical": 0})), displaced, "a relative map next to the document");
#[cfg(unix)]
assert_eq!(replayed(&mut s, id, json!({"mapPath": network_alias(&d.join("map.png")), "horizontal": 50, "vertical": 0})), unfiltered);
// Color Lookup re-reads its LUT `file`.
let (mut s, id) = document(&d, &d.join("ramp.png"));
s.execute("image.adjustments.colorLookup", json!({"layer": id.0, "lut": "none"})).unwrap();
let inverted = replayed(&mut s, id, json!({"file": path("invert.cube")}));
let unfiltered = replayed(&mut s, id, json!({"file": REMOTE}));
assert_ne!(inverted, unfiltered, "the look changes the pixels");
assert_eq!(replayed(&mut s, id, json!({"file": "invert.cube"})), inverted, "a relative look next to the document");
#[cfg(unix)]
assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered);
}
#[test]
fn data_sets_and_footage_read_only_followed_files() {
let d = dir("datasets");
png(&d.join("blue.png"), |_| [0, 0, 255]);
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "#ff0000"})).unwrap();
s.active_mut().unwrap().path = Some(d.join("doc.pcraft").to_string_lossy().into_owned());
let layer = s.active().unwrap().active_layer.unwrap();
s.execute("image.variables.define", json!({"defs": [{"name": "img", "layer": layer.0, "type": "pixelReplacement", "method": "conform"}]})).unwrap();
let set = |name: &str, path: &str| json!({"name": name, "values": [{"variable": "img", "kind": "pixels", "value": path}]});
s.execute("image.variables.dataSets", json!({"dataSets": [set("remote", REMOTE), set("relative", "blue.png")]})).unwrap();
let err = s.execute("image.applyDataSet", json!({"name": "remote"})).unwrap_err().to_string();
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
assert_eq!(centre(&s), RED);
s.execute("image.applyDataSet", json!({"name": "relative"})).unwrap();
assert_eq!(centre(&s), BLUE);
// Reload Frame re-reads the video layer's footage.
s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": d.join("blue.png").to_string_lossy()})).unwrap();
let footage = |s: &mut Session, path: &str| {
let id = s.active().unwrap().active_layer.unwrap();
s.edit("Footage", |doc, _| {
if let Some(v) = doc.layer_mut(id).and_then(|l| l.video.as_mut()) {
v.source = photocraft_doc::VideoSource::File { path: path.into() };
}
Ok(())
})
.unwrap();
s.execute("layer.videoLayers.reloadFrame", json!({})).map_err(|e| e.to_string())
};
let err = footage(&mut s, REMOTE).unwrap_err();
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
footage(&mut s, "blue.png").unwrap();
}
+352 -73
View File
@@ -110,6 +110,12 @@ fn always(_: &Session) -> std::result::Result<(), String> {
fn pixel_layer(s: &Session) -> std::result::Result<LayerId, String> {
let d = s.active().ok_or("no document open")?;
let id = d.active_layer.ok_or("no active layer")?;
pixel_layer_id(s, id)
}
/// `id` if it is an unlocked pixel layer of the active document.
fn pixel_layer_id(s: &Session, id: LayerId) -> std::result::Result<LayerId, String> {
let d = s.active().ok_or("no document open")?;
let l = d.doc.layer(id).ok_or("no active layer")?;
if !matches!(l.content, LayerContent::Raster(_)) {
return Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), l.content.kind_name()));
@@ -358,38 +364,169 @@ fn delete_and_fill(s: &mut Session, _: &Value) -> Result<Value> {
content_aware_fill_as(s, &json!({"sampling": "rectangular"}), "edit.deleteAndFillSelection", "Delete and Fill Selection")
}
fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result<Value> {
use photocraft_algo::content_aware::{FillOptions, auto_sampling_with, color_level, fill_owned, rotation_level};
let id = pixel_layer(s).map_err(EngineError::Other)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = st.doc.clone();
let sel = doc.selection.clone().ok_or_else(|| bad(cmd, "no selection"))?;
let hb = sel.content_bounds().intersect(&doc.bounds());
if hb.is_empty() {
/// Everything one Content-Aware Fill needs, worked out from the document and the command's
/// params. The Content-Aware Fill workspace runs exactly this for its preview, so what it shows
/// is what OK and Apply write.
pub struct CafPlan {
pub layer: LayerId,
/// The rectangle worked on: the sampling window, and any sampling-brush strokes or custom
/// area beyond it.
pub rect: Rect,
/// Photoshop's sampling window around the fill area (`content_aware::sampling_window`).
pub window: Rect,
pub fmt: PixelFormat,
/// What is sampled over `rect`: the target's pixels, or with Sample All Layers the visible
/// composite in the target's encoding.
pub img: Vec<f32>,
/// The target's own pixels over `rect` when they differ from `img` (Sample All Layers); see
/// [`CafPlan::layer_pixels`].
pub layer_px: Option<Vec<f32>>,
/// The fill area (selection coverage, after the workspace's lasso edits) over `rect`.
pub cover: Vec<f32>,
pub hole: Vec<bool>,
/// What may be copied from: the sampling option's area before the brush strokes, and after.
pub base: Vec<bool>,
pub source: Vec<bool>,
pub opts: photocraft_algo::content_aware::FillOptions,
pub output: String,
}
impl CafPlan {
/// The target layer's pixels over `rect`.
pub fn layer_pixels(&self) -> &[f32] {
self.layer_px.as_deref().unwrap_or(&self.img)
}
}
/// The workspace's lasso edits of the fill area: `{"op": "new|add|subtract|intersect", "points":
/// [[x, y], ...]}` or `{"op": "expand|contract", "by": px}`, applied in order.
#[derive(Clone, Debug, PartialEq)]
pub enum FillEdit {
Lasso { op: SelOp, points: Vec<[f64; 2]> },
Grow(i32),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelOp {
New,
Add,
Subtract,
Intersect,
}
/// Most `fillEdits` and `samplingStrokes` one fill takes, and most points in all of them
/// together: the strokes and lassos are painted pixel by pixel, segment by segment.
pub const MAX_CAF_EDITS: usize = 1_000;
pub const MAX_CAF_POINTS: usize = 200_000;
fn points_param(v: &Value, cmd: &str, what: &str, budget: &mut usize) -> Result<Vec<[f64; 2]>> {
let a = v.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("{what} needs `points` [[x, y], ...]")))?;
*budget = budget.checked_sub(a.len()).ok_or_else(|| bad(cmd, format!("at most {MAX_CAF_POINTS} points in all fill edits and sampling strokes")))?;
a.iter()
.map(|p| {
let x = p.get(0).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
let y = p.get(1).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e7);
match (x, y) {
(Some(x), Some(y)) => Ok([x, y]),
_ => Err(bad(cmd, format!("{what} points must be pairs of finite numbers"))),
}
})
.collect()
}
/// `p[key]` as an array of at most [`MAX_CAF_EDITS`] entries (empty when absent).
fn edits_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<&'a [Value]> {
let Some(a) = p.get(key) else { return Ok(&[]) };
let a = a.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be an array")))?;
if a.len() > MAX_CAF_EDITS {
return Err(bad(cmd, format!("`{key}` takes at most {MAX_CAF_EDITS} entries")));
}
Ok(a)
}
fn fill_edits(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<FillEdit>> {
edits_param(p, "fillEdits", cmd)?
.iter()
.map(|e| {
let op = e.get("op").and_then(Value::as_str).unwrap_or("");
Ok(match op {
"expand" | "contract" => {
let by = e
.get("by")
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && (0.0..=1000.0).contains(v))
.ok_or_else(|| bad(cmd, "`by` must be 0..1000 px"))?;
FillEdit::Grow(if op == "expand" { by.round() as i32 } else { -(by.round() as i32) })
}
"new" | "add" | "subtract" | "intersect" => FillEdit::Lasso {
op: match op {
"new" => SelOp::New,
"add" => SelOp::Add,
"subtract" => SelOp::Subtract,
_ => SelOp::Intersect,
},
points: points_param(e, cmd, "a lasso edit", budget)?,
},
o => return Err(bad(cmd, format!("unknown fill edit `{o}` (new|add|subtract|intersect|expand|contract)"))),
})
})
.collect()
}
fn sampling_strokes(p: &Value, cmd: &str, budget: &mut usize) -> Result<Vec<photocraft_algo::content_aware::SamplingStroke>> {
edits_param(p, "samplingStrokes", cmd)?
.iter()
.map(|e| {
let add = match e.get("mode").and_then(Value::as_str).unwrap_or("add") {
"add" => true,
"subtract" => false,
o => return Err(bad(cmd, format!("unknown stroke mode `{o}` (add|subtract)"))),
};
let size = e
.get("size")
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && (1.0..=5000.0).contains(v))
.ok_or_else(|| bad(cmd, "a stroke's `size` must be 1..5000 px"))?;
Ok(photocraft_algo::content_aware::SamplingStroke { add, size, points: points_param(e, cmd, "a sampling stroke", budget)? })
})
.collect()
}
/// A Content-Aware Fill's params, checked against the document before any pixel is read.
struct CafParams<'a> {
sel: &'a Surface,
/// The selection's bounds on the canvas.
sb: Rect,
edits: Vec<FillEdit>,
strokes: Vec<photocraft_algo::content_aware::SamplingStroke>,
sampling: String,
custom_mask: Option<&'a Surface>,
custom_rect: Option<Rect>,
opts: photocraft_algo::content_aware::FillOptions,
output: String,
surf: &'a Surface,
}
fn caf_params<'a>(doc: &'a Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafParams<'a>> {
use photocraft_algo::content_aware::{FillOptions, color_level, rotation_level};
let sel = doc.selection.as_ref().ok_or_else(|| bad(cmd, "no selection"))?;
let sb = sel.content_bounds().intersect(&doc.bounds());
if sb.is_empty() {
return Err(bad(cmd, "the selection is outside the canvas"));
}
let mut budget = MAX_CAF_POINTS;
let edits = fill_edits(p, cmd, &mut budget)?;
let strokes = sampling_strokes(p, cmd, &mut budget)?;
let sampling = str_or(p, "sampling", "auto").to_string();
let custom_mask: Option<Surface> = p.get("channel").and_then(|v| channel_mask(&doc, v)).cloned();
let custom_mask: Option<&Surface> = p.get("channel").and_then(|v| channel_mask(doc, v));
let custom_rect = if p.get("area").is_some() { Some(rect_param(p, "area", cmd)?) } else { None };
// Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin`
// asks for a rectangle that far around the selection instead.
let window = match sampling.as_str() {
"auto" => crate::fill_cmds::sampling_window(hb, doc.bounds()),
"rectangular" => match int(p, "margin") {
Some(m) => hb.inflate(m.clamp(0, 100_000) as i32),
None => crate::fill_cmds::sampling_window(hb, doc.bounds()),
},
"custom" => {
let r = match (custom_rect, custom_mask.as_ref()) {
(Some(r), _) => r,
(None, Some(m)) => m.content_bounds(),
(None, None) => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h] or `channel` (alpha channel index or name)")),
};
r.union(&hb).inflate(4)
}
match sampling.as_str() {
"auto" | "rectangular" => {}
// The workspace's Custom: only what the Sampling Brush adds.
"custom" if custom_rect.is_some() || custom_mask.is_some() || strokes.iter().any(|s| s.add) => {}
"custom" => return Err(bad(cmd, "custom sampling needs `area` [x,y,w,h], `channel` (alpha channel index or name) or `samplingStrokes`")),
other => return Err(bad(cmd, format!("unknown sampling `{other}` (auto|rectangular|custom)"))),
}
.intersect(&doc.bounds());
let opts = FillOptions {
color_adaptation: color_level(str_or(p, "colorAdaptation", "default")),
rotations: rotation_level(str_or(p, "rotationAdaptation", "none")),
@@ -402,6 +539,77 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
return Err(bad(cmd, "`output` must be current|new|duplicate"));
}
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
Ok(CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf })
}
/// Work out a Content-Aware Fill (see [`CafPlan`]) on layer `id` of `doc`.
pub fn content_aware_plan(doc: &Document, id: LayerId, p: &Value, cmd: &str) -> Result<CafPlan> {
content_aware_plan_with(doc, id, p, cmd, &photocraft_raster::Interrupt::NONE)
}
/// [`content_aware_plan`], asking `ctl` row by row whether to stop ([`EngineError::Cancelled`]).
pub fn content_aware_plan_with(doc: &Document, id: LayerId, p: &Value, cmd: &str, ctl: &photocraft_raster::Interrupt) -> Result<CafPlan> {
use photocraft_algo::content_aware::{auto_sampling_with, grow_mask, paint_strokes, polygon_mask};
use rayon::prelude::*;
use crate::fill_cmds::{read_window, read_window_mask};
let CafParams { sel, sb, edits, strokes, sampling, custom_mask, custom_rect, opts, output, surf } = caf_params(doc, id, p, cmd)?;
let canvas = doc.bounds();
let cancel = || ctl.cancelled();
let stop = || if ctl.cancelled() { Err(EngineError::Cancelled) } else { Ok(()) };
// The fill area: the selection, then the lasso edits, over a rectangle that holds them all.
let grow: i32 = edits.iter().map(|e| if let FillEdit::Grow(b) = e { b.max(&0) } else { &0 }).sum();
let reach = edits.iter().fold(sb, |r, e| match e {
FillEdit::Lasso { points, .. } => {
points.iter().fold(r, |r, q| r.union(&Rect::new(q[0].floor() as i32, q[1].floor() as i32, q[0].floor() as i32 + 1, q[1].floor() as i32 + 1)))
}
FillEdit::Grow(_) => r,
});
let er = reach.inflate(grow.saturating_add(1)).intersect(&canvas);
let (ew, eh) = crate::fill_cmds::window_size(er)?;
let ecover = read_window_mask(sel, er, &cancel).ok_or(EngineError::Cancelled)?;
let mut ehole: Vec<bool> = ecover.par_iter().map(|c| *c > 0.0).collect();
for e in &edits {
stop()?;
match e {
FillEdit::Lasso { op, points } => {
let m = polygon_mask(er, points);
for (h, v) in ehole.iter_mut().zip(&m) {
*h = match op {
SelOp::New => *v,
SelOp::Add => *h || *v,
SelOp::Subtract => *h && !*v,
SelOp::Intersect => *h && *v,
};
}
}
FillEdit::Grow(by) => ehole = grow_mask(&ehole, ew, eh, *by),
}
}
let (bx0, by0, bx1, by1) = mask_bounds(ew, eh, &ehole);
if bx1 <= bx0 {
return Err(bad(cmd, "the fill area is empty"));
}
let hb = Rect::new(er.x0 + bx0 as i32, er.y0 + by0 as i32, er.x0 + bx1 as i32, er.y0 + by1 as i32);
// Photoshop's window (`content_aware::sampling_window`) for Auto and Rectangular; `margin`
// asks for a rectangle that far around the fill area instead.
let window = match sampling.as_str() {
"rectangular" => match int(p, "margin") {
Some(m) => hb.inflate(m.clamp(0, 100_000) as i32),
None => crate::fill_cmds::sampling_window(hb, canvas),
},
"custom" => match (custom_rect, custom_mask) {
(Some(r), _) => r,
(None, Some(m)) => m.content_bounds(),
(None, None) => hb,
}
.union(&hb)
.inflate(4),
_ => crate::fill_cmds::sampling_window(hb, canvas),
}
.intersect(&canvas);
// Strokes that add to the sampling area may reach past the window.
let rect = strokes.iter().filter(|s| s.add).fold(window, |r, s| r.union(&s.bounds())).intersect(&canvas);
let fmt = surf.format();
// Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active
// layer has no pixels in the window there is nothing to sample, so that case uses the
@@ -412,56 +620,127 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &
};
let n = fmt.channels();
// Refuse a window too large to read before allocating for it, whatever the sampling (#963).
let (w, h) = crate::fill_cmds::window_size(window)?;
// A background job when started with `Session::start` (#210): reading the window and the
// PatchMatch fill run on a worker against the document snapshot, cancellable per row band.
let (w, h) = crate::fill_cmds::window_size(rect)?;
let pixels = read_window(surf, rect, &cancel).ok_or(EngineError::Cancelled)?;
let (img, layer_px) = if sample_all {
stop()?;
(crate::retouch_cmds::composite_region(doc, Some(id), crate::retouch_cmds::SampleLayers::All, rect, fmt).data, Some(pixels))
} else {
(pixels, None)
};
stop()?;
let mut cover = vec![0.0f32; w * h];
let mut hole = vec![false; w * h];
cover.par_chunks_mut(w.max(1)).zip(hole.par_chunks_mut(w.max(1))).enumerate().try_for_each(|(y, (crow, hrow))| {
stop()?;
let dy = rect.y0 + y as i32;
if dy < er.y0 || dy >= er.y1 {
return Ok(());
}
for (x, (c, hl)) in crow.iter_mut().zip(hrow.iter_mut()).enumerate() {
let dx = rect.x0 + x as i32;
if dx >= er.x0 && dx < er.x1 {
let j = (dy - er.y0) as usize * ew + (dx - er.x0) as usize;
let (eh, ec) = (ehole.get(j).copied().unwrap_or(false), ecover.get(j).copied().unwrap_or(0.0));
*hl = eh;
// Pixels the lasso added are filled outright; selected ones by their coverage.
*c = if eh { if ec > 0.0 { ec } else { 1.0 } } else { 0.0 };
}
}
Ok::<(), EngineError>(())
})?;
drop((ecover, ehole));
// The sampling option's area: Auto keeps what looks like the surroundings within the window,
// Rectangular the whole window, Custom the given area (or nothing, for the brush to add to).
let mut base = vec![false; w * h];
match sampling.as_str() {
"auto" | "rectangular" => {
let (ww, wh) = (window.width() as usize, window.height() as usize);
let (ox, oy) = ((window.x0 - rect.x0) as usize, (window.y0 - rect.y0) as usize);
let area: Vec<bool> = match sampling.as_str() {
"auto" if window == rect => auto_sampling_with(w, h, n, &img, &hole, ctl).map_err(|_| EngineError::Cancelled)?,
"auto" => {
let mut wimg = vec![0.0f32; ww * wh * n];
let mut whole = vec![false; ww * wh];
for y in 0..wh {
let s = ((oy + y) * w + ox) * n;
wimg[y * ww * n..(y + 1) * ww * n].copy_from_slice(&img[s..s + ww * n]);
whole[y * ww..(y + 1) * ww].copy_from_slice(&hole[(oy + y) * w + ox..(oy + y) * w + ox + ww]);
}
auto_sampling_with(ww, wh, n, &wimg, &whole, ctl).map_err(|_| EngineError::Cancelled)?
}
_ => vec![true; ww * wh],
};
for y in 0..wh {
base[(oy + y) * w + ox..(oy + y) * w + ox + ww].copy_from_slice(&area[y * ww..(y + 1) * ww]);
}
}
_ => match (custom_rect, custom_mask) {
(Some(r), _) => base.par_iter_mut().enumerate().for_each(|(i, b)| *b = r.contains(rect.x0 + (i % w) as i32, rect.y0 + (i / w) as i32)),
(None, Some(m)) => {
let v = read_window_mask(m, rect, &cancel).ok_or(EngineError::Cancelled)?;
base.par_iter_mut().zip(&v).for_each(|(b, v)| *b = *v > 0.5);
}
(None, None) => {}
},
}
stop()?;
base.par_iter_mut().zip(&hole).for_each(|(b, h)| *b &= !*h);
let mut source = base.clone();
paint_strokes(&mut source, rect, &strokes);
source.par_iter_mut().zip(&hole).for_each(|(s, h)| *s &= !*h);
Ok(CafPlan { layer: id, rect, window, fmt, img, layer_px, cover, hole, base, source, opts, output })
}
fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: &'static str) -> Result<Value> {
use photocraft_algo::content_aware::fill_owned;
// The workspace names the layer it opened on: after an Apply to a new layer that one is
// active, but the fill still samples the original.
let id = match p.get("layer").and_then(Value::as_u64) {
Some(l) => pixel_layer_id(s, LayerId(l)),
None => pixel_layer(s),
}
.map_err(EngineError::Other)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let doc = st.doc.clone();
// Check the params before the job starts.
caf_params(&doc, id, p, cmd)?;
let p = p.clone();
// A background job when started with `Session::start` (#210): working out the fill area and
// the sampling area, reading the pixels and the PatchMatch fill run on a worker against the
// document snapshot, cancellable per row band.
crate::jobs::run(
s,
label,
true,
move |ctx| {
use rayon::prelude::*;
use crate::fill_cmds::{read_window, read_window_cover, read_window_mask};
ctx.progress(0.0, label);
let cancel = || ctx.cancelled();
let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?;
let img = if sample_all {
if cancel() {
return Err(EngineError::Cancelled);
}
let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt);
if cancel() {
return Err(EngineError::Cancelled);
}
region.data
} else {
read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?
};
let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?;
let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) {
("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(),
("custom", None, Some(m)) => read_window_mask(m, window, &cancel).ok_or(EngineError::Cancelled)?.par_iter().map(|v| *v > 0.5).collect(),
_ => vec![true; w * h],
};
ctx.check()?;
// Auto: only what looks like the selection's surroundings.
if sampling == "auto" {
source = auto_sampling_with(w, h, n, &img, &hole, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|_| EngineError::Cancelled)?;
}
ctx.check()?;
// The fill changes only the selected pixels: keep the original and the coverage over
// their bounds for the apply step, and fill the window's pixels themselves.
let plan = content_aware_plan_with(&doc, id, &p, cmd, &photocraft_raster::Interrupt::cancel_only(&cancel))?;
let CafPlan { layer, rect, fmt, img, layer_px, cover, hole, source, opts, output, .. } = plan;
let (w, h) = (rect.width() as usize, rect.height() as usize);
let n = fmt.channels();
// The fill changes only the fill area: keep the layer's pixels and the coverage over
// its bounds for the apply step, and fill the sampled pixels themselves.
let (bx0, by0, bx1, by1) = mask_bounds(w, h, &hole);
let original: Vec<f32> = (by0..by1).flat_map(|y| img[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect();
let at = |x: usize, y: usize| (window.x0 + x as i32, window.y0 + y as i32);
let ((x0, y0), (x1, y1)) = (at(bx0, by0), at(bx1, by1));
let cover = read_window_mask(&sel, Rect::new(x0, y0, x1, y1), &cancel).ok_or(EngineError::Cancelled)?;
let own = layer_px.as_deref().unwrap_or(&img);
let original: Vec<f32> = (by0..by1).flat_map(|y| own[(y * w + bx0) * n..(y * w + bx1) * n].iter().copied()).collect();
let cover: Vec<f32> = (by0..by1).flat_map(|y| cover[y * w + bx0..y * w + bx1].iter().copied()).collect();
let n_hole = cover.iter().filter(|c| **c > 0.0).count();
let filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?;
Ok(FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole })
ctx.check()?;
let mut filled = ctx.stage(0.05, 1.0, label, |ctl| fill_owned(w, h, n, img, &hole, &source, &opts, ctl)).map_err(|_| EngineError::Cancelled)?;
// Sampled from all layers: outside the fill area the window keeps the layer's pixels.
if let Some(own) = layer_px {
filled.par_chunks_mut(n.max(1)).zip(own.par_chunks(n.max(1))).zip(hole.par_iter()).for_each(|((f, o), h)| {
if !*h {
f.copy_from_slice(o);
}
});
}
Ok((layer, rect, fmt, output, FilledWindow { filled, bounds: (bx0, by0, bx1, by1), original, cover, n_hole }))
},
move |s, done| apply_content_aware_fill(s, label, id, &output, fmt, window, done),
move |s, (layer, rect, fmt, output, done)| apply_content_aware_fill(s, label, layer, &output, fmt, rect, done),
)
}
@@ -1027,18 +1306,18 @@ fn preset_manager(s: &mut Session, p: &Value) -> Result<Value> {
let new =
p.get("newName").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "missing `newName`"))?.to_string();
match kind.as_str() {
"brushes" => {
// A renamed built-in is the user's preset now (and persists as one).
let b = &mut s.tools.presets[i];
b.name = new;
b.builtin = false;
}
// Same rules as Brush Settings: no duplicate names (case-insensitive), the current
// preset follows, a renamed built-in is the user's preset now (and persists as one).
"brushes" => crate::brush_preset_cmds::rename_at(s, i, &new, cmd)?,
"patterns" => s.patterns.items[i].name = new,
_ => s.edit_state.custom_shapes[i].name = new,
}
}
"delete" => match kind.as_str() {
"brushes" => drop(s.tools.presets.remove(i)),
"brushes" => {
let gone = s.tools.presets.remove(i);
crate::brush_cmds::forget_deleted_preset(s, &gone.name);
}
"patterns" => drop(s.patterns.items.remove(i)),
_ => drop(s.edit_state.custom_shapes.remove(i)),
},
@@ -1169,7 +1448,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Content-Aware Fill…",
["Edit"],
None,
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##,
r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"samplingStrokes":[{"mode":"add|subtract","size":px,"points":[[x,y],…]}]? (the workspace's Sampling Brush, after the sampling option's area; added strokes may lie outside the window),"fillEdits":[{"op":"new|add|subtract|intersect","points":[[x,y],…]}|{"op":"expand|contract","by":px}]? (the workspace's lasso edits of the fill area, in order),"sampleAllLayers":bool=false,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","layer":id?=active (the layer filled and sampled),"seed":u64=1}"##,
can_caf,
content_aware_fill
),
+75
View File
@@ -266,6 +266,52 @@ fn content_aware_fill_samples_photoshops_window() {
}
}
#[test]
fn content_aware_fill_takes_the_workspace_edits() {
// The plan the workspace previews: lasso edits change the fill area (and the window follows),
// sampling-brush strokes change what may be copied, also outside the window.
let mut s = Session::new();
s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap();
s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap();
let st = s.active().unwrap();
let (doc, id) = (st.doc.clone(), st.active_layer.unwrap());
let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill");
let count = |m: &[bool]| m.iter().filter(|v| **v).count();
let base = plan(json!({"sampling": "rectangular"})).unwrap();
assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500));
assert_eq!(count(&base.source), 200 * 200 - 2500);
// A lasso square added beside the selection: a bigger fill area, a window around both.
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap();
assert_eq!(count(&p.hole), 5000);
assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window);
// Expand by 2 grows the 50 px square to about 54 px (rounded corners).
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap();
assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole));
// Subtracting it all leaves nothing to fill.
assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err());
// A subtracting stroke takes a band out of the sampling area; an adding one far away reaches
// past the window, and the plan's rectangle grows to hold it.
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap();
assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60);
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap();
assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350));
assert!(count(&p.source) > count(&base.source) + 600);
// Custom from strokes alone; bad edits are errors.
let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap();
assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source));
for bad in [
json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}),
json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}),
json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}),
json!({"fillEdits": [{"op": "expand", "by": -3}]}),
] {
assert!(plan(bad.clone()).is_err(), "{bad}");
}
// The command fills with the same plan; `layer` names the layer even when another is active.
let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap();
assert!(r["filled"].as_u64().unwrap() > 2800);
}
#[test]
fn content_aware_fill_outputs_and_sampling() {
let mut s = blob_session(8);
@@ -427,6 +473,35 @@ fn define_custom_shape_and_preset_manager() {
assert!(t.execute("edit.presets.exportImportPresets", json!({"action": "import", "data": {"format": "other"}})).is_err());
}
#[test]
fn preset_manager_brush_rename_follows_current_preset() {
let mut s = Session::new();
s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap();
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Chalk", "newName": "Chalky"})).unwrap();
assert_eq!(s.tools.current_preset.as_deref(), Some("Chalky"));
s.execute("brush.presets.update", json!({})).unwrap();
}
#[test]
fn preset_manager_brush_rename_refuses_duplicate_name() {
let mut s = Session::new();
let before: Vec<String> = s.tools.presets.iter().map(|b| b.name.clone()).collect();
let r = s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "hard round"}));
assert!(r.is_err(), "a case-insensitive duplicate name must be refused");
assert_eq!(s.tools.presets.iter().map(|b| b.name.clone()).collect::<Vec<_>>(), before);
// Renaming to a different case of its own name is fine.
s.execute("edit.presets.presetManager", json!({"action": "rename", "kind": "brushes", "name": "Soft Round", "newName": "soft round"})).unwrap();
}
#[test]
fn preset_manager_brush_delete_clears_current_preset() {
let mut s = Session::new();
s.execute("brush.presets.setCurrent", json!({"name": "Chalk"})).unwrap();
s.execute("edit.presets.presetManager", json!({"action": "delete", "kind": "brushes", "name": "Chalk"})).unwrap();
assert!(!s.tools.presets.iter().any(|b| b.name == "Chalk"));
assert_eq!(s.tools.current_preset, None);
}
#[test]
fn define_brush_preset_wraps_define_from_selection() {
let mut s = session(8);
+21 -1
View File
@@ -52,7 +52,8 @@ fn open_exr_parts(s: &mut Session, p: &Value) -> Result<Value> {
precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false),
};
let bytes = read_file(&path)?;
let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mut r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
crate::file_cmds::tag_exr_colorspace(s, &path, &mut r.document);
let (i, color) = s.open_document(r.document, Some(path.clone()));
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
}
@@ -173,6 +174,25 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
/// File Handling › "Open color-less EXR as ACEScg" applies to Open EXR Parts too.
#[test]
fn open_exr_parts_honours_the_aces_cg_preference() {
let dir = std::env::temp_dir().join(format!("pcraft-exr-aces-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("aov.exr");
write_multipart(&path);
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
for (on, want) in [(false, &linear), (true, &aces)] {
let mut s = session();
s.prefs.edit(|p| p.file_handling.exr_aces_cg = on);
s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.icc_profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() {
let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id()));
+48 -2
View File
@@ -335,15 +335,18 @@ fn clouds(s: &mut Session, p: &Value, difference: bool) -> Result<Value> {
const TRANSFORM_TARGET: &str =
r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#;
/// Flips and turns about the centre of the box Free Transform would open: with a selection
/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All
/// mirrors across the canvas (#6928); otherwise the content's bounds.
fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
let d = s.active().ok_or(EngineError::NoDocument)?;
// A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835).
let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? {
Some(surf) => (crate::transform_cmds::target_bounds(&d.doc, surf), json!({"target": p.get("target")})),
Some(surf) => (crate::transform_cmds::target_frame(&d.doc, surf), json!({"target": p.get("target")})),
None => {
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
(crate::transform_cmds::transform_bounds(&d.doc, l), json!({"layer": id.0}))
(crate::transform_cmds::transform_frame(&d.doc, l), json!({"layer": id.0}))
}
};
if b.is_empty() {
@@ -1469,6 +1472,49 @@ mod tests {
assert_eq!(pixel(&s, 10, 10), vec![0.0, 1.0, 0.0, 1.0]);
}
/// #6928: with a selection, the presets flip and turn about the selection's own bounds, as
/// in Photoshop: Select All mirrors across the canvas, a marquee across its own centre.
/// Without a selection they keep turning about the content (above).
#[test]
fn transform_presets_turn_about_the_selection() {
let mut s = session(8);
paint_square(&mut s, Rect::new(10, 10, 20, 14), [1.0, 0.0, 0.0, 1.0]);
paint_square(&mut s, Rect::new(10, 10, 11, 11), [0.0, 1.0, 0.0, 1.0]);
let green = vec![0.0, 1.0, 0.0, 1.0];
// Select All on the 40 × 30 canvas: across the canvas's centre lines.
s.execute("select.all", json!({})).unwrap();
let past = s.active().unwrap().history.past_len();
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 14));
assert_eq!(pixel(&s, 29, 10), green);
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step");
s.execute("edit.transform.flipVertical", json!({})).unwrap();
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(20, 16, 30, 20));
assert_eq!(pixel(&s, 29, 19), green);
s.execute("edit.transform.rotate180", json!({})).unwrap();
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14));
assert_eq!(pixel(&s, 10, 10), green);
// A marquee (0, 0)–(24, 30) holding the whole square: across x = 12.
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 30})).unwrap();
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(4, 10, 14, 14));
assert_eq!(pixel(&s, 13, 10), green);
// Undo puts it back where it was.
s.undo();
assert_eq!(active(&s).surface().unwrap().content_bounds(), Rect::new(10, 10, 20, 14));
// A targeted alpha channel turns about the selection's bounds too.
s.execute("channel.new", json!({})).unwrap();
s.edit("paint channel", |doc, _| {
doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
Ok(())
})
.unwrap();
s.execute("channel.target", json!({"channel": 0})).unwrap();
s.execute("select.all", json!({})).unwrap();
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20));
}
#[test]
fn reselect_restores_the_last_selection() {
let mut s = session(8);
+115
View File
@@ -0,0 +1,115 @@
//! Face detection (#3603): the faces in the document, with their box and five landmarks, from
//! [`photocraft_algo::faces`]. Select › Color Range's Detect Faces and Face-Aware Liquify read
//! faces through [`faces_in`]; `image.detectFaces` reports them to scripts and agents.
use photocraft_algo::faces::{self, Face};
use photocraft_doc::{Document, LayerId};
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
/// Faces scoring below this are left out unless asked for (libfacedetection's own threshold for a
/// reliable face).
pub const MIN_SCORE: f32 = 0.5;
/// Why there are no faces in the browser build (see [`photocraft_algo::faces`]).
const UNAVAILABLE: &str = "face detection isn't available in the browser version of PhotoCraft; use the desktop app";
/// The faces in `px` (`w × h` straight RGBA), as [`faces::detect`] finds them.
pub fn detect(px: &[[f32; 4]], w: usize, h: usize, min_score: f32) -> Result<Vec<Face>> {
if !faces::AVAILABLE {
return Err(EngineError::Other(UNAVAILABLE.into()));
}
faces::detect(px, w, h, min_score).ok_or_else(|| EngineError::Other("face detection failed".into()))
}
/// The faces in `doc`, best first: in the composite, or in the active layer's pixels when
/// `all_layers` is off. Coordinates are document pixels.
pub fn faces_in(doc: &Document, active: Option<LayerId>, all_layers: bool, min_score: f32) -> Result<Vec<Face>> {
let area = doc.bounds();
let (w, h) = (area.width() as usize, area.height() as usize);
let surface = active.and_then(|id| doc.layer(id)).and_then(|l| l.surface()).filter(|_| !all_layers);
let px = match surface {
Some(surf) => {
let mut px = vec![[0.0f32; 4]; w * h];
surf.read_rgba_into(area, &mut px);
px
}
None => photocraft_compose::render(doc, area).px,
};
let found = detect(&px, w, h, min_score)?;
let (x0, y0) = (area.x0 as f32, area.y0 as f32);
Ok(found
.into_iter()
.map(|f| Face { rect: [f.rect[0] + x0, f.rect[1] + y0, f.rect[2], f.rect[3]], landmarks: f.landmarks.map(|[x, y]| [x + x0, y + y0]), ..f })
.collect())
}
fn face_json(f: &Face) -> Value {
let pt = |i: usize| json!([f.landmarks[i][0], f.landmarks[i][1]]);
json!({
"score": f.score,
"rect": f.rect,
"landmarks": {"rightEye": pt(0), "leftEye": pt(1), "nose": pt(2), "mouthRight": pt(3), "mouthLeft": pt(4)},
})
}
fn detect_faces(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "image.detectFaces";
let bad = |msg: &str| EngineError::BadParams { cmd: CMD.into(), msg: msg.into() };
let min_score = match p.get("minScore") {
None => MIN_SCORE,
Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad("minScore must be a number in 0..1"))? as f32,
};
let all_layers = match p.get("sampleAllLayers") {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad("sampleAllLayers must be true or false"))?,
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let found = faces_in(&d.doc, d.active_layer, all_layers, min_score)?;
Ok(json!({ "faces": found.iter().map(face_json).collect::<Vec<_>>() }))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "image.detectFaces",
label: "Detect Faces",
menu: &[],
shortcut: None,
params: r##"{"minScore":0..1=0.5,"sampleAllLayers":bool=true} → {"faces":[{"score","rect":[x,y,w,h],"landmarks":{"rightEye","leftEye","nose","mouthRight","mouthLeft"}: [x,y] each},…]} (best first; "rightEye" is the one on the image's left)"##,
enabled: |s| {
if !faces::AVAILABLE {
return Err(UNAVAILABLE.into());
}
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
},
run: detect_faces,
journal: false,
}]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
#[test]
fn detect_faces_reports_no_faces_on_a_plain_image_and_validates_params() {
let mut s = Session::new();
assert!(s.execute("image.detectFaces", json!({})).is_err(), "no document");
assert!(!s.is_enabled("image.detectFaces"));
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
assert!(s.is_enabled("image.detectFaces"));
s.execute("select.all", json!({})).unwrap();
assert_eq!(s.execute("image.detectFaces", json!({})).unwrap(), json!({"faces": []}));
assert_eq!(s.execute("image.detectFaces", json!({"minScore": 0, "sampleAllLayers": false})).unwrap(), json!({"faces": []}));
// No history step of its own: Undo undoes the Select All before it.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active().unwrap().doc.selection.is_none());
for bad in [json!({"minScore": 2}), json!({"minScore": "high"}), json!({"sampleAllLayers": 1})] {
assert!(s.execute("image.detectFaces", bad.clone()).is_err(), "{bad}");
}
}
}
+82 -3
View File
@@ -219,6 +219,40 @@ pub(crate) fn stem(path: &str) -> String {
}
}
/// Whether `a` and `b` name the same file: the same text, or, when both exist, the same file on
/// disk (a hard link, a different case on a case-insensitive volume, `./` or `..` in one of them).
pub fn same_file(a: &str, b: &str) -> bool {
if a == b {
return true;
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return x.dev() == y.dev() && x.ino() == y.ino();
}
}
#[cfg(windows)]
{
// NTFS and FAT compare names without case.
if let (Ok(x), Ok(y)) = (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
return x.to_string_lossy().to_lowercase() == y.to_string_lossy().to_lowercase();
}
}
false
}
/// A path as command results report it: `/` between components, whatever the platform.
///
/// `join` already builds with `/`, but a directory from the caller or from `std::path::absolute`
/// carries the native separator, which would leave a reported path mixed. The control channel
/// takes `/` and rejects alternate separators (`AuthorizedWorkspace` in `crates/automation`), so
/// a result spelt this way can be handed straight back to it. Verbatim and UNC prefixes keep
/// theirs: Win32 does not accept `/` after a leading `\\`.
pub(crate) fn forward_slashes(path: String) -> String {
if path.starts_with(r"\\") { path } else { path.replace('\\', "/") }
}
pub(crate) fn join(dir: &str, name: &str) -> String {
if dir.is_empty() {
name.to_string()
@@ -340,6 +374,8 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> R
tga_bits: save.tga_bits,
image_previews: save.image_previews,
psd_maximize_compatibility: save.psd_maximize_compatibility,
// Linked files a PSD embeds are resolved against the document being worked on.
document_path: crate::doc_path::current(),
..Default::default()
};
// No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP
@@ -461,7 +497,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
/// With File Handling's ACEScg option on, such an EXR opens as ACEScg instead (AP1,
/// linear) — the working space ACES render pipelines write. Only the io default is
/// replaced; an embedded or EXIF-declared profile is kept untouched.
fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
pub(crate) fn tag_exr_colorspace(s: &Session, name: &str, doc: &mut Document) {
if !name.to_ascii_lowercase().ends_with(".exr") || !s.prefs.get().file_handling.exr_aces_cg {
return;
}
@@ -578,7 +614,16 @@ fn place(s: &mut Session, p: &Value, linked: bool) -> Result<Value> {
let cmd = if linked { "file.placeLinked" } else { "file.placeEmbedded" };
let path = str_param(p, "path", cmd)?.to_string();
let bytes = read_file(&path)?;
place_bytes(s, &path, bytes, linked.then(|| path.clone()), p)
// The link is stored absolute: a relative one would later be taken from the document's
// folder (`doc_path`), not the working directory the path was given in.
let link = linked.then(|| {
if std::path::Path::new(&path).is_relative() {
std::path::absolute(&path).map_or_else(|_| path.clone(), |a| a.to_string_lossy().into_owned())
} else {
path.clone()
}
});
place_bytes(s, &path, bytes, link, p)
}
// ---------- File Info (XMP) ----------
@@ -1432,7 +1477,7 @@ fn bake_cube_layers(size: usize, title: &str, layers: &[&Layer]) -> String {
lattice.layers.push(a);
}
let out = photocraft_compose::flatten(&lattice);
let mut s = format!("TITLE \"{}\"\n# Created by Photocraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'"));
let mut s = format!("TITLE \"{}\"\n# Created by PhotoCraft\nLUT_3D_SIZE {n}\nDOMAIN_MIN 0.0 0.0 0.0\nDOMAIN_MAX 1.0 1.0 1.0\n", title.replace('"', "'"));
// .cube order: red changes fastest, then green, then blue.
for b in 0..n {
for g in 0..n {
@@ -1761,4 +1806,38 @@ mod aces_exr_tests {
let d = &s.active().unwrap().doc;
assert_eq!(d.icc_profile.as_deref().map(|v| v.as_slice()), Some(aces.as_slice()));
}
/// The desktop app opens files as background jobs (File > Open, drag-drop, Open Recent):
/// the ACEScg preference applies there too, not only to `file.openAs`.
#[test]
fn exr_aces_cg_preference_applies_to_background_opens() {
use crate::jobs::{OpenSource, Started};
let image = photocraft_codecs::Image::from_f32(4, 4, photocraft_codecs::ChannelLayout::Rgb, &[0.5; 48]).unwrap();
let bytes =
std::sync::Arc::new(photocraft_codecs::encode(&image, photocraft_codecs::Format::OpenExr, &photocraft_codecs::EncodeOptions::default()).unwrap());
let linear = photocraft_cms::Builtin::LinearSrgb.profile().to_bytes();
let aces = photocraft_cms::Builtin::AcesCg.profile().to_bytes();
for (on, want) in [(false, &linear), (true, &aces)] {
let mut s = Session::new();
s.prefs.edit(|p| p.file_handling.exr_aces_cg = on);
let v = match s.start_open("shot.exr", OpenSource::Bytes(std::sync::Arc::clone(&bytes))).unwrap() {
Started::Job(id) => s.wait_job(id).unwrap(),
Started::Done(v) => v,
};
// ACEScg isn't the working space, so Ask When Opening holds it for the prompt, which
// names the ACEScg profile; linear sRGB (io's HDR default) opens without asking.
let profile = match v.get("held").and_then(Value::as_u64) {
Some(held) => {
assert!(on, "{v}");
assert!(v["color"]["embedded"].as_str().unwrap().starts_with("ACEScg"), "{v}");
s.take_held_open(held).unwrap().0.icc_profile
}
None => {
assert!(v.get("document").is_some(), "{v}");
s.active().unwrap().doc.icc_profile.clone()
}
};
assert_eq!(profile.as_deref().map(|v| v.as_slice()), Some(want.as_slice()), "exr_aces_cg = {on}");
}
}
}
+1 -1
View File
@@ -1033,7 +1033,7 @@ fn reveal_in_finder_reveals_the_documents_saved_path() {
let path = "/tmp/pics/a.psd";
s.active_mut().unwrap().path = Some(path.into());
let r = s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap();
let (program, args) = crate::layer_menu_cmds::reveal_command(path);
let (program, args) = crate::layer_menu_cmds::reveal_command(path).unwrap();
assert_eq!(r["program"], json!(program));
assert_eq!(r["args"], json!(args));
// The tab menu names a background tab: the active document is not the one revealed.
-24
View File
@@ -334,30 +334,6 @@ pub(crate) fn read_window(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool
if cancelled() { None } else { Some(out) }
}
/// Which pixels of `r` a selection (or mask) covers at all: channel 0 above zero, read in
/// parallel bands as [`read_window`] does, without holding the coverage itself.
pub(crate) fn read_window_cover(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option<Vec<bool>> {
use rayon::prelude::*;
const BAND: usize = 64;
let n = surf.channels().max(1);
let (w, h) = (r.width() as usize, r.height() as usize);
let mut out = vec![false; w * h];
if out.is_empty() {
return Some(out);
}
out.par_chunks_mut(BAND * w).enumerate().for_each_init(Vec::new, |buf, (i, chunk)| {
if cancelled() {
return;
}
let y0 = r.y0 + (i * BAND) as i32;
surf.read_region_into(Rect::new(r.x0, y0, r.x1, y0 + (chunk.len() / w) as i32), buf);
for (o, px) in chunk.iter_mut().zip(buf.chunks_exact(n)) {
*o = px[0] > 0.0;
}
});
if cancelled() { None } else { Some(out) }
}
/// Channel 0 of [`read_window`] (a selection's or an alpha channel's coverage).
pub(crate) fn read_window_mask(surf: &Surface, r: Rect, cancelled: &(dyn Fn() -> bool + Sync)) -> Option<Vec<f32>> {
use rayon::prelude::*;
+4 -2
View File
@@ -466,13 +466,15 @@ fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>>
/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not
/// the map, so their re-render reads the map again here; without it the filter passed the pixels
/// through. `mapDocument` needs the session and is left unresolved.
/// through. The path is stored in the document, so it is followed only as
/// [`crate::doc_path::resolve`] allows. `mapDocument` needs the session and is left unresolved.
pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> {
if let FilterParams::Displace { map, .. } = fp
&& map.is_none()
&& let Some(path) = map_path(p)
{
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
let file = crate::doc_path::resolve(path).map_err(|why| crate::doc_path::refusal("the displacement map", path, why))?;
*map = map_from_path(&file.to_string_lossy(), p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
}
Ok(())
}
+24 -3
View File
@@ -323,6 +323,9 @@ pub fn run<T: Send + 'static>(
) -> Result<Value> {
let authorization = s.authorize.or_else(|| RENDER_AUTHORIZATION.with(std::cell::Cell::get));
let _authorization = AuthorizationScope::enter(authorization);
// The job's document: stored paths it follows are resolved against it on any thread.
let document = s.active().and_then(|d| d.path.as_deref()).map(std::path::PathBuf::from);
let _document = crate::doc_path::scope(document.as_deref());
if !s.jobs.spawn || cfg!(target_arch = "wasm32") {
let t = work(&s.jobs.inline.clone().unwrap_or_default())?;
return apply(s, t);
@@ -337,8 +340,10 @@ pub fn run<T: Send + 'static>(
};
let ctx = JobCtx::new();
let (tx, rx) = std::sync::mpsc::channel::<Result<Payload>>();
let apply_document = document.clone();
let erased: Apply = Box::new(move |s, any| {
let _authorization = AuthorizationScope::enter(authorization);
let _document = crate::doc_path::scope(apply_document.as_deref());
let t = any.downcast::<T>().map_err(|_| EngineError::Other("internal error: job result type mismatch".into()))?;
apply(s, *t)
});
@@ -352,6 +357,7 @@ pub fn run<T: Send + 'static>(
// no way to get it back, so the error is reported instead.
let spawned = std::thread::Builder::new().name(format!("photocraft-job-{}", id.0)).spawn(move || {
let _authorization = AuthorizationScope::enter(authorization);
let _document = crate::doc_path::scope(document.as_deref());
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(&wctx).map(|t| Box::new(t) as Payload)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{label_w}` failed with an internal error (logged); the document is unchanged"))));
// The receiver is gone when the job was cancelled: nothing to report.
@@ -503,7 +509,9 @@ impl Session {
warm_layer_bounds(&imported.document, || ctx.check())?;
Ok(imported)
},
move |s, r: photocraft_io::ImportResult| {
move |s, mut r: photocraft_io::ImportResult| {
// File Handling's ACEScg option, before the colour prompt looks at the profile.
crate::file_cmds::tag_exr_colorspace(s, &name_a, &mut r.document);
// Ask When Opening: held back until the prompt is answered (`take_held_open`). A
// developed raw has no embedded profile to ask about.
let raw = r.warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));
@@ -543,7 +551,7 @@ impl Session {
job.ctx.cancel();
Err(e)
}
(Err(e), None) => Err(e),
(Err(e), None) => Err(self.explain_missing_layer(e)),
(Ok(_), Some(mut job)) => {
job.command = command.to_string();
job.params = params;
@@ -694,6 +702,7 @@ impl Session {
gate(id, &params)?;
}
let _authorization = AuthorizationScope::enter(self.authorize);
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
// Last Filter runs as the filter it repeats: journaled, recorded in an action, faded and
// run as a job like that filter, so a recorded step replays the same filter anywhere
// (#3089), as Transform Again records its transform (#1440). With no filter yet it falls
@@ -740,7 +749,7 @@ impl Session {
job.ctx.cancel();
Err(e)
}
(Err(e), None) => Err(e),
(Err(e), None) => Err(self.explain_missing_layer(e)),
(Ok(_), Some(mut job)) => {
job.command = id.to_string();
job.params = params;
@@ -756,6 +765,18 @@ impl Session {
}
}
/// A command that failed on a layer id the document doesn't have says which ids it does have
/// (#5058): ids are not positions (an opened PNG's Background is not layer 1), and an agent
/// should not need `document.inspect` to recover.
fn explain_missing_layer(&self, e: EngineError) -> EngineError {
match (e, self.active()) {
(EngineError::NoLayer(id), Some(st)) if st.doc.layer(id).is_none() => {
EngineError::UnknownLayer { id, known: crate::commands::layer_listing(&st.doc) }
}
(e, _) => e,
}
}
/// Bookkeeping after a command (or a job's apply) succeeded.
///
/// `nested_from` is the journal length when the command started. A replayable command stands
+36 -10
View File
@@ -373,30 +373,56 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
let SmartSource::Linked { path } = &sm.source else {
return Err(other("embedded smart objects have no file to reveal"));
};
reveal(path, p.get("dryRun").and_then(Value::as_bool).unwrap_or(false))
// The path is stored in the document: shown only when it would be followed.
let file =
crate::doc_path::resolve_in(path, d.path.as_deref().map(std::path::Path::new)).map_err(|why| crate::doc_path::refusal("the linked file", path, why))?;
reveal(&file.to_string_lossy(), p.get("dryRun").and_then(Value::as_bool).unwrap_or(false))
}
/// Show `path` in the platform file manager ([`reveal_command`] + [`spawn`]); `dry_run` answers
/// the program and arguments instead (tests). Shared by the smart-object command and the document
/// tab's Reveal in Finder (UI-217-6).
pub(crate) fn reveal(path: &str, dry_run: bool) -> Result<Value> {
let (program, args) = reveal_command(path);
let (program, args) = reveal_command(path)?;
if dry_run {
return Ok(json!({"program": program, "args": args}));
}
ensure_folder(program, &args)?;
spawn(program, &args)?;
Ok(json!({"path": path}))
}
/// `xdg-open` opens whatever it is given in its default application, so it only gets a folder: a
/// linked path such as `tool.jar/a.png` would otherwise launch `tool.jar` instead of showing it.
fn ensure_folder(program: &str, args: &[String]) -> Result<()> {
if program != "xdg-open" {
return Ok(());
}
match args.first() {
Some(dir) if std::path::Path::new(dir).is_dir() => Ok(()),
Some(dir) => Err(other(format!("{dir} is not a folder, so it can't be shown in the file manager"))),
None => Err(other("no folder to show in the file manager")),
}
}
/// The file-manager invocation that selects `path` on this platform.
pub fn reveal_command(path: &str) -> (&'static str, Vec<String>) {
if cfg!(target_os = "macos") {
("open", vec!["-R".into(), path.into()])
} else if cfg!(target_os = "windows") {
("explorer", vec![format!("/select,{path}")])
} else {
let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned());
("xdg-open", vec![parent])
pub fn reveal_command(path: &str) -> Result<(&'static str, Vec<String>)> {
reveal_command_on(std::env::consts::OS, path)
}
/// [`reveal_command`] on the platform `os` (a `std::env::consts::OS` value).
fn reveal_command_on(os: &str, path: &str) -> Result<(&'static str, Vec<String>)> {
match os {
// `--` ends the options: a path starting with `-` is not read as one.
"macos" => Ok(("open", vec!["-R".into(), "--".into(), path.into()])),
// Explorer splits `/select,` arguments at commas and reads quotes itself, so such a path
// would select something else.
"windows" if path.contains([',', '"']) => Err(other(format!("{path} can't be shown in Explorer: its path contains a comma or a quotation mark"))),
"windows" => Ok(("explorer", vec![format!("/select,{path}")])),
_ => {
let parent = std::path::Path::new(path).parent().map_or_else(|| ".".to_string(), |p| p.to_string_lossy().into_owned());
Ok(("xdg-open", vec![parent]))
}
}
}
+53 -6
View File
@@ -465,16 +465,13 @@ fn stack_mode_rejects_sources_without_visible_layers() {
#[test]
fn reveal_in_finder_dry_run_and_enabled() {
let link = std::env::temp_dir().join("pics").join("a.png").to_string_lossy().into_owned();
let mut s = session(8, "rgb");
assert!(!s.is_enabled("layer.smartObjects.revealInFinder"));
s.edit("linked", |doc, active| {
let mut l = Layer::new(
"linked",
LayerContent::Smart(photocraft_doc::SmartObject::new(
SmartSource::Linked { path: "/tmp/pics/a.png".into() },
photocraft_geom::Affine::IDENTITY,
None,
)),
LayerContent::Smart(photocraft_doc::SmartObject::new(SmartSource::Linked { path: link.clone() }, photocraft_geom::Affine::IDENTITY, None)),
);
l.visible = true;
*active = Some(doc.insert_above(*active, l));
@@ -483,11 +480,61 @@ fn reveal_in_finder_dry_run_and_enabled() {
.unwrap();
assert!(s.is_enabled("layer.smartObjects.revealInFinder"));
let r = s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).unwrap();
let (prog, args) = reveal_command("/tmp/pics/a.png");
let (prog, args) = reveal_command(&link).unwrap();
assert_eq!(r["program"], json!(prog));
assert_eq!(r["args"], json!(args));
}
#[test]
fn reveal_follows_linked_paths_like_the_link_and_passes_paths_as_paths() {
let mut s = session(8, "rgb");
let folder = std::env::temp_dir().join("pics");
s.active_mut().unwrap().path = Some(folder.join("doc.pcraft").to_string_lossy().into_owned());
let mut link = |path: &str| {
s.edit("linked", |doc, active| {
let so = photocraft_doc::SmartObject::new(SmartSource::Linked { path: path.into() }, photocraft_geom::Affine::IDENTITY, None);
*active = Some(doc.insert_above(*active, Layer::new("linked", LayerContent::Smart(so))));
Ok(())
})
.unwrap();
s.execute("layer.smartObjects.revealInFinder", json!({"dryRun": true})).map_err(|e| e.to_string())
};
let err = link(r"\\192.0.2.1\share\x.png").unwrap_err();
assert!(err.contains("is not opened: the path is on another computer"), "{err}");
// A relative link is shown next to the document.
let r = link("a.png").unwrap();
assert_eq!(r["args"], json!(reveal_command(&folder.join("a.png").to_string_lossy()).unwrap().1));
// The document's own file is shown wherever it is, a network share included.
s.active_mut().unwrap().path = Some(r"\\192.0.2.1\share\doc.psd".into());
s.execute("file.revealInFinder", json!({"dryRun": true})).unwrap();
assert_eq!(reveal_command_on("macos", "-a.png").unwrap(), ("open", vec!["-R".to_string(), "--".into(), "-a.png".into()]));
assert_eq!(reveal_command_on("windows", r"C:\pics\a.png").unwrap(), ("explorer", vec![r"/select,C:\pics\a.png".to_string()]));
for path in [r"C:\pics\a,b.png", r#"C:\pics\a".png"#] {
assert!(reveal_command_on("windows", path).is_err(), "{path}");
}
}
/// `xdg-open` launches a file it is given, so Reveal hands it the link's folder only when that is a
/// folder: a link stored as `tool.jar/a.png` names a file, not a folder, as its parent.
#[test]
fn reveal_on_linux_never_hands_xdg_open_a_file() {
let dir = std::env::temp_dir().join(format!("photocraft-reveal-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("tool.jar");
std::fs::write(&file, b"not a folder").unwrap();
let (program, args) = reveal_command_on("linux", &file.join("a.png").to_string_lossy()).unwrap();
assert_eq!(args, vec![file.to_string_lossy().into_owned()]);
let err = ensure_folder(program, &args).unwrap_err();
assert!(err.to_string().contains("is not a folder"), "{err}");
let (program, args) = reveal_command_on("linux", &dir.join("a.png").to_string_lossy()).unwrap();
ensure_folder(program, &args).unwrap();
assert!(ensure_folder("xdg-open", &[]).is_err());
// Finder and Explorer select the file itself, whatever it is.
ensure_folder("open", &["-R".into(), "--".into(), file.to_string_lossy().into_owned()]).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn mask_all_objects_masks_the_subject() {
let mut s = session(8, "rgb");
+3
View File
@@ -147,6 +147,9 @@ pub fn raw_params(cmd: &str, p: &Value) -> Result<CameraRaw> {
cr.noise_luminance_detail = cr.noise_luminance_detail.clamp(0.0, 100.0);
cr.noise_color = cr.noise_color.clamp(0.0, 100.0);
cr.noise_color_detail = cr.noise_color_detail.clamp(0.0, 100.0);
// `exposure` becomes `2^exposure`, which overflows f32 to infinity from 128 up (and then
// writes inf/NaN into 32-bit layers). Clamp to the documented -5..5.
cr.exposure = cr.exposure.clamp(-5.0, 5.0);
Ok(cr)
}
+11 -1
View File
@@ -1,4 +1,4 @@
//! The Photocraft engine façade: open documents, history, and the command registry.
//! The PhotoCraft engine façade: open documents, history, and the command registry.
//!
//! Every user-visible action is a command with a stable id (Photoshop-style, such as
//! `layer.newAdjustmentLayer.invert`) and JSON parameters. Every frontend goes through the same
@@ -34,6 +34,7 @@ mod crop_fill;
pub mod cutout_cmds;
pub mod display_color;
pub mod distort_cmds;
pub mod doc_path;
pub mod document_preset_cmds;
pub mod edit_cmds;
pub mod edit_menu_cmds;
@@ -41,6 +42,7 @@ pub mod eraser_cmds;
pub mod export_preset_cmds;
pub mod exr_cmds;
pub mod extra_cmds;
pub mod face_cmds;
pub mod file_cmds;
pub mod fill_cmds;
pub mod fill_key_cmds;
@@ -155,6 +157,10 @@ pub enum EngineError {
NoDocument,
#[error("no such layer {0:?}")]
NoLayer(LayerId),
/// [`Self::NoLayer`] as a command reports it: with the layers the document does have, as
/// ids are not positions (#5058).
#[error("no such layer {}; {known}", id.0)]
UnknownLayer { id: LayerId, known: String },
#[error("{0}")]
Other(String),
/// A background job was cancelled (see [`jobs`]); nothing changed.
@@ -480,6 +486,9 @@ pub struct Session {
/// the same check a top-level request sees. `None` runs everything, which is what a local
/// UI and `photocraft-cli run` do.
pub authorize: Option<fn(&str, &serde_json::Value) -> Result<()>>,
/// A further route for File › Print when neither `lp` nor a CUPS server over IPP is
/// reachable (the XDG Print portal in a Flatpak); see `print_cmds::Spooler`.
pub print_spooler: Option<print_cmds::Spooler>,
/// Background jobs (see [`jobs`]).
jobs: jobs::Jobs,
/// Color Sampler points dropped by the last canvas-size change (crop, Canvas Size, Image Size,
@@ -782,6 +791,7 @@ impl Session {
// Every mutation path (linked-file refresh, transform, place) re-renders smart objects
// through this entry; the requesting session's authority must reach those renders.
let _authorization = crate::jobs::render_authorization_scope(self.authorize);
let _document = crate::doc_path::scope(self.active().and_then(|d| d.path.as_deref()).map(std::path::Path::new));
let restrict = self.color_restrict;
let sampler_map = self.sampler_map.take();
let st = self.active_mut().ok_or(EngineError::NoDocument)?;
+83 -9
View File
@@ -16,6 +16,9 @@
//! keeps waiting for Update Modified Content, as in Photoshop); one that can't be read (another app
//! is still writing it) is tried again once it changes again, not on every look. Only smart objects
//! of the document itself follow their files, not ones nested in another smart object's contents.
//! While a background job runs on the document, its files aren't looked at: an update would replace
//! the snapshot the job works on and its result would be thrown away. The first look after the job
//! ends catches up.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, Mutex, PoisonError};
@@ -32,15 +35,17 @@ pub type Stamp = (u64, u128);
/// ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
/// Stamps `file`, a linked path resolved by [`crate::doc_path`]; `None` (not looked at, like a
/// missing file) when it is not followed.
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
fn stamp(file: Option<&std::path::Path>) -> Option<Stamp> {
let meta = std::fs::metadata(file?).ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
fn stamp(_: Option<&std::path::Path>) -> Option<Stamp> {
None
}
@@ -61,11 +66,24 @@ pub fn linked_files(d: &Document) -> Vec<(String, Vec<u64>)> {
/// the layers of the ones that changed.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let (doc, files) = (st.doc.id.0, linked_files(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let (doc, files) = (st.doc.id.0, resolved_files(st));
let now = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
s.apply_link_stamps(doc, now)
}
/// [`linked_files`] of `st`'s document with the file each path resolves to, or `None` for a path
/// that is not followed ([`crate::doc_path`]).
fn resolved_files(st: &crate::DocState) -> Vec<(String, Option<std::path::PathBuf>, Vec<u64>)> {
let document = st.path.as_deref().map(std::path::Path::new);
linked_files(&st.doc)
.into_iter()
.map(|(p, ids)| {
let file = crate::doc_path::resolve_in(&p, document).ok();
(p, file, ids)
})
.collect()
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
@@ -74,14 +92,15 @@ impl Session {
return;
}
let Some(st) = self.active() else { return };
let (doc, files) = (st.doc.id.0, linked_files(&st.doc));
if files.is_empty() {
let (doc, files) = (st.doc.id.0, resolved_files(st));
// Nothing to look at: no links, or none that is followed.
if files.iter().all(|(_, file, _)| file.is_none()) {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
let stamps = files.into_iter().map(|(p, file, ids)| (p, ids, stamp(file.as_deref()))).collect();
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((doc, stamps));
});
if spawned.is_ok() {
@@ -103,6 +122,12 @@ impl Session {
if self.active().map(|d| d.doc.id.0) != Some(doc) {
return Ok(json!({"updated": []}));
}
// A background job holds the document: refreshing now would replace the snapshot it works
// on and its result would be discarded. The stamps stay as they were, so the change is
// seen again by the first look after the job ends.
if self.active_job().is_some() {
return Ok(json!({"updated": []}));
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.doc.id.0).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
@@ -155,7 +180,7 @@ mod tests {
use photocraft_geom::Rect;
use serde_json::json;
use crate::Session;
use crate::{EngineError, Session};
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-link-watch-{name}-{}", std::process::id()));
@@ -280,4 +305,53 @@ mod tests {
assert!(is(centre(&s), BLUE));
assert!(s.link_scan.is_none());
}
#[test]
fn a_refresh_waits_for_a_running_job_on_the_document() {
let d = dir("job");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, id) = placed(&path);
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
let (release, job) = gated_job(&mut s);
save(&path, &png(10, 10, "#0000ff"));
let snapshot = s.active().unwrap().doc.clone();
// The path the desktop app's timer takes: it doesn't go through the command conflict check.
assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": []}));
assert!(std::sync::Arc::ptr_eq(&s.active().unwrap().doc, &snapshot), "the job's snapshot must stay in place");
release.send(()).unwrap();
s.wait_job(job).unwrap();
// The job finished; the change that came meanwhile is picked up exactly once.
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": [id]}));
assert!(is(centre(&s), BLUE));
assert_eq!(s.execute("layer.smartObjects.updateChanged", json!({})).unwrap(), json!({"updated": []}));
}
#[test]
fn a_job_on_another_document_does_not_hold_up_a_refresh() {
let d = dir("job-other");
let path = d.join("art.png");
std::fs::write(&path, png(10, 10, "#ff0000")).unwrap();
let (mut s, id) = placed(&path);
s.execute("layer.smartObjects.updateChanged", json!({})).unwrap();
s.execute("file.new", json!({"width": 10, "height": 10})).unwrap();
let (release, job) = gated_job(&mut s);
s.set_active(0);
save(&path, &png(10, 10, "#0000ff"));
assert_eq!(super::update_changed(&mut s, &json!({})).unwrap(), json!({"updated": [id]}));
assert!(is(centre(&s), BLUE));
release.send(()).unwrap();
s.wait_job(job).unwrap();
}
/// A background job that locks the active document until something is sent on the returned
/// sender.
fn gated_job(s: &mut Session) -> (std::sync::mpsc::Sender<()>, crate::jobs::JobId) {
let (release, gate) = std::sync::mpsc::channel::<()>();
let r = s.start_job("test.gated", json!({}), "Gated job", true, move |_| gate.recv().map_err(|_| EngineError::Cancelled), |_, ()| Ok(json!(null)));
match r.unwrap() {
crate::jobs::Started::Job(j) => (release, j),
other => panic!("expected a background job, got {other:?}"),
}
}
}
+4
View File
@@ -444,6 +444,10 @@ fn parse_inks(cmd: &str, v: &Value, n: usize) -> Result<Vec<DuotoneInk>> {
if let Some(pts) = ink.get("curve").and_then(Value::as_array) {
let mut curve: Vec<CurvePoint> =
pts.iter().filter_map(|p| Some(CurvePoint { input: p.get(0)?.as_f64()? as f32 / 100.0, output: p.get(1)?.as_f64()? as f32 / 100.0 })).collect();
// 1e39 is a valid JSON number but inf as f32, and a .pcraft with an inf curve can't be saved.
if curve.iter().any(|p| !p.input.is_finite() || !p.output.is_finite()) {
return Err(bad(cmd, "curve points must be finite numbers 0..100"));
}
curve.sort_by(|a, b| a.input.total_cmp(&b.input));
if curve.len() >= 2 {
d.curve = curve;
+14
View File
@@ -353,6 +353,20 @@ fn duotone_inks_and_display() {
assert!(!session(4, 4, 8, "rgb").is_enabled("image.mode.duotone"));
}
#[test]
fn duotone_curve_points_must_be_finite() {
let mut s = session(4, 4, 8, "gray");
// 1e39 overflows f32 to inf, which the .pcraft writer refuses to save.
for curve in [json!([[0, 0], [100, 1e39]]), json!([[0, 0], [1e39, 50]]), json!([[-1e39, 0], [100, 100]])] {
let r = s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": curve}]}));
assert!(r.is_err(), "{curve} accepted");
assert_eq!(doc(&s).mode, ColorMode::Grayscale);
}
s.execute("image.mode.duotone", json!({"type": "monotone", "inks": [{"curve": [[0, 0], [50, 70], [100, 100]]}]})).unwrap();
assert_eq!(doc(&s).mode, ColorMode::Duotone);
photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap();
}
#[test]
fn modes_round_trip_through_pcraft() {
let mut s = session(12, 12, 8, "rgb");
+16
View File
@@ -106,6 +106,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
n.position = [nx, ny];
// The popup travels with its icon.
n.popup = [n.popup[0] + dx, n.popup[1] + dy, n.popup[2] + dx, n.popup[3] + dy];
if !n.popup.iter().all(|v| v.is_finite()) {
return Err(bad(CMD, "the move is too far: the note popup would leave the representable range"));
}
}
if n == d.notes[i] {
return Ok(json!({"index": i, "note": note_json(i, &n), "changed": false}));
@@ -279,6 +282,19 @@ mod tests {
assert!(s.execute("notes.set", json!({"index": 0})).is_err());
}
#[test]
fn huge_move_is_rejected_and_document_stays_saveable() {
let mut s = session();
s.execute("notes.add", json!({"x": 1.7e308, "y": 0})).unwrap();
let before = s.active().unwrap().doc.notes[0].clone();
let past = s.active().unwrap().history.past_len();
assert!(s.execute("notes.set", json!({"index": 0, "x": -1.7e308})).is_err());
let a = s.active().unwrap();
assert_eq!(a.doc.notes[0], before);
assert_eq!(a.history.past_len(), past);
assert!(photocraft_format::save_to_bytes(&a.doc, &Default::default()).is_ok());
}
#[test]
fn bad_params() {
let mut s = session();
+22 -3
View File
@@ -168,10 +168,29 @@ impl PresetState {
}
impl Session {
/// Restore presets saved by [`PresetState::to_json`]. Missing sets keep their defaults; a
/// malformed blob is ignored (presets are never worth refusing to start over).
/// Restore presets saved by [`PresetState::to_json`]. Missing or malformed sets keep their
/// defaults (presets are never worth refusing to start over). Each set loads on its own: the
/// shell writes back only the keys that changed, so one damaged set would otherwise discard
/// every other set, saved document presets included, at each launch (#5602).
pub fn load_presets_json(&mut self, v: Value) {
let Ok(p) = serde_json::from_value::<Persisted>(v) else { return };
let Value::Object(mut m) = v else { return };
fn take<T: serde::de::DeserializeOwned>(m: &mut serde_json::Map<String, Value>, key: &str) -> Option<T> {
m.remove(key).filter(|v| !v.is_null()).and_then(|v| serde_json::from_value(v).ok())
}
let p = Persisted {
gradients: take(&mut m, "gradients"),
gradient: take(&mut m, "gradient"),
styles: take(&mut m, "styles"),
shapes: take(&mut m, "shapes"),
pattern_groups: take(&mut m, "pattern_groups"),
tool_presets: take(&mut m, "tool_presets"),
documents: take(&mut m, "documents"),
recent_documents: take(&mut m, "recent_documents"),
export_presets: take(&mut m, "export_presets"),
custom_shapes: take(&mut m, "custom_shapes"),
layer_defaults: take(&mut m, "layer_defaults"),
swatches: take(&mut m, "swatches"),
};
let st = &mut self.presets;
if let Some(g) = p.gradients {
st.gradients = g;
+26
View File
@@ -748,3 +748,29 @@ fn clone_source_errors_and_overlay() {
assert_eq!(v["width"], json!(1000.0));
assert_eq!(v["rotation"], json!(-90.0));
}
// ------------------------------------------------------------------ persistence
/// #5602: one damaged preset set in the preferences file must not discard the saved New
/// Document presets (or any other set) at the next launch.
#[test]
fn a_damaged_preset_set_keeps_the_other_sets() {
let mut s = Session::new();
let settings = json!({"width": 1600, "height": 900, "resolution": 144});
s.execute("document.presets.save", json!({"name": "Banner", "settings": settings})).unwrap();
let saved = s.presets.to_json(&s);
for key in ["gradients", "gradient", "styles", "shapes", "pattern_groups", "custom_shapes", "layer_defaults", "swatches"] {
let mut blob = saved.clone();
blob[key] = json!("not a preset set");
let mut next = Session::new();
next.load_presets_json(blob);
assert_eq!(next.presets.documents, s.presets.documents, "damaged `{key}`");
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len(), "damaged `{key}`");
}
// Null sets keep their defaults, as before.
let mut next = Session::new();
next.load_presets_json(json!({"tool_presets": null, "gradients": null, "documents": saved["documents"].clone()}));
assert_eq!(next.presets.tool_presets.len(), Session::new().presets.tool_presets.len());
assert_eq!(next.presets.gradients.len(), Session::new().presets.gradients.len());
assert_eq!(next.presets.documents, s.presets.documents);
}
+232 -19
View File
@@ -8,6 +8,12 @@
//! `"dryRun": true` reports the backend command without spooling; a PDF is kept only at an
//! explicit output path. Shared spooler transport files are private and automatically removed.
//!
//! Where `lp` is not on the path (a Flatpak has none), the job goes to CUPS over IPP in-process
//! (`photocraft-ipp`, through the sandbox's `--socket=cups`), and when no print server or no
//! printer is reachable, to the app's spooler, the XDG Print portal (`Session::print_spooler`;
//! `PHOTOCRAFT_PRINT_PORTAL=1` prefers it, `=0` removes it). `"printServer"` names a server for
//! one job and always takes the IPP route; `file.printers` lists the queues (#2728).
//!
//! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible
//! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U`
//! compound paths), following the public PostScript Language Reference and the AI3 format notes.
@@ -31,6 +37,191 @@ fn other(e: impl std::fmt::Display) -> EngineError {
EngineError::Other(e.to_string())
}
// ---------- Spooling ----------
/// A rendered page on its way to the printer: what the IPP route and the app's spooler need.
#[derive(Debug, Clone)]
pub struct PrintJob {
pub pdf: Vec<u8>,
/// The printer asked for; `None` means the default.
pub printer: Option<String>,
pub copies: u32,
/// The queue entry's name: the document's.
pub title: String,
/// A print server named in the request (`printServer`), else the system's.
pub server: Option<String>,
/// The page size in points, as laid out (landscape already applied).
pub paper: (f64, f64),
pub landscape: bool,
}
/// A spooler's work: the job in, where it went out.
pub type SpoolFn = std::sync::Arc<dyn Fn(&PrintJob) -> std::result::Result<String, String> + Send + Sync>;
/// A further route to the printer, installed by the app on `Session::print_spooler`: the XDG
/// Print portal, which shows the system print dialog from inside a sandbox. It takes the jobs
/// no other route can (no print server reachable, no printer set up), or every job when
/// `preferred`.
#[derive(Clone)]
pub struct Spooler {
/// Reported as the job's `backend`.
pub name: &'static str,
pub preferred: bool,
/// Returns where the job went ("the system print dialog").
pub run: SpoolFn,
}
/// Why the IPP route could not spool: no route at all (the app's spooler may have one), or a
/// failure on a route that exists (a wrong printer name, a rejected job).
enum SpoolError {
NoRoute(String),
Failed(String),
}
/// Sends the job and says where it went and through what. A named print server takes the IPP
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
/// error, never a fall-through.
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
if job.server.is_none() {
if let Some(sp) = spooler.filter(|sp| sp.preferred) {
return app(sp);
}
if let Some(name) = system_spooler() {
return craft_print::submit(&job.pdf, lp).map(|m| (m, name.to_string())).map_err(other);
}
}
match cups_spool(job) {
Ok(m) => Ok((m, "ipp".into())),
Err(SpoolError::NoRoute(why)) => match spooler {
Some(sp) => app(sp),
None => Err(other(why)),
},
Err(SpoolError::Failed(why)) => Err(other(why)),
}
}
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
/// browser.
fn system_spooler() -> Option<&'static str> {
match craft_print::backend()? {
craft_print::Backend::WindowsPdf => Some("windows"),
craft_print::Backend::Cups => {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).any(|dir| dir.join("lp").is_file()).then_some("lp")
}
}
}
/// The printer a job goes to: the one asked for when the server knows it, else the default,
/// else the only one; otherwise an error that names what there is.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn resolve_printer(requested: Option<&str>, default: Option<&str>, printers: &[photocraft_ipp::Printer]) -> std::result::Result<String, SpoolError> {
let names: Vec<&str> = printers.iter().map(|p| p.name.as_str()).collect();
let known = |n: &str| names.iter().find(|k| k.eq_ignore_ascii_case(n)).map(|k| (*k).to_string());
if let Some(r) = requested {
let choices = if names.is_empty() { "none".to_string() } else { names.join(", ") };
return known(r).ok_or_else(|| SpoolError::Failed(format!("no printer called `{r}`; the print server knows: {choices}")));
}
if printers.is_empty() {
return Err(SpoolError::NoRoute("no printer is set up on this computer; add one in the system settings, or print to PDF".into()));
}
if let Some(d) = default.and_then(known) {
return Ok(d);
}
match names.as_slice() {
[only] => Ok((*only).to_string()),
_ => Err(SpoolError::Failed(format!("no default printer; choose one in the Printer field: {}", names.join(", ")))),
}
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn cups_server(named: Option<&str>) -> std::result::Result<photocraft_ipp::Endpoint, SpoolError> {
match named {
Some(server) => {
let ep = photocraft_ipp::Endpoint::parse(server);
if !ep.reachable() {
return Err(SpoolError::Failed(format!("no print server at {ep}")));
}
Ok(ep)
}
None => photocraft_ipp::Endpoint::detect()
.ok_or_else(|| SpoolError::NoRoute("no print server: CUPS is not running, or not reachable from this sandbox; print to PDF instead".into())),
}
}
/// The IPP route: the job to the system's (or the named) CUPS server.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn cups_spool(job: &PrintJob) -> std::result::Result<String, SpoolError> {
let failed = |e: photocraft_ipp::Error| SpoolError::Failed(e.to_string());
let ep = cups_server(job.server.as_deref())?;
let printers = photocraft_ipp::printers(&ep).map_err(failed)?;
let default = printers.iter().find(|p| p.is_default).map(|p| p.name.clone());
let printer = resolve_printer(job.printer.as_deref(), default.as_deref(), &printers)?;
let is_class = printers.iter().any(|p| p.name == printer && p.is_class);
let user = std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_else(|_| "photocraft".into());
let id = photocraft_ipp::print_job(
&ep,
&photocraft_ipp::Job { printer: &printer, is_class, title: &job.title, user: &user, copies: job.copies, document: &job.pdf, format: "application/pdf" },
)
.map_err(failed)?;
Ok(format!("job {id} on {printer}"))
}
/// No IPP route here: a named server is a failure, the system's a missing route (the app's
/// spooler may have one).
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
fn cups_spool(job: &PrintJob) -> std::result::Result<String, SpoolError> {
let why = if cfg!(target_arch = "wasm32") {
"printing needs the desktop app"
} else {
"a print server over IPP is not available on Windows yet; print to PDF instead"
};
match job.server {
Some(_) => Err(SpoolError::Failed(why.into())),
None => Err(SpoolError::NoRoute(why.into())),
}
}
/// `file.printers`: the printers the system's (or `printServer`'s) print server knows, with the default. From
/// `craft-print` (`lpstat`, the Windows spooler) where that is the route; over IPP for a named
/// server and where there is no `lp` (a sandbox).
fn system_printers(_s: &mut Session, p: &Value) -> Result<Value> {
let named = p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty());
if named.is_none() && system_spooler().is_some() {
let list = craft_print::printers();
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
return Ok(json!({"server": Value::Null, "default": default, "printers": printers}));
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
{
let ep = match cups_server(named) {
Ok(ep) => ep,
Err(SpoolError::NoRoute(why) | SpoolError::Failed(why)) => {
return Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": why}));
}
};
let list = photocraft_ipp::printers(&ep).map_err(other)?;
let default = list.iter().find(|p| p.is_default).map(|p| p.name.clone());
let printers: Vec<Value> = list
.iter()
.map(|p| {
json!({"name": p.name, "info": p.info, "state": match p.state { 4 => "printing", 5 => "stopped", _ => "idle" }, "acceptingJobs": p.accepting_jobs, "default": p.is_default, "class": p.is_class})
})
.collect();
Ok(json!({"server": ep.to_string(), "default": default, "printers": printers}))
}
#[cfg(not(all(not(target_arch = "wasm32"), not(target_os = "windows"))))]
{
let _ = named;
Ok(json!({"server": Value::Null, "default": Value::Null, "printers": [], "note": "listing printers is not available on this platform yet"}))
}
}
// ---------- PDF ----------
/// Paper sizes in points (portrait).
@@ -343,6 +534,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let params = p.clone();
let worker_params = params.clone();
let command = cmd.to_string();
let spooler = s.print_spooler.clone();
if send && !dry {
// Session::execute remains synchronous; the desktop's Session::start snapshots here
// and runs PDF composition and the potentially slow driver on a worker.
@@ -352,7 +544,7 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
false,
move |ctx| {
ctx.progress(0.0, "Print");
render_and_spool(&doc, &worker_params, &command)
render_and_spool(&doc, &worker_params, &command, spooler.as_ref())
},
move |s, result| {
remember_print(s, &params);
@@ -370,13 +562,13 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
},
);
}
let result = render_and_spool(&doc, p, cmd)?;
let result = render_and_spool(&doc, p, cmd, spooler.as_ref())?;
remember_print(s, p);
crate::automate_cmds::fire_event(s, "print");
Ok(result)
}
fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
fn render_and_spool(doc: &Document, p: &Value, cmd: &str, spooler: Option<&Spooler>) -> Result<Value> {
let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?;
let (img, color) = print_image(doc, p, cmd)?;
let description = p
@@ -394,7 +586,12 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
// Sending follows the shared command contract; fuzz tests explicitly request dryRun.
// The worker returns its result before repeat settings and hooks are applied once.
let handling = p.get("colorHandling").and_then(Value::as_str).unwrap_or("printerManages");
if cfg!(windows) && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) {
// A named print server always takes the IPP route and a preferred app spooler takes every
// job (the route order in `spool_job`), on Windows too: neither may enter the native path
// below, which would print on the local default printer instead.
let named_server = p.get("printServer").and_then(Value::as_str).is_some_and(|v| !v.is_empty());
let skip_native = named_server || spooler.is_some_and(|sp| sp.preferred);
if cfg!(windows) && !skip_native && send && !dry && output.is_none() && native_rgb_compatible(&page, handling) {
require_printing_enabled()?;
let printed = print_windows(&page, p, &doc.name, copies)?;
return Ok(json!({
@@ -402,10 +599,11 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
"copies": copies, "command": Value::Null, "sent": true, "spooler": printed, "color": color,
}));
}
if cfg!(windows) && send && !dry && output.is_none() && printer_settings(p)?.is_some() {
if cfg!(windows) && !skip_native && send && !dry && output.is_none() && printer_settings(p)?.is_some() {
return Err(other("the selected color profile or page text requires PDF printing. Save as PDF, or clear Print Settings to use PDF printing."));
}
let pdf = print_pdf(&page);
let pdf_len = pdf.len();
let job = craft_print::Job {
printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string),
copies,
@@ -425,13 +623,25 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result<Value> {
};
let mut sent = false;
let mut spool = Value::Null;
let mut backend = Value::Null;
if send && !dry {
require_printing_enabled()?;
spool = json!(craft_print::submit(&pdf, &job).map_err(other)?);
let print_job = PrintJob {
printer: job.printer.clone(),
copies,
title: doc.name.clone(),
server: p.get("printServer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string),
paper: (pw, ph),
landscape: p.get("orientation").and_then(Value::as_str) == Some("landscape"),
pdf,
};
let (where_, via) = spool_job(spooler, &print_job, &job)?;
spool = json!(where_);
backend = json!(via);
sent = true;
}
Ok(
json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}),
json!({"pdf": pdf_path, "bytes": pdf_len, "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "backend": backend, "color": color}),
)
}
@@ -573,15 +783,6 @@ fn require_printing_enabled() -> Result<()> {
}
}
/// The printers the system spooler knows (Print's Printer menu): CUPS queues and driverless
/// destinations on macOS and Linux, `Win32_Printer` on Windows, none in the browser.
fn system_printers(_: &mut Session, _: &Value) -> Result<Value> {
let list = craft_print::printers();
let default = list.iter().find(|p| p.default).map(|p| p.name.clone());
let printers: Vec<Value> = list.into_iter().map(|p| json!({"name": p.name, "default": p.default})).collect();
Ok(json!({"printers": printers, "default": default}))
}
fn print(s: &mut Session, p: &Value) -> Result<Value> {
do_print(s, p, "file.print")
}
@@ -602,8 +803,15 @@ fn print_one_copy(s: &mut Session, p: &Value) -> Result<Value> {
fn package(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.package";
let dir = p.get("dir").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"dir\" (where to create the package folder)"))?;
// The copies are relinked by absolute path: a relative one would be resolved against the
// package document's folder, not the working directory `dir` is relative to.
// `folder`, `document` and `links` are built on it with `join`, so it is spelt the same way:
// `/` throughout, not a native prefix with `/` after it.
let dir = std::path::absolute(dir).map_or_else(|_| dir.to_string(), |d| d.to_string_lossy().into_owned());
let dir = crate::file_cmds::forward_slashes(dir);
let d = s.active().ok_or(EngineError::NoDocument)?;
let doc = (*d.doc).clone();
let document = d.path.clone();
let name = stem(&d.path.clone().unwrap_or_else(|| doc.name.clone()));
// The native format keeps external links; our PSD writer has no `lnkE` (external linked
// file) support yet, so a PSD package would embed or flatten them.
@@ -612,7 +820,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
Some(f) => return Err(bad(cmd, format!("unknown package format `{f}` (pcraft|psd|psb)"))),
None => "pcraft".into(),
};
let folder = join(dir, &crate::file_cmds::sanitize(&name));
let folder = join(&dir, &crate::file_cmds::sanitize(&name));
let links_dir = join(&folder, "Links");
// Copy every linked file once; relink the copy of the document to the copies.
let mut copied: Vec<(String, String)> = Vec::new();
@@ -629,7 +837,12 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
fname = format!("{}-{}", copied.len(), fname);
}
let t = join(&links_dir, &fname);
match read_file(path) {
// A path that is not followed is packaged as missing, like a file that is gone.
let Ok(file) = crate::doc_path::resolve_in(path, document.as_deref().map(std::path::Path::new)) else {
missing.push(path.clone());
continue;
};
match read_file(&file.to_string_lossy()) {
Ok(bytes) => {
write_file(&t, &bytes)?;
copied.push((path.clone(), t.clone()));
@@ -750,7 +963,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
};
}
const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent}"##;
const PRINT_PARAMS: &str = r##"{"printerSettings":hex? (Windows driver settings),"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"printServer":"host[:port]|socket path"? (default: the system's CUPS; a named server is always printed to over IPP),"dryRun":bool=false (render the PDF, report the backend command, don't spool; write a PDF only at an explicit output path)} → {pdf:path|null (null without explicit output), imageRect, command:diagnostic array|null, sent, spooler:where the job went|null, backend:"lp|windows|ipp|portal"|null}"##;
vec![
spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print),
+298 -2
View File
@@ -5,7 +5,9 @@ fn tmp(name: &str) -> String {
let d = std::env::temp_dir().join(format!("photocraft-print-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.to_string_lossy().into_owned()
// Spelt the way command results spell paths, so an expectation built with `/` matches on
// Windows too. Win32 takes `/` as a separator, so the directory is still usable as one.
d.to_string_lossy().replace('\\', "/")
}
fn session(mode: &str, depth: u32) -> Session {
@@ -254,7 +256,12 @@ fn package_copies_links_and_relinks() {
let copied = format!("{dir}/pkg/Print Me/Links/art.png");
assert_eq!(r["links"], json!([copied]));
assert!(std::path::Path::new(&copied).is_file());
// Reported paths are `/`-spelt on every platform, not the native separator up to `dir` and
// `/` after it: a client hands them back to the control channel, which takes only `/`.
let docp = r["document"].as_str().unwrap();
for p in [r["folder"].as_str().unwrap(), docp, r["links"][0].as_str().unwrap()] {
assert!(!p.contains('\\'), "{p}");
}
assert!(docp.ends_with("Print Me/Print Me.pcraft"));
let back = photocraft_io::import("Print Me.pcraft", &std::fs::read(docp).unwrap()).unwrap().document;
let linked: Vec<String> = back
@@ -360,7 +367,7 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc;
assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document");
assert_eq!(out["sent"], true);
assert_eq!(out["spooler"], "request id is Office-42");
assert_eq!((out["spooler"].as_str(), out["backend"].as_str()), (Some("request id is Office-42"), Some("lp")));
assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF");
let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF-1.4"));
@@ -434,3 +441,292 @@ fn printers_lists_the_spooler_destinations_without_a_document() {
None => assert!(list.iter().all(|p| p["default"] == false)),
}
}
/// Spooling tests run in a child process: `disable_printing` is process-wide and a test above
/// sets it, and CI sets `PHOTOCRAFT_NO_PRINT` for the whole shell. True in the child, which runs
/// the test body; the parent starts the child and checks that it passed.
fn in_child(test: &str, envs: &[(&str, &str)]) -> bool {
const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_CHILD";
if std::env::var_os(CHILD).is_some() {
return true;
}
let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
cmd.args(["--exact", test, "--nocapture"]).env(CHILD, "1").env_remove("PHOTOCRAFT_NO_PRINT");
for (k, v) in envs {
cmd.env(k, v);
}
// Bounded: a child stuck in a printer dialog or a driver must not hang the suite.
let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break Some(status);
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
break None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
let out = child.wait_with_output().unwrap();
assert!(
status.is_some_and(|s| s.success()),
"{}\nstdout: {}\nstderr: {}",
if status.is_none() { "the child did not finish within 120 s and was killed" } else { "the child failed" },
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
false
}
/// A spooler that keeps every job it gets.
fn recording_spooler(preferred: bool, seen: &std::sync::Arc<std::sync::Mutex<Vec<PrintJob>>>) -> Spooler {
let seen = std::sync::Arc::clone(seen);
Spooler {
name: "test",
preferred,
run: std::sync::Arc::new(move |job: &PrintJob| {
seen.lock().unwrap().push(job.clone());
Ok("queued".into())
}),
}
}
#[test]
fn a_preferred_app_spooler_takes_the_job() {
if !in_child("print_cmds::tests::a_preferred_app_spooler_takes_the_job", &[]) {
return;
}
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut s = session("rgb", 8);
s.print_spooler = Some(recording_spooler(true, &seen));
let r = s.execute("file.print", json!({"copies": 2, "paper": "a4", "orientation": "landscape"})).unwrap();
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("test"), Some("queued")));
assert!(r["pdf"].is_null(), "{r}");
let jobs = seen.lock().unwrap();
let job = &jobs[0];
assert!(job.pdf.starts_with(b"%PDF-1.4"));
assert_eq!((job.copies, job.title.as_str(), job.printer.as_deref(), job.landscape), (2, "Print Me.psd", None, true));
assert!((job.paper.0 - 841.89).abs() < 0.01 && (job.paper.1 - 595.28).abs() < 0.01);
drop(jobs);
// A named print server asks for the IPP route, preferred spooler or not.
let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
assert!(e.contains("no print server at 127.0.0.1:1") || e.contains("not available on Windows"), "{e}");
assert_eq!(seen.lock().unwrap().len(), 1);
// "output" keeps the file and does not spool unless asked.
let out = format!("{}/out.pdf", tmp("output"));
let r = s.execute("file.print", json!({"output": out})).unwrap();
assert_eq!((r["sent"].as_bool(), r["pdf"].as_str()), (Some(false), Some(out.as_str())));
assert!(std::path::Path::new(&out).exists());
}
#[cfg(not(target_os = "windows"))]
mod cups {
use super::*;
use photocraft_ipp::{
Attribute, OP_CUPS_GET_CLASSES, OP_CUPS_GET_DEFAULT, OP_CUPS_GET_PRINTERS, OP_PRINT_JOB, Printer, Request, TAG_JOB_GROUP, TAG_NAME, TAG_PRINTER_GROUP,
};
use std::io::{BufRead, BufReader, Read, Write};
use std::sync::mpsc;
fn printer(name: &str, is_default: bool) -> Printer {
Printer { name: name.into(), info: String::new(), state: 3, accepting_jobs: true, is_default, is_class: false }
}
#[test]
fn the_printer_is_the_requested_one_else_the_default_else_the_only_one() {
let two = [printer("Office", false), printer("Lab", true)];
assert_eq!(resolve_printer(None, Some("Lab"), &two).ok(), Some("Lab".into()));
assert_eq!(resolve_printer(Some("office"), Some("Lab"), &two).ok(), Some("Office".into()));
assert_eq!(resolve_printer(None, None, &two[..1]).ok(), Some("Office".into()));
match resolve_printer(None, None, &two) {
Err(SpoolError::Failed(e)) => assert!(e.contains("no default printer") && e.contains("Office, Lab"), "{e}"),
_ => panic!("several printers and no default must fail"),
}
match resolve_printer(Some("Nope"), Some("Lab"), &two) {
Err(SpoolError::Failed(e)) => assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}"),
_ => panic!("an unknown printer must fail"),
}
match resolve_printer(None, None, &[]) {
Err(SpoolError::NoRoute(e)) => assert!(e.contains("no printer is set up"), "{e}"),
_ => panic!("no printers is no route"),
}
// A printer asked for by name on a server without any is a failure, not a missing route.
match resolve_printer(Some("Office"), None, &[]) {
Err(SpoolError::Failed(e)) => assert!(e.contains("`Office`") && e.contains("none"), "{e}"),
_ => panic!("a named printer never falls through"),
}
// A stale default that the server no longer lists falls through to the only printer.
assert_eq!(resolve_printer(None, Some("Gone"), &two[..1]).ok(), Some("Office".into()));
}
/// What the fake received per request: op, attributes, document.
type Seen = (u16, Vec<Attribute>, Vec<u8>);
/// A fake CUPS on a TCP port: lists `printers` and `classes`, names `default`, accepts jobs
/// as id 42. Answers for as long as the test process lives.
fn fake_cups(printers: &'static [&'static str], classes: &'static [&'static str], default: Option<&'static str>) -> (String, mpsc::Receiver<Seen>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let server = format!("127.0.0.1:{}", listener.local_addr().unwrap().port());
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { break };
let mut reader = BufReader::new(stream);
let (mut first, mut line, mut len) = (String::new(), String::new(), 0usize);
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if first.is_empty() {
first = line.clone();
}
if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap();
}
}
if first.is_empty() {
continue; // a reachability probe
}
let mut body = vec![0u8; len];
reader.read_exact(&mut body).unwrap();
let (op, attrs, doc) = photocraft_ipp::parse_request(&body).unwrap();
let list = |names: &[&str]| {
if names.is_empty() {
return Request::new(0x0406, 1).end();
}
let mut r = Request::new(0, 1);
for p in names {
r.group(TAG_PRINTER_GROUP);
r.attr(TAG_NAME, "printer-name", p.as_bytes());
}
r.end()
};
let reply = match op {
OP_CUPS_GET_PRINTERS => list(printers),
OP_CUPS_GET_CLASSES => list(classes),
OP_CUPS_GET_DEFAULT => match default {
Some(d) => {
let mut r = Request::new(0, 1);
r.group(TAG_PRINTER_GROUP);
r.attr(TAG_NAME, "printer-name", d.as_bytes());
r.end()
}
None => Request::new(0x0406, 1).end(),
},
OP_PRINT_JOB => {
let mut r = Request::new(0, 1);
r.group(TAG_JOB_GROUP);
r.integer("job-id", 42);
r.end()
}
_ => Request::new(0x0501, 1).end(),
};
let _ = tx.send((op, attrs, doc));
let mut out = reader.into_inner();
let _ = out.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/ipp\r\nContent-Length: {}\r\n\r\n", reply.len()).as_bytes());
let _ = out.write_all(&reply);
let _ = out.flush();
}
});
(server, rx)
}
#[test]
fn a_named_server_is_printed_to_over_ipp() {
if !in_child("print_cmds::tests::cups::a_named_server_is_printed_to_over_ipp", &[]) {
return;
}
let (server, rx) = fake_cups(&["Office"], &["Lab"], Some("Lab"));
let mut s = session("rgb", 8);
let r = s.execute("file.print", json!({"printServer": server, "copies": 3})).unwrap();
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Lab")));
assert!(r["pdf"].is_null());
let job = std::iter::from_fn(|| rx.try_recv().ok()).find(|(op, _, _)| *op == OP_PRINT_JOB).expect("a Print-Job request");
let attr = |name: &str| job.1.iter().find(|a| a.name == name).unwrap_or_else(|| panic!("{name}"));
assert!(attr("printer-uri").text().ends_with("/classes/Lab"), "{}", attr("printer-uri").text());
assert_eq!(
(attr("copies").int(), attr("job-name").text().as_str(), attr("document-format").text().as_str()),
(Some(3), "Print Me.psd", "application/pdf")
);
assert!(job.2.starts_with(b"%PDF-1.4"));
// The requested printer, in any case; an unknown one names the choices.
let r = s.execute("file.print", json!({"printServer": server, "printer": "office"})).unwrap();
assert_eq!(r["spooler"], "job 42 on Office");
let job = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).last().expect("the second Print-Job");
assert!(job.1.iter().any(|a| a.name == "printer-uri" && a.text().ends_with("/printers/Office")));
let e = s.execute("file.print", json!({"printServer": server, "printer": "Nope"})).unwrap_err().to_string();
assert!(e.contains("`Nope`") && e.contains("Office, Lab"), "{e}");
// Printers can be listed.
let r = s.execute("file.printers", json!({"printServer": server})).unwrap();
let names: Vec<&str> = r["printers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect();
assert_eq!((names, r["default"].as_str()), (vec!["Office", "Lab"], Some("Lab")));
assert_eq!(
(r["printers"][1]["default"].as_bool(), r["printers"][1]["class"].as_bool(), r["printers"][0]["class"].as_bool()),
(Some(true), Some(true), Some(false))
);
}
#[test]
fn without_a_printer_the_app_spooler_takes_over() {
if !in_child("print_cmds::tests::cups::without_a_printer_the_app_spooler_takes_over", &[]) {
return;
}
let (server, _rx) = fake_cups(&[], &[], None);
let mut s = session("gray", 8);
let e = s.execute("file.print", json!({"printServer": server})).unwrap_err().to_string();
assert!(e.contains("no printer is set up"), "{e}");
// Asking for a printer by name never falls through to another route.
let e = s.execute("file.print", json!({"printServer": server, "printer": "Office"})).unwrap_err().to_string();
assert!(e.contains("`Office`"), "{e}");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
s.print_spooler = Some(recording_spooler(false, &seen));
let r = s.execute("file.print", json!({"printServer": server})).unwrap();
assert_eq!((r["backend"].as_str(), r["spooler"].as_str()), (Some("test"), Some("queued")));
assert_eq!(seen.lock().unwrap().len(), 1);
// An unreachable named server is a failure, not a missing route.
let e = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
assert!(e.contains("no print server at"), "{e}");
let r = s.execute("file.printers", json!({"printServer": "127.0.0.1:1"})).unwrap();
assert!(r["printers"].as_array().unwrap().is_empty() && r["note"].as_str().unwrap().contains("no print server"));
}
/// The Flatpak case: no `lp` on the path, so the job goes to the system's CUPS over IPP
/// (`CUPS_SERVER` names it here), and with no print server at all to the app's spooler.
/// Runs in a child process, since the path is process-wide.
#[test]
fn without_lp_the_job_goes_over_ipp_then_to_the_app_spooler() {
const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_NO_LP";
if std::env::var_os(CHILD).is_some() {
let mut s = session("rgb", 8);
let r = s.execute("file.print", json!({"copies": 2})).unwrap();
assert_eq!((r["sent"].as_bool(), r["backend"].as_str(), r["spooler"].as_str()), (Some(true), Some("ipp"), Some("job 42 on Office")));
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
s.print_spooler = Some(recording_spooler(false, &seen));
// The IPP route first, then the app's spooler only when there is no server.
let r = s.execute("file.print", json!({})).unwrap();
assert_eq!(r["backend"], "ipp");
assert!(seen.lock().unwrap().is_empty());
// (CUPS_SERVER is read per job; an unreachable one is no route.)
let r = s.execute("file.print", json!({"printServer": "127.0.0.1:1"})).unwrap_err().to_string();
assert!(r.contains("no print server at"), "{r}");
println!("child ok");
return;
}
let (server, rx) = fake_cups(&["Office"], &[], Some("Office"));
let no_lp = tempfile::tempdir().unwrap();
let out = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "print_cmds::tests::cups::without_lp_the_job_goes_over_ipp_then_to_the_app_spooler", "--nocapture"])
.env(CHILD, "1")
.env("PATH", no_lp.path())
.env("CUPS_SERVER", &server)
.env_remove("PHOTOCRAFT_NO_PRINT")
.output()
.unwrap();
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success() && stdout.contains("child ok"), "stdout: {stdout}\nstderr: {stderr}");
let jobs = std::iter::from_fn(|| rx.try_recv().ok()).filter(|(op, _, _)| *op == OP_PRINT_JOB).count();
assert_eq!(jobs, 2, "both prints reached the fake CUPS");
}
}
+29
View File
@@ -377,6 +377,35 @@ mod tests {
assert!((x - 40.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}");
}
#[test]
fn flips_and_right_angle_turns_keep_points_on_their_pixel() {
use crate::canvas_geom::Turn;
for (id, turn) in [
("image.imageRotation.flipCanvasHorizontal", Turn::FlipHorizontal),
("image.imageRotation.flipCanvasVertical", Turn::FlipVertical),
("image.imageRotation.180", Turn::Rotate180),
("image.imageRotation.90cw", Turn::Cw90),
("image.imageRotation.90ccw", Turn::Ccw90),
] {
// A black pixel at (3, 3) of a white 100×80 document, with a point on it.
let mut s = session();
s.edit("paint", |doc, _| {
let surf = doc.layers[0].surface_mut().unwrap();
let black = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 0.0, 1.0]);
surf.fill_rect(Rect::new(3, 3, 4, 4), &black);
Ok(())
})
.unwrap();
s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap();
s.execute(id, json!({})).unwrap();
let (x, y) = turn.pixel_map(100, 80)(3, 3);
let p = points(&mut s);
assert_eq!(p.len(), 1, "{id}");
assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}");
assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}");
}
}
#[test]
fn a_rotated_crop_moves_points() {
// The crop frame is centred on the point, so the point lands at the new canvas centre.
+13 -4
View File
@@ -77,8 +77,13 @@ fn base_name(path: &str) -> String {
path.rsplit(['/', '\\']).next().unwrap_or(path).to_string()
}
/// A linked file's bytes; `None` when it is missing or its path is not followed
/// ([`crate::doc_path::resolve`]).
fn read_file(path: &str) -> Option<Vec<u8>> {
if path.is_empty() { None } else { photocraft_format::read_file(std::path::Path::new(path)).ok() }
if path.is_empty() {
return None;
}
photocraft_format::read_file(&crate::doc_path::resolve(path).ok()?).ok()
}
/// The source file of a smart object: embedded bytes, the PSD's embedded linked-layer data (PSD
@@ -492,10 +497,14 @@ fn refresh_or_fail(doc: &mut Document, id: LayerId) -> Result<()> {
fn refresh_or_fail_in(doc: &mut Document, id: LayerId, ctx: &JobCtx) -> Result<()> {
if refresh_in(doc, id, ctx)? {
Ok(())
} else {
Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered"))
return Ok(());
}
if let SmartSource::Linked { path } = &smart(doc, id)?.source
&& let Err(why @ crate::doc_path::NotFollowed::Refused(_)) = crate::doc_path::resolve(path)
{
return Err(crate::doc_path::refusal("the linked file", path, why));
}
Err(other("the smart object's contents are unavailable (missing linked file?), so it can't be re-rendered"))
}
fn smart(doc: &Document, id: LayerId) -> Result<&SmartObject> {
+27 -5
View File
@@ -89,10 +89,21 @@ pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> {
}
fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) {
return None;
}
let b = crate::transform_cmds::transform_bounds(doc, l);
snappable(l).then(|| crate::transform_cmds::transform_bounds(doc, l)).and_then(to_rect)
}
/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection
/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the
/// selection's, so a moved selection snapped back to where it started).
fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect)
}
fn snappable(l: &photocraft_doc::Layer) -> bool {
!(l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()))
}
fn to_rect(b: photocraft_geom::Rect) -> Option<[f64; 4]> {
(!b.is_empty()).then_some([b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64])
}
@@ -121,7 +132,7 @@ impl SnapTargets {
if !l.visible || exclude.contains(&l.id) {
continue;
}
if let Some(r) = rect_of(doc, l) {
if let Some(r) = target_rect_of(l) {
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
}
}
@@ -365,6 +376,17 @@ mod tests {
let t = SnapTargets::from_document(&doc, &SnapOptions { guides: false, ..opts }, &[id]);
assert!(!t.x.iter().any(|t| t.kind == SnapKind::Guide || (t.kind == SnapKind::LayerEdge && t.pos == 30.0)));
assert_eq!(layer_rect(&doc, id), Some([10.0, 20.0, 30.0, 60.0]));
// #6290: a selection doesn't move the layers' targets onto its own bounds.
let mut sel = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8);
sel.fill_rect(photocraft_geom::Rect::new(64, 66, 124, 86), &[1.0]);
doc.selection = Some(sel);
let t = SnapTargets::from_document(&doc, &SnapOptions { selection: false, ..opts }, &[]);
assert!(t.x.iter().any(|t| t.kind == SnapKind::LayerEdge && t.pos == 30.0));
assert!(
!t.x.iter()
.chain(&t.y)
.any(|t| matches!(t.kind, SnapKind::LayerEdge | SnapKind::LayerCenter) && [64.0, 66.0, 76.0, 86.0, 94.0, 124.0].contains(&t.pos))
);
assert_eq!(union([[0.0, 0.0, 1.0, 1.0], [2.0, -1.0, 3.0, 0.5]]), Some([0.0, -1.0, 3.0, 1.0]));
}
}
+41 -2
View File
@@ -1,7 +1,8 @@
//! Stamping (#217): merge layers into a layer while keeping the originals.
//!
//! - **Stamp Visible** (⌘⌥⇧E, or ⌥ + Layer › Merge Visible): every visible layer composited into a
//! new layer above the active one.
//! new layer above the active one, or into the active layer when it is an empty pixel layer (as
//! in Photoshop, so a recorded New Layer + Stamp Visible leaves one layer, #6785).
//! - **Stamp Down** (⌘⌥E, or ⌥ + Layer › Merge Down): a copy of the active layer merged into the
//! pixel layer below it; with several layers selected, a copy of them merged into a new layer
//! above the top one.
@@ -65,6 +66,13 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result<Value> {
if !doc.walk().iter().any(|(path, _, l)| !l.is_group() && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))) {
return Err(EngineError::Other("there are no visible layers to stamp".into()));
}
// Like Photoshop, an empty pixel layer that is active (Layer > New > Layer, then Stamp
// Visible, as recorded actions do) takes the stamp itself instead of getting a second layer.
if let Some(empty) = (*active).filter(|id| is_empty_pixel_layer(doc, *id)) {
let surf = photocraft_compose::flatten_to_surface(doc, with_alpha(doc), None);
*crate::pixels_mut(doc.layer_mut(empty).ok_or(EngineError::NoLayer(empty))?)? = surf;
return Ok(empty);
}
let layer = stamp_layer(doc, doc.next_layer_name("Layer"))?;
let id = doc.insert_above(*active, layer);
*active = Some(id);
@@ -74,6 +82,18 @@ fn stamp_visible(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "layer": id.0 }))
}
/// A plain, unlocked pixel layer with no pixels, mask or clipping: safe to stamp into.
fn is_empty_pixel_layer(doc: &Document, id: LayerId) -> bool {
let Some(l) = doc.layer(id) else { return false };
let locks = doc.effective_locks(id);
matches!(&l.content, LayerContent::Raster(s) if s.content_bounds().is_empty())
&& l.visible
&& l.mask.is_none()
&& l.vector_mask.is_none()
&& !l.clipped
&& !(locks.all || locks.pixels || locks.transparency)
}
fn stamp_down(s: &mut Session, p: &Value) -> Result<Value> {
object(p, "layer.stampDown")?;
let sel = crate::layer_multi_cmds::selected(s);
@@ -148,7 +168,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Stamp Visible",
menu: &[],
shortcut: Some("Cmd+Alt+Shift+E"),
params: r##"{} → {layer} (every visible layer merged into a new layer above the active one; the originals stay)"##,
params: r##"{} → {layer} (visible layers merged into a new layer above the active one, or into the active one if it is an empty pixel layer)"##,
enabled: has_doc,
journal: true,
run: stamp_visible,
@@ -235,6 +255,25 @@ mod tests {
}
}
/// Recorded actions do New Layer, then Stamp Visible: the stamp lands in the new empty layer.
#[test]
fn stamp_visible_fills_an_empty_active_layer_instead_of_adding_one() {
let (mut s, bg, hidden, blue) = session(8);
s.execute("layer.new.layer", json!({"name": "Empty"})).unwrap();
let empty = s.active().unwrap().active_layer.unwrap();
let r = s.execute("layer.stampVisible", json!({})).unwrap();
assert_eq!(r["layer"].as_u64(), Some(empty.0));
assert_eq!(ids(&s), [bg, hidden, blue, empty], "no extra layer");
let st = s.active().unwrap();
assert_eq!((st.active_layer, st.selected_layers.clone()), (Some(empty), vec![empty]));
assert!(close(rgba(&s, empty, 30, 30), [0.0, 0.0, 1.0, 1.0]));
assert!(close(rgba(&s, empty, 2, 2), [1.0, 0.0, 0.0, 1.0]));
// A second stamp: the layer now has pixels, so this one adds a layer.
let r2 = s.execute("layer.stampVisible", json!({})).unwrap();
assert_ne!(r2["layer"].as_u64(), Some(empty.0));
assert_eq!(ids(&s).len(), 5);
}
#[test]
fn stamp_visible_respects_opacity_blend_and_groups() {
let (mut s, bg, _, blue) = session(8);
+27 -6
View File
@@ -1,6 +1,6 @@
//! Edit › Free Transform / Transform: projective transforms of layers (and the selection).
use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with};
use photocraft_algo::transform::{Homography, Interp, warp_output_size, warp_surface, warp_surface_with};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
use photocraft_geom::{Affine, Rect};
@@ -54,7 +54,7 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
}
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
fn whole_layer_bounds(layer: &Layer) -> Rect {
pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect {
let content = match &layer.content {
LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
@@ -273,10 +273,13 @@ const BUDGET: u64 = 1 << 20;
/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget,
/// before anything is built. `what` names the surface in the message.
pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> {
let Some(b) = warp_output_bounds(src, h) else { return Ok(()) };
let (w, ht) = (u64::from(b.width()), u64::from(b.height()));
if w.saturating_mul(ht) > BUDGET {
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit");
check_budget_with_limit(src, h, what, BUDGET)
}
fn check_budget_with_limit(src: Rect, h: &Homography, what: &str, budget: u64) -> Result<()> {
let Some((w, ht)) = warp_output_size(src, h) else { return Ok(()) };
if w.saturating_mul(ht) > budget {
let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {budget} px limit");
return Err(crate::allocation::error("transforming pixels", &detail));
}
Ok(())
@@ -735,6 +738,24 @@ mod tests {
// ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ----
#[test]
fn huge_transform_scales_exceed_the_production_budget_before_clipping() {
let src = Rect::new(0, 0, 24, 16);
for scale in [1e7, 1e28, 1e306] {
for (sx, sy) in [(scale, 1.0), (1.0, scale), (-scale, 1.0)] {
let h = Homography([sx, 0.0, 0.0, 0.0, sy, 0.0, 0.0, 0.0, 1.0]);
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_err(), "{sx} x {sy} must be refused");
}
}
}
#[test]
fn an_ordinary_transform_remains_within_the_production_budget() {
let src = Rect::new(0, 0, 24, 16);
let h = Homography([10_000.0, 0.0, 900_000.0, 0.0, 1.0, -900_000.0, 0.0, 0.0, 1.0]);
assert!(check_budget_with_limit(src, &h, "layer", MAX_TRANSFORM_PIXELS).is_ok());
}
/// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10.
fn column() -> Session {
let mut s = Session::new();
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more