Merge origin/main into codex/multi-channel-editing

Integrate main 69b8fd9c1, retaining multi-channel selection and editing through extracted channel-panel, Move-pointer and tone-editor helpers. Adapt explicit imports and preserve progress-document histories.
This commit is contained in:
Vlad Grankovsky
2026-10-29 00:37:26 +01:00
550 changed files with 27014 additions and 10605 deletions
+24
View File
@@ -29,3 +29,27 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: bash packaging/linux/verified-download-test.sh
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+8 -4
View File
@@ -4,10 +4,13 @@ name: Perf (nightly)
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
# more than 15 % against perf/baseline.json from the same machine class (other classes are
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
# never compared; a baseline built from several runs is judged against its highest p50). The
# table goes to the job summary; results.json is uploaded as the `perf-results` artifact.
#
# To give this runner class its own baseline: run it by hand with "update_baseline", download
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
# To give this runner class its own baseline: download the `perf-results` artifact of the last
# five or more nightly runs (`gh run download <run id> -n perf-results -D runs/<run id>`), run
# `cargo xtask perf --update-baseline --from runs/<id>/results.json ... --allow-contended` (this
# runner is never idle) and commit perf/baseline.json and docs/scorecard.md in a PR.
on:
schedule:
@@ -58,7 +61,8 @@ jobs:
run: |
args=()
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
# This runner is never idle: a baseline from it is recorded labelled as contended.
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline --allow-contended); fi
cargo xtask perf "${args[@]}"
- name: Job summary
+4 -2
View File
@@ -26,8 +26,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: photocraft
app-dir: PhotoCraft
install-relative-path: Programs/ArtCraft/PhotoCraft
install-scope: perUser
+1 -1
View File
@@ -58,7 +58,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
- **Last-resort guard.** The app shell must catch an escaped panic around command dispatch and file import/export, reports it as an error and keeps the document. It's a safety net, not a licence to panic. Keep `panic = "unwind"`.
- **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix.
- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic`/indexing in tests only. Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it.
- **Enforced by clippy.** `clippy.toml` allows `unwrap`/`expect`/`panic` in tests only. Clean production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`.
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
+2 -1
View File
@@ -29,11 +29,12 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `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`, `slice-select.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.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`, `align-edge-*.svg` and `distribute-*.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`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.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`, `align-edge-*.svg` and `distribute-*.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/slice-select.svg` | Slice Select 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 |
| `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
Generated
+33 -31
View File
@@ -4961,7 +4961,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"arboard",
"async-io",
@@ -5011,7 +5011,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"crc32fast",
"flate2",
@@ -5021,7 +5021,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5035,7 +5035,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"cap-std",
@@ -5047,6 +5047,7 @@ dependencies = [
"photocraft-engine",
"photocraft-format",
"photocraft-io",
"photocraft-raster",
"photocraft-raw",
"rmcp",
"rustix 1.1.5",
@@ -5059,7 +5060,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
@@ -5078,7 +5079,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"moxcms",
"rayon",
@@ -5086,7 +5087,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"craft-jpeg",
"exr",
@@ -5108,7 +5109,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -5116,7 +5117,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"criterion",
"photocraft-cms",
@@ -5132,7 +5133,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5143,7 +5144,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"blake3",
"chrono",
@@ -5184,7 +5185,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"blake3",
"crc32fast",
@@ -5207,14 +5208,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"bytemuck",
"half",
@@ -5234,19 +5235,20 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"blake3",
"craft-docpath",
"crc32fast",
"flate2",
"miniz_oxide 0.9.1",
"photocraft-affinity",
"photocraft-algo",
"photocraft-cms",
@@ -5275,18 +5277,18 @@ dependencies = [
[[package]]
name = "photocraft-ipp"
version = "0.16.0"
version = "0.17.0"
[[package]]
name = "photocraft-ops"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-cms",
"photocraft-color",
@@ -5299,7 +5301,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5315,7 +5317,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -5326,7 +5328,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -5337,7 +5339,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"flate2",
"image",
@@ -5352,7 +5354,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.5",
@@ -5364,7 +5366,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"memory-stats",
"serde_json",
@@ -5372,7 +5374,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"flate2",
"parley",
@@ -5387,7 +5389,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"craft-print",
@@ -5432,7 +5434,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -5443,7 +5445,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -5465,7 +5467,7 @@ dependencies = [
[[package]]
name = "photocraft-web-boot"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"flate2",
"js-sys",
@@ -9181,7 +9183,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.16.0"
version = "0.17.0"
dependencies = [
"chrono",
"serde",
+5 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.16.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -51,7 +51,7 @@ clap_complete = "4.6.11"
clap_mangen = { version = "0.3.3", features = ["env"] }
arc-swap = "1"
bytemuck = { version = "1", features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
image = { version = "0.25", default-features = false }
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
zune-core = "0.5"
@@ -76,6 +76,9 @@ web-time = "1"
unsafe_code = "forbid"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
+146 -116
View File
@@ -193,6 +193,143 @@ fn at_least_min_size(mut builder: egui::ViewportBuilder) -> egui::ViewportBuilde
builder
}
/// Attach the log file under the settings directory. Called after the arguments are parsed, so
/// `--version` and usage errors leave no file behind. Records logged until now are written first.
fn attach_log_file(logger: Option<&'static logging::AppLogger>) {
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
}
/// One PhotoCraft per user (#3708). Only with the per-user settings folder (a portable copy's may
/// be shared between users), and not for automation, `--new-instance` or per-process options.
/// `Break` when the files were handed to the running PhotoCraft and this launch is done.
#[cfg(not(target_os = "macos"))]
fn claim_single_instance(
files: &[String],
new_instance: bool,
control: bool,
unreadable_paths: bool,
mut launch_options: Vec<launch_args::PerProcess>,
) -> std::ops::ControlFlow<(), Option<single_instance::Primary>> {
use std::ops::ControlFlow::{Break, Continue};
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
// keeps its own process instead of handing its files over and being ignored.
launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
new_instance_env: new_instance_env.as_deref(),
control,
unreadable_paths,
launch_options: &launch_options,
};
match (single_instance::skip_reason(&context), services::config_dir()) {
(Some(reason), _) => {
log::info!("running as a separate PhotoCraft: {reason}");
Continue(None)
}
(None, None) => Continue(None),
(None, Some(dir)) => {
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
match single_instance::claim(&dir, &paths) {
single_instance::Launch::Forwarded => {
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
Break(())
}
single_instance::Launch::Primary(primary) => Continue(Some(primary)),
single_instance::Launch::Standalone(reason) => {
log::warn!("starting a separate PhotoCraft: {reason}");
Continue(None)
}
}
}
}
}
/// The display server to open the window on: Xwayland for a pen on Wayland, which gives this app
/// no pen input (#639). Exits with the package to install when its libraries are missing.
#[cfg(target_os = "linux")]
fn linux_display_session() -> linux_libs::DisplaySession {
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
let session = if session == linux_libs::DisplaySession::Wayland
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
&& linux_libs::available(linux_libs::DisplaySession::X11)
{
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
linux_libs::DisplaySession::X11
} else {
session
};
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
if let Err(message) = linux_libs::preflight(session) {
eprint!("{message}");
std::process::exit(1);
}
session
}
/// The control server's port, token and workspace, when it is on (exits 2 on a bad configuration).
fn control_setup(
cli_port: Option<u16>,
auth: photocraft_automation::args::ControlArgs,
automation: &photocraft_automation::args::AutomationArgs,
) -> Option<(u16, String, photocraft_automation::workspace::AuthorizedWorkspace)> {
let (port, warning) = control_port_for(cli_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &warning {
eprint!("{warning}");
}
let port = port?;
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
eprintln!("photocraft: cannot configure control authentication: {e}");
std::process::exit(2);
}
};
if let Some(path) = token_file {
eprintln!("photocraft: control token file: {}", path.display());
} else if supplied.is_none() {
eprintln!("photocraft: control token: {token}");
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match automation.workspace() {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
std::process::exit(2);
}
};
Some((port, token, workspace))
}
/// Record this start's backend in the GPU startup marker, if the marker is locked.
fn write_start_marker(sentinel: &gpu_startup::SharedSentinel, plan: &gpu_startup::Plan) {
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
let backend = match &plan.env {
Some(v) => format!("env:{v}"),
None => plan.backend.name().to_string(),
};
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
if let Err(e) = s.write(marker) {
log::warn!("GPU startup marker: {e}");
}
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@@ -228,15 +365,7 @@ fn main() -> eframe::Result {
mut launch_options,
} = launch;
// The log file lives under the settings directory; opened after the arguments, so `--version`
// and usage errors leave no file behind. Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, services::config_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PhotoCraft {}, log file {}", photocraft_engine::build_info::long_version(), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
attach_log_file(logger);
// An adapter named in the environment is chosen at startup, which a forwarded launch never reaches.
let rendering_mode = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref()).1;
@@ -244,114 +373,24 @@ fn main() -> eframe::Result {
launch_options.push(launch_args::PerProcess::GpuAdapterEnv);
}
// One PhotoCraft per user (#3708): files opened from Explorer or a file manager while it runs
// become tabs in its window. Only with the per-user settings folder (a portable copy's may be
// shared between users), and not for automation, `--new-instance` or per-process options.
// become tabs in its window. Lives until the event loop returns, then `shutdown` lets the next
// launch become the primary.
#[cfg(not(target_os = "macos"))]
let single_instance = {
let new_instance_env = std::env::var("PHOTOCRAFT_NEW_INSTANCE").ok();
// A variable documented as the equivalent of a per-launch flag counts as that flag: a
// launch that sets only `PHOTOCRAFT_PRESENT_MODE` (say) has per-process options too, so it
// keeps its own process instead of handing its files over and being ignored.
// Folded in here, inside the block that only non-macOS builds compile, so the binding
// above keeps its `mut` off macOS, where nothing is folded in.
let mut launch_options = launch_options;
launch_options.extend_from_slice(&single_instance::env_launch_options());
let context = single_instance::LaunchContext {
per_user_settings: app_dirs::current().mode == app_dirs::Mode::Platform,
new_instance_flag: new_instance,
new_instance_env: new_instance_env.as_deref(),
control: control_port.is_some(),
unreadable_paths: !unreadable_paths.is_empty(),
launch_options: &launch_options,
};
match (single_instance::skip_reason(&context), services::config_dir()) {
(Some(reason), _) => {
log::info!("running as a separate PhotoCraft: {reason}");
None
}
(None, None) => None,
(None, Some(dir)) => {
let paths: Vec<String> = files.iter().map(|f| single_instance::absolute(f)).collect();
match single_instance::claim(&dir, &paths) {
single_instance::Launch::Forwarded => {
log::info!("handed {} file(s) to the running PhotoCraft", paths.len());
return Ok(());
}
single_instance::Launch::Primary(primary) => Some(primary),
single_instance::Launch::Standalone(reason) => {
log::warn!("starting a separate PhotoCraft: {reason}");
None
}
}
}
}
let single_instance = match claim_single_instance(&files, new_instance, control_port.is_some(), !unreadable_paths.is_empty(), launch_options) {
std::ops::ControlFlow::Break(()) => return Ok(()),
std::ops::ControlFlow::Continue(primary) => primary,
};
// Lives until the event loop returns, then `shutdown` lets the next launch become the primary.
#[cfg(not(target_os = "macos"))]
let single_instance = &single_instance;
#[cfg(target_os = "macos")]
let _ = (new_instance, &launch_options);
// The display server to open the window on: Xwayland for a pen on Wayland, which gives this
// app no pen input (#639).
#[cfg(target_os = "linux")]
let native_session = linux_libs::session_from_env(|name| std::env::var(name).ok());
#[cfg(target_os = "linux")]
let session = tablet::display_session();
// Preferences › Performance › Linux display server = X11: Xwayland on a Wayland session too,
// where native file drops work (winit 0.30 has none on Wayland, #386). Only when Xwayland can
// run the window ($DISPLAY set, X11 libraries installed); otherwise the session's own.
#[cfg(target_os = "linux")]
let session = if session == linux_libs::DisplaySession::Wayland
&& gpu_startup::read_display_server(services::prefs_file().as_deref()) == photocraft_engine::prefs::LinuxDisplayServer::X11
&& std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty())
&& linux_libs::available(linux_libs::DisplaySession::X11)
{
eprintln!("photocraft: opening the window through Xwayland (Preferences › Performance › Linux display server)");
linux_libs::DisplaySession::X11
} else {
session
};
let session = linux_display_session();
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight(session) {
eprint!("{message}");
std::process::exit(1);
}
let (control_port, control_pref_warning) = control_port_for(control_port, &integrations(services::prefs_file().as_deref()));
if let Some(warning) = &control_pref_warning {
eprint!("{warning}");
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = (auth.control_token, auth.control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
Ok(token) => token,
Err(e) => {
eprintln!("photocraft: cannot configure control authentication: {e}");
std::process::exit(2);
}
};
if let Some(path) = token_file {
eprintln!("photocraft: control token file: {}", path.display());
} else if supplied.is_none() {
eprintln!("photocraft: control token: {token}");
} else {
eprintln!("photocraft: using supplied control token");
}
let workspace = match automation.workspace() {
Ok(workspace) => workspace,
Err(error) => {
eprintln!("photocraft: cannot configure automation workspace: {error}");
std::process::exit(2);
}
};
Some((port, token, workspace))
} else {
None
};
let control = control_setup(control_port, auth, &automation);
// Finder / Dock / Open With deliver files as Apple events, not arguments; catch the one that
// launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -409,16 +448,7 @@ fn main() -> eframe::Result {
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
}
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
let backend = match &plan.env {
Some(v) => format!("env:{v}"),
None => plan.backend.name().to_string(),
};
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
if let Err(e) = s.write(marker) {
log::warn!("GPU startup marker: {e}");
}
}
write_start_marker(&sentinel, &plan);
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
let wanted_adapter =
+28 -11
View File
@@ -18,10 +18,10 @@ mod recovery_tests;
/// Photoshop brushes (.abr), gradients (.grd), layer styles (.asl), swatches (.aco, .act, .ase) and keyboard shortcut sets
/// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts.
const OPEN_EXTS: &[&str] = &[
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico",
"qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3", "iff", "lbm",
"ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz", "aco", "act",
"ase", "kys", "af", "afdesign", "afphoto", "afpub",
"pcraft", "pdn", "ora", "xcf", "xcfgz", "gz", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "dib", "tga",
"ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pnm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "cin", "dcm", "dicom", "dc3",
"iff", "lbm", "ilbm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", "asl", "svg", "svgz",
"aco", "act", "ase", "kys", "af", "afdesign", "afphoto", "afpub",
];
const SVG_EXTS: &[&str] = &["svg", "svgz"];
@@ -54,14 +54,14 @@ const OPEN_FILTERS: &[(&str, &[&str])] = &[
("TIFF", &["tif", "tiff"]),
("WebP", &["webp"]),
("GIF", &["gif"]),
("BMP", &["bmp"]),
("BMP", &["bmp", "dib"]),
("Targa", &["tga"]),
("ICO", &["ico"]),
("QOI", &["qoi"]),
("PCX", &["pcx"]),
("DDS", &["dds"]),
("XPM", &["xpm"]),
("Portable Bitmap", &["pbm", "pgm", "ppm", "pam", "pfm"]),
("Portable Bitmap", &["pbm", "pgm", "ppm", "pnm", "pam", "pfm"]),
("OpenEXR", &["exr"]),
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit", "fts"]),
@@ -91,15 +91,19 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
("PhotoCraft", &["pcraft"]),
("OpenRaster", &["ora"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
("JPEG", &["jpg", "jpeg"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("TIFF", &["tif", "tiff"]),
("Targa", &["tga"]),
// Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha.
("BMP", &["bmp"]),
("BMP", &["bmp", "dib"]),
("GIF", &["gif"]),
("ICO", &["ico"]),
("OpenEXR", &["exr"]),
// The encoder picks P5, P6, P7 or PFM from the image, not from the name, so only the neutral
// .pnm is offered: a .pbm or .pgm name would hold pixmap data (CodeRabbit on #9775).
("Netpbm", &["pnm"]),
("Radiance HDR", &["hdr"]),
("FITS", &["fits", "fit"]),
("DPX", &["dpx"]),
// Game and 3D textures; DDS Options asks for the compression and mipmaps (#1327).
@@ -112,9 +116,21 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[
/// never swaps their extension for a document format's.
const OTHER_SAVE_FILTERS: &[(&str, &[&str])] = &[("Color Swatches", &["aco"]), ("Color Table", &["act"]), ("Swatch Exchange", &["ase"])];
/// `suggested` with a Netpbm subtype's extension (an opened .pgm) as the neutral .pnm, the name
/// every save dialog starts from: the encoder picks P5, P6, P7 or PFM from the image, so the old
/// suffix could name other data, and macOS and rfd keep the suggested name as it is.
fn netpbm_as_pnm(suggested: &str) -> String {
let path = Path::new(suggested);
let subtype = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).is_some_and(|e| ["pbm", "pgm", "ppm", "pam", "pfm"].contains(&e.as_str()));
if subtype { path.with_extension("pnm").to_string_lossy().into_owned() } else { suggested.to_string() }
}
/// Lists the save dialog's file types with the `suggested` type first (added if unlisted), so the dialog keeps that extension instead of .psd.
fn save_filters(suggested: &str) -> Vec<(String, Vec<String>)> {
let ext = Path::new(suggested).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
// A Netpbm subtype's name (an opened .pgm) starts on the Netpbm type as .pnm: the encoder picks
// the subtype from the image, so the old name could describe other data (CodeRabbit on #9775).
let ext = if ["pbm", "pgm", "ppm", "pam", "pfm"].contains(&ext.as_str()) { "pnm".to_string() } else { ext };
if let Some((name, exts)) = OTHER_SAVE_FILTERS.iter().find(|(_, exts)| exts.contains(&ext.as_str())) {
return vec![(name.to_string(), exts.iter().map(|e| e.to_string()).collect())];
}
@@ -216,6 +232,7 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
Box::pin(async move { picked.await.map(|folder| answer_one(folder.path(), true, FileDialogAnswer::FolderTo)) })
}
FileDialogRequest::Save { suggested } => {
let suggested = netpbm_as_pnm(&suggested);
let filters = save_filters(&suggested);
let name = dialog_file_name(&suggested, cfg!(windows));
let dir = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()).map(Path::to_path_buf);
@@ -617,8 +634,8 @@ fn shell_word(s: &str) -> String {
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
}
/// The release-check transport, or `None` inside Flatpak: the sandbox has no network (see the
/// manifest's finish-args) and `flatpak update` owns updates there.
/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there.
/// The sandbox has network (for the `--control` server), which does not change this.
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
flatpak_id.is_none().then(crate::updates::service)
}
+30 -1
View File
@@ -294,9 +294,33 @@ fn save_as_offers_only_writable_formats_including_ico() {
assert!(format.caps().write, "{name}: .{ext} can't be written");
}
}
for ext in ["ico", "gif", "bmp", "dds"] {
for ext in ["ico", "gif", "bmp", "dds", "pnm", "hdr", "dib", "jpeg", "tiff"] {
assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered");
}
// The codec writes P5/P6/P7/PF from the image, never from the name, so Netpbm offers only the
// neutral .pnm (#3609; CodeRabbit on #9775): a .pbm, .pgm or .pfm name would hold other data.
let netpbm = save_filters("Untitled").into_iter().find(|(name, _)| name == "Netpbm").map(|(_, exts)| exts);
assert_eq!(netpbm, Some(vec!["pnm".to_string()]));
// The suggested name itself becomes .pnm, as macOS and rfd keep it (CodeRabbit on #9775).
assert_eq!(netpbm_as_pnm("scans/image.pgm"), Path::new("scans/image.pnm").to_string_lossy());
assert_eq!(netpbm_as_pnm("image.PFM"), "image.pnm");
assert_eq!(netpbm_as_pnm("image.png"), "image.png");
assert_eq!(netpbm_as_pnm("Untitled-1"), "Untitled-1");
// Also when Save As starts from a subtype's name: no filter offers that subtype again.
for name in ["image.pbm", "image.pgm", "image.ppm", "image.pam", "image.pfm"] {
let filters = save_filters(name);
assert_eq!(filters[0], ("Netpbm".to_string(), vec!["pnm".to_string()]), "{name}");
let subtype = name.rsplit('.').next().unwrap_or_default();
assert!(!filters.iter().any(|(_, exts)| exts.iter().any(|e| e == subtype)), "{name}: .{subtype} is offered");
}
// File › Open lists every file Save As writes, also in that format's own type (PDF is written
// but not read yet).
for (name, exts) in SAVE_FILTERS {
for ext in exts.iter().filter(|e| **e != "pdf") {
assert!(OPEN_EXTS.contains(ext), "{name}: Open's All Formats lacks .{ext}");
assert!(OPEN_FILTERS.iter().any(|(_, open)| open.contains(ext)), "{name}: no Open type lists .{ext}");
}
}
assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type");
// What Save As writes there: an icon up to 256×256, and a clear error past that.
let doc = |side: u32| {
@@ -307,6 +331,11 @@ fn save_as_offers_only_writable_formats_including_ico() {
let opts = export_options(&photocraft_ui_egui::ExportSettings::default());
let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap();
assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header");
// And what the Netpbm and Radiance rows write.
let pnm = photocraft_io::export(&doc(4), "x.pnm", &opts).unwrap();
assert_eq!(pnm.bytes[..2], *b"P6", "an RGB document saves as a binary pixmap");
let hdr = photocraft_io::export(&doc(4), "x.hdr", &opts).unwrap();
assert!(hdr.bytes.starts_with(b"#?RADIANCE"), "a Radiance header");
let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("256"), "{err}");
}
+14
View File
@@ -0,0 +1,14 @@
<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"
>
<path d="M4 9.5c2.7-2 5.3-2 8 0s5.3 2 8 0" />
<path d="M4 15.5c2.7-2 5.3-2 8 0s5.3 2 8 0" />
</svg>

After

Width:  |  Height:  |  Size: 305 B

+16
View File
@@ -0,0 +1,16 @@
<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"
>
<path d="M3 4h10M8 4v10" />
<path d="M14 8h7M17.5 8v6" />
<path d="M3 19h18" />
<circle cx="9" cy="19" r="2" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 348 B

+3 -3
View File
@@ -38,13 +38,13 @@ The release packages ship `photocraft-cli` alongside the desktop app, so no buil
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\PhotoCraft\photocraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe` by default (`C:\Program Files\ArtCraft\PhotoCraft\photocraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/photocraft-cli` |
| macOS | the separate `photocraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add photocraft -- "C:\Program Files\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
# Windows (PowerShell), default install folder
claude mcp add photocraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\PhotoCraft\photocraft-cli.exe" mcp --automation-read-root <dir> --automation-write-root <dir>
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add photocraft -- photocraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>
```
+1
View File
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+1 -1
View File
@@ -332,7 +332,7 @@ fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
}
fn inflate(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
read_bounded(flate2::read::ZlibDecoder::new(data), size)
read_bounded(flate2::bufread::ZlibDecoder::new(data), size)
}
fn unzstd(data: &[u8], size: usize) -> Result<Vec<u8>, Error> {
+4 -1
View File
@@ -4,7 +4,10 @@
//!
//! `--json` writes the report `cargo xtask perf` reads: `{"rows": [{"name", "samples_ms"}]}`.
//! `--quick` runs the list on a 1500×1000 layer instead, for `cargo xtask perf --quick`.
use photocraft_algo::*;
use photocraft_algo::{
BlurPath, BlurQuality, BlurShape, DepthSource, Distribution, FieldPin, FilterParams, IrisPin, Light, SmartBlurMode, SpinPin, TextureChannel, apply,
output_area,
};
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_raster::Surface;
+54 -7
View File
@@ -555,13 +555,20 @@ pub(crate) fn run(effects: &[GalleryEffect], src: &Image, out: Rect, ctx: &Ctx)
res
}
/// Applies one gallery effect to the working window in place.
/// Applies one gallery effect to the working window in place. Each category handles its own
/// filters and reports whether the effect was one of them.
fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
let handled =
apply_artistic(e, win) || apply_brush_strokes(e, win) || apply_distort(e, win) || apply_sketch(e, win, ctx) || apply_stylize_texture(e, win, ctx);
debug_assert!(handled, "every gallery filter belongs to a category");
}
/// Artistic filters: the painterly looks of the gallery.
fn apply_artistic(e: &GalleryEffect, win: &mut Win) -> bool {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
match e.filter {
// ---------------- Artistic ----------------
F::ColoredPencil => {
let pw = g("pencilWidth");
let pressure = g("strokePressure") / 15.0;
@@ -745,7 +752,16 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = map(saturate(win.c[i], 1.2), |v| clamp01(v * k + n).powf(1.1 + sh * 0.6));
}
}
// ---------------- Brush Strokes ----------------
_ => return false,
}
true
}
/// Brush Strokes filters.
fn apply_brush_strokes(e: &GalleryEffect, win: &mut Win) -> bool {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
match e.filter {
F::AccentedEdges => {
let mut l = win.lum();
blur1(&mut l, w, h, 0.4 + g("smoothness") * 0.3);
@@ -859,7 +875,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
*p = map(*p, |v| clamp01((v - 0.5) * (1.0 + con) + 0.5) * (1.0 - ink * 0.9 * (0.5 + con)));
}
}
// ---------------- Distort ----------------
_ => return false,
}
true
}
/// Distort filters.
fn apply_distort(e: &GalleryEffect, win: &mut Win) -> bool {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
let bg = rgb(e.background);
match e.filter {
F::DiffuseGlow => {
let (gr, ga, ca) = (g("graininess") / 10.0, g("glowAmount") / 20.0, g("clearAmount") / 20.0);
let thr = 0.2 + ca * 0.7;
@@ -903,7 +929,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = src.sample(x as f32 + 0.5 + dx, y as f32 + 0.5 + dy);
}
}
// ---------------- Sketch ----------------
_ => return false,
}
true
}
/// Sketch filters (foreground is ink, background is paper).
fn apply_sketch(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
match e.filter {
F::BasRelief => {
let mut l = win.lum();
let orig = l.clone();
@@ -1107,7 +1143,17 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
win.c[i] = map(win.c[i], |v| (v * (0.85 + 0.25 * fib) - 0.5) * (co * 1.6 + 0.2) + 0.5 + (br - 0.5) * 0.8);
}
}
// ---------------- Stylize ----------------
_ => return false,
}
true
}
/// Stylize and Texture filters.
fn apply_stylize_texture(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) -> bool {
let g = |k: &str| e.get(k);
let (w, h) = (win.w, win.h);
let (fg, bg) = (rgb(e.foreground), rgb(e.background));
match e.filter {
F::GlowingEdges => {
blur3(&mut win.c, w, h, 0.3 + g("smoothness") * 0.25);
let mut e3 = vec![[0.0f32; 3]; win.c.len()];
@@ -1123,7 +1169,6 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
*p = map(*q, |v| clamp01(v * k));
}
}
// ---------------- Texture ----------------
F::Craquelure => {
let (sp, dep, bri) = (g("crackSpacing"), g("crackDepth") / 10.0, g("crackBrightness") / 10.0);
let cell = sp * 1.3 + 1.0;
@@ -1239,7 +1284,9 @@ fn apply(e: &GalleryEffect, win: &mut Win, ctx: &Ctx) {
}
}
F::Texturizer => texturize(win, e.choice("texture"), g("scaling"), g("relief"), e.choice("light"), e.flag("invert")),
_ => return false,
}
true
}
fn normalize3(v: [f32; 3]) -> [f32; 3] {
+124 -84
View File
@@ -361,23 +361,7 @@ impl Plan {
{
return None;
}
let mut scales = [0.0f64; 5];
for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() {
if i % 4096 == 0 && ctl.cancelled() {
return None;
}
if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE)
|| (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value)))
{
return None;
}
let a = if alpha { *pixel.last()? } else { 1.0 };
for (c, &value) in pixel.iter().enumerate() {
let value = if alpha && c + 1 < src.ch { value * a } else { value };
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(value.abs()));
}
}
let scales = channel_scales(src, alpha, ctl)?;
let mut result = buffer(ow.checked_mul(oh)?.checked_mul(src.ch)?, 0.0f64)?;
let read = self.read_rect(out)?;
let used = read.intersect(&src.rect);
@@ -393,81 +377,18 @@ impl Plan {
if ctl.cancelled() {
return None;
}
let pair = ChannelPair { src, alpha, scales: &scales, channel };
data.fill(Number::default());
for y in used.y0..used.y1 {
if (y - used.y0) % 8 == 0 && ctl.cancelled() {
return None;
}
let source_start =
usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?;
let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?;
let row = src.data.get(source_start..source_start.checked_add(source_len)?)?;
let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?;
let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?;
for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) {
let a = if alpha { *pixel.last()? } else { 1.0 };
// Normalize each channel separately before packing a pair.
// Otherwise a bright HDR colour's FFT residual could swamp
// a small alpha channel sharing the imaginary component.
let sample = |c: usize| -> f64 {
match (pixel.get(c), scales.get(c)) {
(Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale,
_ => 0.0,
}
};
*dst = Number::new(sample(channel), sample(channel + 1));
}
}
self.pack_channel_pair(&pair, used, read, &mut data, ctl)?;
self.transform(&mut data, &mut transposed, &mut scratch, false, ctl)?;
for (value, kernel) in data.iter_mut().zip(&self.kernel) {
*value *= kernel;
}
self.transform(&mut data, &mut transposed, &mut scratch, true, ctl)?;
let x0 = usize::try_from(-self.min_x).ok()?;
let y0 = usize::try_from(-self.min_y).ok()?;
for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() {
let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?;
let values = data.get(start..start.checked_add(ow)?)?;
for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) {
*pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?;
if let Some(next) = pixel.get_mut(channel + 1) {
*next = value.im * norm * scales.get(channel + 1)?;
}
}
}
self.unpack_channel_pair(&pair, &data, &mut result, ow, norm)?;
}
if alpha {
// The row kernel accumulates in f32. Recompute very small alpha
// near its cutoff so a different summation order cannot expose colour.
let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?;
for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() {
if i % 16 == 0 && ctl.cancelled() {
return None;
}
let a = pixel.last_mut()?;
// Valid alpha is nonnegative. Remove transform roundoff at
// zero to keep fully transparent regions empty.
if a.abs() < 1e-14 * scales.get(src.ch - 1)? {
*a = 0.0;
}
*a = a.clamp(0.0, 1.0);
let a = *a;
// The row kernel's f32 sum can straddle the cutoff at long distances.
// Keep its branch and straight colour exactly for that narrow
// range, rather than amplifying a tiny alpha difference.
if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) {
let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?;
let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?;
let original = sample_row_point(src, x, y, alpha, &self.taps, ctl)?;
for (dst, value) in pixel.iter_mut().zip(original) {
*dst = f64::from(value);
}
continue;
}
for value in pixel.iter_mut().take(src.ch - 1) {
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
}
}
self.restore_small_alpha(src, out, ow, &scales, &mut result, ctl)?;
}
let mut output = buffer(result.len(), 0.0f32)?;
for (dst, value) in output.iter_mut().zip(result) {
@@ -475,6 +396,125 @@ impl Plan {
}
Some(output)
}
/// Packs the pair of channels into the real and imaginary parts of `data`, over the source
/// rows that fall inside the transform tile, each channel normalised by its own scale.
fn pack_channel_pair(&self, pair: &ChannelPair, used: Rect, read: Rect, data: &mut [Number], ctl: &Interrupt) -> Option<()> {
let ChannelPair { src, alpha, scales, channel } = *pair;
let (sw, _) = dimensions(src.rect)?;
for y in used.y0..used.y1 {
if (y - used.y0) % 8 == 0 && ctl.cancelled() {
return None;
}
let source_start =
usize::try_from(y - src.rect.y0).ok()?.checked_mul(sw)?.checked_add(usize::try_from(used.x0 - src.rect.x0).ok()?)?.checked_mul(src.ch)?;
let source_len = usize::try_from(used.x1 - used.x0).ok()?.checked_mul(src.ch)?;
let row = src.data.get(source_start..source_start.checked_add(source_len)?)?;
let start = usize::try_from(y - read.y0).ok()?.checked_mul(self.width)?.checked_add(usize::try_from(used.x0 - read.x0).ok()?)?;
let dst = data.get_mut(start..start.checked_add(source_len / src.ch)?)?;
for (pixel, dst) in row.chunks_exact(src.ch).zip(dst) {
let a = if alpha { *pixel.last()? } else { 1.0 };
// Normalize each channel separately before packing a pair.
// Otherwise a bright HDR colour's FFT residual could swamp
// a small alpha channel sharing the imaginary component.
let sample = |c: usize| -> f64 {
match (pixel.get(c), scales.get(c)) {
(Some(&value), Some(&scale)) if scale > 0.0 => f64::from(if alpha && c + 1 < src.ch { value * a } else { value }) / scale,
_ => 0.0,
}
};
*dst = Number::new(sample(channel), sample(channel + 1));
}
}
Some(())
}
/// Writes the filtered pair of channels from `data` into the output pixels, undoing the
/// per-channel normalisation.
fn unpack_channel_pair(&self, pair: &ChannelPair, data: &[Number], result: &mut [f64], ow: usize, norm: f64) -> Option<()> {
let ChannelPair { src, scales, channel, .. } = *pair;
let x0 = usize::try_from(-self.min_x).ok()?;
let y0 = usize::try_from(-self.min_y).ok()?;
for (y, dst) in result.chunks_exact_mut(ow * src.ch).enumerate() {
let start = y.checked_add(y0)?.checked_mul(self.width)?.checked_add(x0)?;
let values = data.get(start..start.checked_add(ow)?)?;
for (pixel, value) in dst.chunks_exact_mut(src.ch).zip(values) {
*pixel.get_mut(channel)? = value.re * norm * scales.get(channel)?;
if let Some(next) = pixel.get_mut(channel + 1) {
*next = value.im * norm * scales.get(channel + 1)?;
}
}
}
Some(())
}
/// Straightens the colour of the filtered pixels by their alpha, taking care of the pixels
/// whose alpha is at the cutoff.
fn restore_small_alpha(&self, src: &Image, out: Rect, ow: usize, scales: &[f64; 5], result: &mut [f64], ctl: &Interrupt) -> Option<()> {
// The row kernel accumulates in f32. Recompute very small alpha
// near its cutoff so a different summation order cannot expose colour.
let uncertainty = f64::from(self.distance.abs().ceil()) * f64::from(f32::EPSILON) * scales.get(src.ch - 1)?;
for (i, pixel) in result.chunks_exact_mut(src.ch).enumerate() {
if i % 16 == 0 && ctl.cancelled() {
return None;
}
let a = pixel.last_mut()?;
// Valid alpha is nonnegative. Remove transform roundoff at
// zero to keep fully transparent regions empty.
if a.abs() < 1e-14 * scales.get(src.ch - 1)? {
*a = 0.0;
}
*a = a.clamp(0.0, 1.0);
let a = *a;
// The row kernel's f32 sum can straddle the cutoff at long distances.
// Keep its branch and straight colour exactly for that narrow
// range, rather than amplifying a tiny alpha difference.
if a > 0.0 && (a <= 1.01e-7 + uncertainty || (0.99e-7..=1.01e-7).contains(&(a as f32))) {
let x = out.x0.checked_add(i32::try_from(i % ow).ok()?)?;
let y = out.y0.checked_add(i32::try_from(i / ow).ok()?)?;
let original = sample_row_point(src, x, y, true, &self.taps, ctl)?;
for (dst, value) in pixel.iter_mut().zip(original) {
*dst = f64::from(value);
}
continue;
}
for value in pixel.iter_mut().take(src.ch - 1) {
*value = if a as f32 > 1e-7 { *value / a } else { 0.0 };
}
}
Some(())
}
}
/// One pair of channels (packed as real and imaginary parts) of a filter call.
#[derive(Clone, Copy)]
struct ChannelPair<'a> {
src: &'a Image,
alpha: bool,
scales: &'a [f64; 5],
channel: usize,
}
/// Per-channel peak magnitude of the (alpha-premultiplied) samples. `None` when a sample is not
/// finite, is too large, or its alpha is outside 0..=1, and when cancelled.
fn channel_scales(src: &Image, alpha: bool, ctl: &Interrupt) -> Option<[f64; 5]> {
let mut scales = [0.0f64; 5];
for (i, pixel) in src.data.chunks_exact(src.ch).enumerate() {
if i % 4096 == 0 && ctl.cancelled() {
return None;
}
if pixel.iter().any(|value| !value.is_finite() || value.abs() > MAX_SAMPLE) || (alpha && !pixel.last().is_some_and(|value| (0.0..=1.0).contains(value)))
{
return None;
}
let a = if alpha { *pixel.last()? } else { 1.0 };
for (c, &value) in pixel.iter().enumerate() {
let value = if alpha && c + 1 < src.ch { value * a } else { value };
let scale = scales.get_mut(c)?;
*scale = scale.max(f64::from(value.abs()));
}
}
Some(scales)
}
#[cfg(test)]
+138 -91
View File
@@ -167,29 +167,7 @@ fn complete_cow<'a>(
}
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is about four patches across (a deeper pyramid decides the
// structure where the texture has averaged out, and fills flat). A thin hole stops while it
// is still about two patches thick: through it, not along it, is what counts (#1366).
let mut levels = vec![Level { w, h, img, px: Cow::Borrowed(px) }];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
let thickness = hole_thickness(w, hb0, px);
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
if thin {
ext = thickness;
}
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
levels.push(d);
boxes.push(boxes[boxes.len() - 1].half());
ext = ext.div_ceil(2);
exts.push(ext);
}
let Pyramid { mut levels, boxes, exts, thin } = build_pyramid(Level { w, h, img, px: Cow::Borrowed(px) }, ch, hb0, psz, ctl)?;
let nlev = levels.len();
// The coarser level's result.
let mut prev: Option<Coarser> = None;
@@ -209,15 +187,7 @@ fn complete_cow<'a>(
return Ok(Err(limg));
}
// The level's own pixels to fill (at level 0 the caller's, or a copy made in parallel).
let mut limg = match limg {
Cow::Owned(v) => v,
Cow::Borrowed(b) => {
const CHUNK: usize = 1 << 16;
let mut v = vec![0.0f32; b.len()];
par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()]));
v
}
};
let mut limg = into_owned_pixels(limg);
let targets = targets_around(lw, r, &lpx, hb, tb);
ctl.check()?;
// Initialise the hole: at the coarsest level peeled from its edge inwards (or a membrane
@@ -225,40 +195,13 @@ fn complete_cow<'a>(
// its scaled-up matches.
let coarser = prev.take();
match &coarser {
Some(c) => {
let (pw, ph) = (c.w, c.h);
for y in hb.y0..hb.y1 {
for x in hb.x0..hb.x1 {
if lpx[y * lw + x] == Px::Hole {
let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1));
let (d, s) = ((y * lw + x) * ch, (sy * pw + sx) * ch);
limg[d..d + ch].copy_from_slice(&c.img[s..s + ch]);
}
}
}
}
Some(c) => copy_up_coarser(&mut limg, &lpx, lw, ch, hb, c),
// A thin hole, when peeling it would take too long, takes the membrane below: a few
// patches thick here, a membrane across it is its surroundings.
None if sources.len() > 0 && (!thin || peel_affordable(lw, &lpx, hb, sources.len())) => {
onion_peel(lw, lh, ch, ri, &mut limg, &lpx, hb, &sources.all(&valid, lw), ctl)?
}
None => {
let mut unknown: Vec<bool> = lpx.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
// Across a thin hole the membrane starts a patch radius out, past the
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
if thin {
let hole: Vec<bool> = lpx.iter().map(|k| *k == Px::Hole).collect();
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, lw, lh)) {
*u |= d <= r as f32;
}
}
let m = membrane_fill(lw, lh, ch, &limg, &unknown);
for (i, k) in lpx.iter().enumerate() {
if *k == Px::Hole {
limg[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]);
}
}
}
None => fill_hole_with_membrane(&mut limg, &lpx, (lw, lh, ch), r, thin),
}
if sources.len() == 0 {
prev = Some(Coarser { w: lw, h: lh, img: limg, field: Vec::new(), tbox: tb });
@@ -290,36 +233,7 @@ fn complete_cow<'a>(
// NNF init: the coarser level's matches scaled up where they land on a valid source,
// random otherwise.
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
let tw = tb.width();
let mut nnf = vec![(0i32, 0i32); tb.area()];
let mut upsampled = false;
for y in tb.y0..tb.y1 {
if y % 32 == 0 {
ctl.check()?;
}
for x in tb.x0..tb.x1 {
let k = (y - tb.y0) * tw + (x - tb.x0);
if !solver.targets[k] {
continue;
}
let mut cand = None;
if let Some(c) = &coarser
&& !c.field.is_empty()
{
let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1));
let (sx, sy) = c.field_at(cx, cy);
if sx != i32::MIN {
// The same displacement, twice as long.
let c = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32));
if solver.is_valid(c.0, c.1) {
cand = Some(c);
}
}
}
upsampled |= cand.is_some();
nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw));
}
}
let (mut nnf, upsampled) = seed_matches(&solver, coarser.as_ref(), &sources, &mut rng, lw, tb, ctl)?;
// The coarser level has given all it had.
drop(coarser);
let mut cost = vec![f32::INFINITY; tb.area()];
@@ -370,5 +284,138 @@ fn complete_cow<'a>(
Ok(prev.map(|c| c.img).ok_or(Cow::Owned(Vec::new())))
}
/// The levels of the completion, finest first, with the hole's bounds and extent at each.
struct Pyramid<'a> {
levels: Vec<Level<'a>>,
boxes: Vec<common::Bounds>,
exts: Vec<usize>,
/// The hole is a thin one: its thickness, not its length, decides how deep to go.
thin: bool,
}
/// Shrinks the image until the hole is about four patches across (a deeper pyramid decides the
/// structure where the texture has averaged out, and fills flat). A thin hole stops while it is
/// still about two patches thick: through it, not along it, is what counts (#1366).
fn build_pyramid<'a>(
first: Level<'a>,
ch: usize,
hb0: common::Bounds,
psz: usize,
ctl: &photocraft_raster::Interrupt,
) -> Result<Pyramid<'a>, photocraft_raster::Cancelled> {
let thickness = hole_thickness(first.w, hb0, &first.px);
let mut levels = vec![first];
let mut boxes = vec![hb0];
let mut ext = hb0.width().max(hb0.height());
let thin = thickness.saturating_mul(THIN_RATIO) <= ext;
if thin {
ext = thickness;
}
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= if thin { 2 * psz } else { 4 * psz } || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
let d = downsample(l, ch);
levels.push(d);
boxes.push(boxes[boxes.len() - 1].half());
ext = ext.div_ceil(2);
exts.push(ext);
}
Ok(Pyramid { levels, boxes, exts, thin })
}
/// The level's own pixels to fill: the caller's, or a copy made in parallel.
fn into_owned_pixels(img: Cow<'_, [f32]>) -> Vec<f32> {
match img {
Cow::Owned(v) => v,
Cow::Borrowed(b) => {
const CHUNK: usize = 1 << 16;
let mut v = vec![0.0f32; b.len()];
par::chunks_mut(&mut v, CHUNK, true, |i, d| d.copy_from_slice(&b[i * CHUNK..i * CHUNK + d.len()]));
v
}
}
}
/// Starts the hole from the coarser level's pixels, scaled up.
fn copy_up_coarser(img: &mut [f32], px: &[Px], w: usize, ch: usize, hb: common::Bounds, c: &Coarser) {
let (pw, ph) = (c.w, c.h);
for y in hb.y0..hb.y1 {
for x in hb.x0..hb.x1 {
if px[y * w + x] == Px::Hole {
let (sx, sy) = ((x / 2).min(pw - 1), (y / 2).min(ph - 1));
let (d, s) = ((y * w + x) * ch, (sy * pw + sx) * ch);
img[d..d + ch].copy_from_slice(&c.img[s..s + ch]);
}
}
}
}
/// Starts the hole as a membrane over its surroundings, for where nothing can be copied. `dims`
/// is the level's width, height and channel count.
fn fill_hole_with_membrane(img: &mut [f32], px: &[Px], dims: (usize, usize, usize), r: usize, thin: bool) {
let (w, h, ch) = dims;
let mut unknown: Vec<bool> = px.iter().map(|k| matches!(k, Px::Hole | Px::Void)).collect();
// Across a thin hole the membrane starts a patch radius out, past the
// anti-aliased fringe or halo that a selection drawn close to the object leaves.
if thin {
let hole: Vec<bool> = px.iter().map(|k| *k == Px::Hole).collect();
for (u, d) in unknown.iter_mut().zip(crate::selection::edt(&hole, w, h)) {
*u |= d <= r as f32;
}
}
let m = membrane_fill(w, h, ch, img, &unknown);
for (i, k) in px.iter().enumerate() {
if *k == Px::Hole {
img[i * ch..(i + 1) * ch].copy_from_slice(&m[i * ch..(i + 1) * ch]);
}
}
}
/// The coarser level's match for `(x, y)`, scaled up, where it lands on a valid source.
fn upscaled_match(solver: &Solver, c: &Coarser, x: usize, y: usize) -> Option<(i32, i32)> {
let (cx, cy) = ((x / 2).min(c.w - 1), (y / 2).min(c.h - 1));
let (sx, sy) = c.field_at(cx, cy);
if sx == i32::MIN {
return None;
}
// The same displacement, twice as long.
let m = (x as i32 + 2 * (sx - cx as i32), y as i32 + 2 * (sy - cy as i32));
solver.is_valid(m.0, m.1).then_some(m)
}
/// NNF init: the coarser level's matches scaled up where they land on a valid source, random
/// otherwise. Also says whether any came from the coarser level.
fn seed_matches(
solver: &Solver,
coarser: Option<&Coarser>,
sources: &Sources,
rng: &mut Rng,
lw: usize,
tb: common::Bounds,
ctl: &photocraft_raster::Interrupt,
) -> Result<(Vec<(i32, i32)>, bool), photocraft_raster::Cancelled> {
let tw = tb.width();
let mut nnf = vec![(0i32, 0i32); tb.area()];
let mut upsampled = false;
for y in tb.y0..tb.y1 {
if y % 32 == 0 {
ctl.check()?;
}
for x in tb.x0..tb.x1 {
let k = (y - tb.y0) * tw + (x - tb.x0);
if !solver.targets[k] {
continue;
}
let cand = coarser.filter(|c| !c.field.is_empty()).and_then(|c| upscaled_match(solver, c, x, y));
upsampled |= cand.is_some();
nnf[k] = cand.unwrap_or_else(|| sources.nth((rng.next() % sources.len() as u64) as usize, &solver.valid, lw));
}
}
Ok((nnf, upsampled))
}
#[cfg(test)]
mod tests;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::Cow;
/// Below this many cells a pass runs on the calling thread (thread hand-off costs more than it
/// saves). Every pass gives the same result either way.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::common::{Adapt, Bounds, PAR_MIN_PATCHES, Px, Rng};
use super::*;
use super::par;
/// Most channels whose moments the colour adaptation fits (the rest are copied as they are).
const MOM_CH: usize = 8;
+1 -1
View File
@@ -1,5 +1,5 @@
use super::common::{Bounds, Level, PAR_MIN, Px};
use super::*;
use super::{Cow, par};
/// Half-size level: a cell is a hole if any of its pixels is; else it averages its non-void
/// pixels, and may be copied from only if all of them may.
+1 -1
View File
@@ -1,7 +1,7 @@
use super::common::{Bounds, Px, Rng};
use super::solver::{Best, LANES, known_sse, known_sse_any, known_sse_lanes, least_sum};
use super::sources::{hole_bbox, integral, window_count};
use super::*;
use super::{HoleIntegral, membrane_fill, par};
/// Fill the hole from its edge inwards, one ring at a time: each pixel takes the centre of the
/// source patch that best matches what is already known around it (the first in `sources` of
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Normal, Placement, par_map, par_rows};
// ---------- photometric ----------
+86 -61
View File
@@ -1,6 +1,6 @@
//! Reuse the jittered lattice in cell space. Raster work stays in the original
//! row/candidate order, including f32 ties and boundary-cell average additions.
use super::*;
use super::{Ctx, Image, MAXC, Rect, cell_point, hash01, native, ncol, subtractive};
#[derive(Clone, Copy)]
struct Site {
@@ -348,76 +348,101 @@ fn pointillize_grid(src: &Image, out: Rect, ctx: &Ctx, cell: f32, background: [f
let mut owners = vec![u32::MAX; width];
for (row, pixels) in result.chunks_exact_mut(stride).enumerate() {
let y = out.y0.checked_add(i32::try_from(row).ok()?)?;
let fy = y as f32 + 0.5;
let cy = key(y, origin.1, cell);
depths.fill(-0.5);
owners.fill(u32::MAX);
if cell >= 32.0 && out.width() <= 2048 {
for &(cx, x0, x1) in &base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
certified_row(grid, &base_runs, out, cy, y, &mut owners, &mut depths)?;
} else {
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &(x0, x1)) in columns.iter().enumerate() {
if x0 >= x1 {
continue;
}
let i = row.checked_add(column)?;
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
continue;
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
continue;
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
}
}
}
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(&depths).zip(&owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
scan_row(grid, &columns, out, cy, y, &mut owners, &mut depths)?;
}
composite_row(pixels, &depths, &owners, grid, ctx, bg)?;
}
Some(result)
}
/// Resolve one output row's dot owners through the certified run spans.
fn certified_row(grid: &Grid, base_runs: &[(i32, i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
for &(cx, x0, x1) in base_runs {
let ids = grid.neighbours(cx, cy)?;
let start = usize::try_from(x0.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(x1.checked_sub(out.x0)?).ok()?;
certified_span::<true>(grid, &ids, x0, y, owners.get_mut(start..end)?, depths.get_mut(start..end)?)?;
}
Some(())
}
/// Resolve one output row's dot owners by scanning the 3x3 neighbouring cells.
fn scan_row(grid: &Grid, columns: &[(i32, i32)], out: Rect, cy: i32, y: i32, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let fy = y as f32 + 0.5;
for gy in cy.checked_sub(1)?..=cy.checked_add(1)? {
let row = usize::try_from(gy.checked_sub(grid.y0)?).ok()?.checked_mul(grid.width)?;
// Global gy/gx order is the original dy/dx tie order at every pixel.
for (column, &span) in columns.iter().enumerate() {
if span.0 >= span.1 {
continue;
}
let i = row.checked_add(column)?;
scan_site(grid, i, fy, span, out, owners, depths)?;
}
}
Some(())
}
/// Let one site claim the pixels of the current row it reaches within `[x0, x1)`.
fn scan_site(grid: &Grid, i: usize, fy: f32, (x0, x1): (i32, i32), out: Rect, owners: &mut [u32], depths: &mut [f32]) -> Option<()> {
let site = grid.sites.get(i)?;
let dy2 = (site.point.1 - fy).powi(2);
let reach = f64::from(site.radius) + 1.5;
let square = reach * reach - f64::from(dy2);
if square < 0.0 {
return Some(());
}
// The extra pixel covers subtraction/square/sqrt rounding for
// the bounded coordinates/radii above. The exact old depth
// comparison still decides every candidate that survives.
let reach_x = square.max(0.0).sqrt() + 1.0;
let lo = ((f64::from(site.point.0) - 0.5 - reach_x).floor() as i32).max(x0);
let hi = ((f64::from(site.point.0) - 0.5 + reach_x).ceil() as i32).min(x1);
if lo >= hi {
return Some(());
}
let start = usize::try_from(lo.checked_sub(out.x0)?).ok()?;
let end = usize::try_from(hi.checked_sub(out.x0)?).ok()?;
for (offset, (depth, owner)) in depths.get_mut(start..end)?.iter_mut().zip(owners.get_mut(start..end)?).enumerate() {
let fx = (lo + offset as i32) as f32 + 0.5;
let d = ((site.point.0 - fx).powi(2) + dy2).sqrt();
let candidate = site.radius - d;
if candidate > *depth {
*depth = candidate;
*owner = i as u32;
}
}
Some(())
}
/// Blend each pixel of a row with the background and its owning dot colour.
fn composite_row(pixels: &mut [f32], depths: &[f32], owners: &[u32], grid: &Grid, ctx: &Ctx, bg: [f32; 8]) -> Option<()> {
let n = pixels.len() / depths.len().max(1);
for ((pixel, &depth), &owner) in pixels.chunks_exact_mut(n).zip(depths).zip(owners) {
let a_orig = if ctx.alpha { *pixel.get(n - 1)? } else { 1.0 };
let mut canvas = bg;
if ctx.alpha {
*canvas.get_mut(n - 1)? = a_orig.max(bg.get(n - 1)?.min(1.0));
}
if owner != u32::MAX {
let i = owner as usize;
let k = (depth + 0.5).clamp(0.0, 1.0);
let dot = grid.sites.get(i)?;
for (c, value) in canvas.iter_mut().enumerate().take(n) {
*value += (*dot.color.get(c)? - *value) * k;
}
}
pixel.copy_from_slice(canvas.get(..n)?);
}
Some(())
}
/// The filters sample cell centres, not a dense neighbourhood. On ordinary
/// tiles read those sparse samples directly; keep only the original output
/// pixels for alpha and selection mixing. Crystallize boundary tiles still
+294 -230
View File
@@ -1236,256 +1236,320 @@ fn star(out: &mut Vec<Prim>, c: (f32, f32), r: f32, points: usize, col: [f32; 4]
}
}
/// Builds picture-frame primitives inside `canvas`.
pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
let mut out = Vec::new();
let short = (canvas.width().min(canvas.height()) as f32).max(8.0);
let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0;
let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05);
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
let inset = margin + unit;
let b = Border {
x0: canvas.x0 as f32 + inset,
y0: canvas.y0 as f32 + inset,
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
};
let total = b.len();
let count = (total / spacing).round().max(4.0) as usize;
let step = total / count as f32;
let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64);
let (vine, fl, lf) = (spec.vine_color, spec.flower_color, spec.leaf_color);
let style = FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize];
let lines = spec.lines.clamp(1, 5) as usize;
let white = [1.0, 1.0, 1.0, 1.0];
let yellow = [0.98, 0.82, 0.2, 1.0];
// A wavy vine along the border.
let vine_path = |out: &mut Vec<Prim>, amp: f32, wl: f32, col: [f32; 4], r: f32| {
let n = (total / 3.0).ceil().max(8.0) as usize;
/// Resolved geometry and palette shared by every picture-frame style.
struct FrameLayout {
b: Border,
unit: f32,
thick: f32,
spacing: f32,
total: f32,
count: usize,
step: f32,
lines: usize,
vine: [f32; 4],
fl: [f32; 4],
lf: [f32; 4],
style: &'static str,
}
const FRAME_WHITE: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const FRAME_YELLOW: [f32; 4] = [0.98, 0.82, 0.2, 1.0];
fn wave_off(s: f32, amp: f32, wl: f32) -> f32 {
amp * (s / wl * std::f32::consts::TAU).sin()
}
impl FrameLayout {
fn new(spec: &FrameSpec, canvas: Rect) -> Self {
let short = (canvas.width().min(canvas.height()) as f32).max(8.0);
let margin = short * spec.margin.clamp(0.0, 30.0) / 100.0;
let unit = short * (0.008 + spec.size.clamp(1.0, 100.0) / 100.0 * 0.05);
let thick = (unit * 0.04 + short * 0.00012 * spec.thickness.clamp(1.0, 100.0)).max(0.6);
let spacing = unit * (4.0 - 3.2 * spec.arrangement.clamp(1.0, 100.0) / 100.0);
let inset = margin + unit;
let b = Border {
x0: canvas.x0 as f32 + inset,
y0: canvas.y0 as f32 + inset,
w: (canvas.width() as f32 - 2.0 * inset).max(1.0),
h: (canvas.height() as f32 - 2.0 * inset).max(1.0),
};
let total = b.len();
let count = (total / spacing).round().max(4.0) as usize;
Self {
b,
unit,
thick,
spacing,
total,
count,
step: total / count as f32,
lines: spec.lines.clamp(1, 5) as usize,
vine: spec.vine_color,
fl: spec.flower_color,
lf: spec.leaf_color,
style: FRAME_STYLES[(spec.frame.clamp(1, FRAME_STYLES.len() as u32) - 1) as usize],
}
}
/// A wavy vine along the border.
fn vine_path(&self, out: &mut Vec<Prim>, amp: f32, wl: f32, col: [f32; 4], r: f32) {
let n = (self.total / 3.0).ceil().max(8.0) as usize;
let mut prev: Option<(f32, f32)> = None;
for k in 0..=n {
let s = k as f32 / n as f32 * total;
let (p, _, nn) = b.at(s);
let o = amp * (s / wl * std::f32::consts::TAU).sin();
let s = k as f32 / n as f32 * self.total;
let (p, _, nn) = self.b.at(s);
let o = wave_off(s, amp, wl);
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
if let Some(pp) = prev {
out.push(Prim::capsule(pp, q, r, r, col, col));
}
prev = Some(q);
}
};
let wave_off = |s: f32, amp: f32, wl: f32| amp * (s / wl * std::f32::consts::TAU).sin();
match style {
"vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => {
let amp = unit * 0.5;
let wl = step * 2.0;
vine_path(&mut out, amp, wl, vine, thick);
for k in 0..count {
let s = k as f32 * step + step * 0.5;
let (p, t, nn) = b.at(s);
let o = wave_off(s, amp, wl);
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
let side = if k % 2 == 0 { 1.0 } else { -1.0 };
let ta = t.1.atan2(t.0);
let la = ta + side * 0.9 + rng.sym() * 0.3;
let ll = unit * rng.range(0.9, 1.2);
match style {
"vineWithFlowers" => {
leaf(&mut out, q, la, ll, lf);
if k % 2 == 0 {
let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side);
flower(&mut out, c, unit * 0.65, 5, fl, yellow, rng.f() * 6.3);
}
}
"vineWithLeaves" => {
leaf(&mut out, q, la, ll, lf);
leaf(&mut out, q, ta - side * 0.9, ll * 0.8, lf);
}
"ivy" => {
for j in 0..3 {
let a = la + (j as f32 - 1.0) * 0.7;
leaf(&mut out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32));
}
}
"roses" => {
leaf(&mut out, q, la, ll, lf);
if k % 3 == 0 {
let r = unit * 0.7;
for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() {
let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32);
out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55)));
}
}
}
"daisies" => {
leaf(&mut out, q, la, ll * 0.8, lf);
if k % 2 == 1 {
flower(&mut out, q, unit * 0.6, 12, white, yellow, rng.f());
}
}
_ => {
leaf(&mut out, q, la, ll, lf);
for _ in 0..3 {
let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4);
out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6)));
}
}
}
}
}
"bamboo" => {
for edge in 0..4 {
let (s0, l) = match edge {
0 => (0.0, b.w),
1 => (b.w, b.h),
2 => (b.w + b.h, b.w),
_ => (2.0 * b.w + b.h, b.h),
};
let (a, _, _) = b.at(s0 + 0.01);
let (z, _, _) = b.at(s0 + l - 0.01);
let joints = (l / (spacing * 2.0)).round().max(1.0) as usize;
for j in 0..joints {
let f0 = j as f32 / joints as f32;
let f1 = (j + 1) as f32 / joints as f32;
let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0);
let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1);
out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8)));
out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7)));
}
// A few leaves per edge.
for _ in 0..(joints / 2).max(1) {
let f = rng.f();
let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f);
leaf(&mut out, p, rng.f() * 6.3, unit * 1.4, lf);
}
}
}
"waves" | "zigzag" | "doubleLine" | "rope" => {
for li in 0..lines {
let off = (li as f32 - (lines - 1) as f32 / 2.0) * thick * 4.0;
let n = (total / 2.0).ceil().max(8.0) as usize;
let mut prev: Option<(f32, f32)> = None;
for k in 0..=n {
let s = k as f32 / n as f32 * total;
let (p, _, nn) = b.at(s);
let o = off
+ match style {
"waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step),
"zigzag" => {
let ph = (s / step).fract();
unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0)
}
_ => 0.0,
};
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
if style == "rope" {
if k % 2 == 0 {
let (_, t, _) = b.at(s);
out.push(Prim::ellipse(
q,
unit * 0.45,
unit * 0.2,
t.1.atan2(t.0) + 0.7,
shade([vine[0], vine[1], vine[2]], 1.1),
shade([vine[0], vine[1], vine[2]], 0.65),
));
}
} else if let Some(pp) = prev {
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
}
prev = Some(q);
}
if style == "doubleLine" {
// A second rule inside each line and corner squares.
let inner = Border {
x0: b.x0 + unit * 0.6 + off,
y0: b.y0 + unit * 0.6 + off,
w: b.w - 2.0 * (unit * 0.6 + off),
h: b.h - 2.0 * (unit * 0.6 + off),
};
for e in 0..4 {
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
out.push(Prim::capsule(p, q, thick * 0.6, thick * 0.6, vine, vine));
}
}
}
if style == "doubleLine" {
for e in 0..4 {
let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]);
out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, fl, fl));
}
}
}
"dots" | "scallops" | "snowflakes" | "stars" | "hearts" => {
for k in 0..count {
let s = k as f32 * step;
let (p, t, nn) = b.at(s);
let ta = t.1.atan2(t.0);
match style {
"dots" => {
let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 };
out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75)));
}
"scallops" => {
let (c0, _, _) = b.at(s - step * 0.5);
let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0);
let n = 8;
for j in 0..n {
let a0 = std::f32::consts::PI * j as f32 / n as f32;
let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32;
let r = step * 0.5;
let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin());
out.push(Prim::capsule(pt(a0), pt(a1), thick, thick, vine, vine));
}
}
"snowflakes" => {
let r = unit * 0.6;
let c = [0.85, 0.92, 1.0, 1.0];
for j in 0..6 {
let a = ta + j as f32 * std::f32::consts::FRAC_PI_3;
let e = (p.0 + a.cos() * r, p.1 + a.sin() * r);
out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c));
let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6);
for sd in [-0.7f32, 0.7] {
let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3);
out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c));
}
}
}
"stars" => star(&mut out, p, unit * 0.55, 5, if k % 2 == 0 { yellow } else { fl }, ta - std::f32::consts::FRAC_PI_2),
_ => {
// Heart: two lobes and a point towards the inside.
let r = unit * 0.3;
let (tx, ty) = t;
let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3);
let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3);
let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6);
out.push(Prim::capsule(l, tip, r, 0.6, fl, fl));
out.push(Prim::capsule(rr, tip, r, 0.6, fl, fl));
}
}
}
}
_ => {}
}
// Fade: lower the opacity of ornaments that reach further inside.
let fade = spec.fade.clamp(0.0, 100.0) / 100.0;
if fade > 0.0 {
/// Vine styles: a wavy stem with a leaf cluster or bloom at each step.
fn vine_styles(&self, out: &mut Vec<Prim>, rng: &mut Rng) {
let (unit, step, fl, lf) = (self.unit, self.step, self.fl, self.lf);
let amp = unit * 0.5;
let wl = step * 2.0;
self.vine_path(out, amp, wl, self.vine, self.thick);
for k in 0..self.count {
let s = k as f32 * step + step * 0.5;
let (p, t, nn) = self.b.at(s);
let o = wave_off(s, amp, wl);
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
let side = if k % 2 == 0 { 1.0 } else { -1.0 };
let ta = t.1.atan2(t.0);
let la = ta + side * 0.9 + rng.sym() * 0.3;
let ll = unit * rng.range(0.9, 1.2);
match self.style {
"vineWithFlowers" => {
leaf(out, q, la, ll, lf);
if k % 2 == 0 {
let c = (q.0 + nn.0 * unit * 0.7 * side, q.1 + nn.1 * unit * 0.7 * side);
flower(out, c, unit * 0.65, 5, fl, FRAME_YELLOW, rng.f() * 6.3);
}
}
"vineWithLeaves" => {
leaf(out, q, la, ll, lf);
leaf(out, q, ta - side * 0.9, ll * 0.8, lf);
}
"ivy" => {
for j in 0..3 {
let a = la + (j as f32 - 1.0) * 0.7;
leaf(out, q, a, ll * 0.75, shade([lf[0], lf[1], lf[2]], 0.75 + 0.15 * j as f32));
}
}
"roses" => {
leaf(out, q, la, ll, lf);
if k % 3 == 0 {
let r = unit * 0.7;
for (j, f) in [1.0f32, 0.75, 0.5, 0.28].iter().enumerate() {
let c = shade([fl[0], fl[1], fl[2]], 0.75 + 0.1 * j as f32);
out.push(Prim::ellipse(q, r * f, r * f * 0.9, rng.f() * 3.0, c, shade([fl[0], fl[1], fl[2]], 0.55)));
}
}
}
"daisies" => {
leaf(out, q, la, ll * 0.8, lf);
if k % 2 == 1 {
flower(out, q, unit * 0.6, 12, FRAME_WHITE, FRAME_YELLOW, rng.f());
}
}
_ => {
leaf(out, q, la, ll, lf);
for _ in 0..3 {
let c = (q.0 + rng.sym() * unit * 0.4, q.1 + rng.sym() * unit * 0.4);
out.push(Prim::ellipse(c, unit * 0.2, unit * 0.2, 0.0, shade([fl[0], fl[1], fl[2]], 1.2), shade([fl[0], fl[1], fl[2]], 0.6)));
}
}
}
}
}
/// Bamboo: jointed canes along each edge with a few leaves.
fn bamboo(&self, out: &mut Vec<Prim>, rng: &mut Rng) {
let (b, unit, vine) = (&self.b, self.unit, self.vine);
for edge in 0..4 {
let (s0, l) = match edge {
0 => (0.0, b.w),
1 => (b.w, b.h),
2 => (b.w + b.h, b.w),
_ => (2.0 * b.w + b.h, b.h),
};
let (a, _, _) = b.at(s0 + 0.01);
let (z, _, _) = b.at(s0 + l - 0.01);
let joints = (l / (self.spacing * 2.0)).round().max(1.0) as usize;
for j in 0..joints {
let f0 = j as f32 / joints as f32;
let f1 = (j + 1) as f32 / joints as f32;
let p0 = (a.0 + (z.0 - a.0) * f0, a.1 + (z.1 - a.1) * f0);
let p1 = (a.0 + (z.0 - a.0) * f1, a.1 + (z.1 - a.1) * f1);
out.push(Prim::capsule(p0, p1, unit * 0.35, unit * 0.35, vine, shade([vine[0], vine[1], vine[2]], 0.8)));
out.push(Prim::disc(p1, unit * 0.4, shade([vine[0], vine[1], vine[2]], 0.7)));
}
// A few leaves per edge.
for _ in 0..(joints / 2).max(1) {
let f = rng.f();
let p = (a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f);
leaf(out, p, rng.f() * 6.3, unit * 1.4, self.lf);
}
}
}
/// Continuous rule styles: waves, zigzag, double line and rope.
fn rule_styles(&self, out: &mut Vec<Prim>) {
let (b, unit, thick, step, vine) = (&self.b, self.unit, self.thick, self.step, self.vine);
for li in 0..self.lines {
let off = (li as f32 - (self.lines - 1) as f32 / 2.0) * thick * 4.0;
let n = (self.total / 2.0).ceil().max(8.0) as usize;
let mut prev: Option<(f32, f32)> = None;
for k in 0..=n {
let s = k as f32 / n as f32 * self.total;
let (p, _, nn) = b.at(s);
let o = off
+ match self.style {
"waves" => wave_off(s + li as f32 * step * 0.25, unit * 0.4, step),
"zigzag" => {
let ph = (s / step).fract();
unit * 0.4 * (4.0 * (ph - 0.5).abs() - 1.0)
}
_ => 0.0,
};
let q = (p.0 + nn.0 * o, p.1 + nn.1 * o);
if self.style == "rope" {
if k % 2 == 0 {
let (_, t, _) = b.at(s);
out.push(Prim::ellipse(
q,
unit * 0.45,
unit * 0.2,
t.1.atan2(t.0) + 0.7,
shade([vine[0], vine[1], vine[2]], 1.1),
shade([vine[0], vine[1], vine[2]], 0.65),
));
}
} else if let Some(pp) = prev {
out.push(Prim::capsule(pp, q, thick, thick, vine, vine));
}
prev = Some(q);
}
if self.style == "doubleLine" {
self.inner_rule(out, off);
}
}
if self.style == "doubleLine" {
for e in 0..4 {
let (p, _, _) = b.at([0.0, b.w, b.w + b.h, 2.0 * b.w + b.h][e]);
out.push(Prim::ellipse(p, unit * 0.45, unit * 0.45, std::f32::consts::FRAC_PI_4, self.fl, self.fl));
}
}
}
/// A second rule inside each double line.
fn inner_rule(&self, out: &mut Vec<Prim>, off: f32) {
let (b, unit) = (&self.b, self.unit);
let inner = Border { x0: b.x0 + unit * 0.6 + off, y0: b.y0 + unit * 0.6 + off, w: b.w - 2.0 * (unit * 0.6 + off), h: b.h - 2.0 * (unit * 0.6 + off) };
for e in 0..4 {
let (p, _, _) = inner.at([0.0, inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h][e]);
let (q, _, _) = inner.at([inner.w, inner.w + inner.h, 2.0 * inner.w + inner.h, inner.len() - 0.001][e]);
out.push(Prim::capsule(p, q, self.thick * 0.6, self.thick * 0.6, self.vine, self.vine));
}
}
/// Evenly spaced motif styles: dots, scallops, snowflakes, stars and hearts.
fn motif_styles(&self, out: &mut Vec<Prim>) {
let (unit, fl) = (self.unit, self.fl);
for k in 0..self.count {
let s = k as f32 * self.step;
let (p, t, nn) = self.b.at(s);
let ta = t.1.atan2(t.0);
match self.style {
"dots" => {
let r = unit * if k % 2 == 0 { 0.4 } else { 0.22 };
out.push(Prim::ellipse(p, r, r, 0.0, shade([fl[0], fl[1], fl[2]], 1.15), shade([fl[0], fl[1], fl[2]], 0.75)));
}
"scallops" => self.scallop(out, s, p, t, nn),
"snowflakes" => self.snowflake(out, p, ta),
"stars" => star(out, p, unit * 0.55, 5, if k % 2 == 0 { FRAME_YELLOW } else { fl }, ta - std::f32::consts::FRAC_PI_2),
_ => self.heart(out, p, t, nn),
}
}
}
fn scallop(&self, out: &mut Vec<Prim>, s: f32, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) {
let (c0, _, _) = self.b.at(s - self.step * 0.5);
let mid = ((c0.0 + p.0) / 2.0, (c0.1 + p.1) / 2.0);
let n = 8;
for j in 0..n {
let a0 = std::f32::consts::PI * j as f32 / n as f32;
let a1 = std::f32::consts::PI * (j + 1) as f32 / n as f32;
let r = self.step * 0.5;
let pt = |a: f32| (mid.0 - t.0 * r * a.cos() - nn.0 * r * a.sin(), mid.1 - t.1 * r * a.cos() - nn.1 * r * a.sin());
out.push(Prim::capsule(pt(a0), pt(a1), self.thick, self.thick, self.vine, self.vine));
}
}
fn snowflake(&self, out: &mut Vec<Prim>, p: (f32, f32), ta: f32) {
let thick = self.thick;
let r = self.unit * 0.6;
let c = [0.85, 0.92, 1.0, 1.0];
for j in 0..6 {
let a = ta + j as f32 * std::f32::consts::FRAC_PI_3;
let e = (p.0 + a.cos() * r, p.1 + a.sin() * r);
out.push(Prim::capsule(p, e, (thick * 0.6).max(0.6), (thick * 0.4).max(0.5), c, c));
let m = (p.0 + a.cos() * r * 0.6, p.1 + a.sin() * r * 0.6);
for sd in [-0.7f32, 0.7] {
let b2 = (m.0 + (a + sd).cos() * r * 0.3, m.1 + (a + sd).sin() * r * 0.3);
out.push(Prim::capsule(m, b2, (thick * 0.4).max(0.5), 0.4, c, c));
}
}
}
/// Heart: two lobes and a point towards the inside.
fn heart(&self, out: &mut Vec<Prim>, p: (f32, f32), t: (f32, f32), nn: (f32, f32)) {
let r = self.unit * 0.3;
let (tx, ty) = t;
let l = (p.0 - tx * r * 0.8 - nn.0 * r * 0.3, p.1 - ty * r * 0.8 - nn.1 * r * 0.3);
let rr = (p.0 + tx * r * 0.8 - nn.0 * r * 0.3, p.1 + ty * r * 0.8 - nn.1 * r * 0.3);
let tip = (p.0 + nn.0 * r * 1.6, p.1 + nn.1 * r * 1.6);
out.push(Prim::capsule(l, tip, r, 0.6, self.fl, self.fl));
out.push(Prim::capsule(rr, tip, r, 0.6, self.fl, self.fl));
}
/// Fade: lower the opacity of ornaments that reach further inside.
fn apply_fade(&self, out: &mut [Prim], fade: f32, canvas: Rect) {
let cx = canvas.x0 as f32 + canvas.width() as f32 / 2.0;
let cy = canvas.y0 as f32 + canvas.height() as f32 / 2.0;
for p in &mut out {
for p in out {
let c = match p.shape {
Shape::Capsule { a, .. } => a,
Shape::Ellipse { c, .. } => c,
};
let edge = ((c.0 - cx).abs() / (b.w / 2.0)).max((c.1 - cy).abs() / (b.h / 2.0)).clamp(0.0, 1.0);
let edge = ((c.0 - cx).abs() / (self.b.w / 2.0)).max((c.1 - cy).abs() / (self.b.h / 2.0)).clamp(0.0, 1.0);
let k = 1.0 - fade * (1.0 - edge) * 8.0;
p.c0[3] *= k.clamp(0.0, 1.0);
p.c1[3] *= k.clamp(0.0, 1.0);
}
}
}
/// Builds picture-frame primitives inside `canvas`.
pub fn picture_frame(spec: &FrameSpec, canvas: Rect) -> Vec<Prim> {
let mut out = Vec::new();
let layout = FrameLayout::new(spec, canvas);
let mut rng = Rng::new(spec.seed, 0xF8A3E + spec.frame as u64);
match layout.style {
"vineWithFlowers" | "vineWithLeaves" | "ivy" | "roses" | "daisies" | "berries" => layout.vine_styles(&mut out, &mut rng),
"bamboo" => layout.bamboo(&mut out, &mut rng),
"waves" | "zigzag" | "doubleLine" | "rope" => layout.rule_styles(&mut out),
"dots" | "scallops" | "snowflakes" | "stars" | "hearts" => layout.motif_styles(&mut out),
_ => {}
}
let fade = spec.fade.clamp(0.0, 100.0) / 100.0;
if fade > 0.0 {
layout.apply_fade(&mut out, fade, canvas);
}
out
}
+86 -51
View File
@@ -51,63 +51,14 @@ impl Prepared {
if prefix_len > MAX_SAMPLES || output_len > MAX_SAMPLES {
return None;
}
let mut prefix = Vec::new();
prefix.try_reserve_exact(prefix_len).ok()?;
prefix.resize(prefix_len, 0.0f64);
if input_row != 0 {
for (s, d) in src.data.chunks_exact(input_row).zip(prefix.chunks_exact_mut(stride)) {
let mut sum = [0.0f64; crate::fxutil::MAXC];
for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) {
let alpha = if ctx.alpha { *px.last()? } else { 1.0 };
for (c, ((v, sum), dst)) in px.iter().zip(sum.iter_mut()).zip(dst).enumerate() {
// Multiplication stays f32, exactly as premul_window.
let v = if ctx.alpha && c + 1 < n { *v * alpha } else { *v };
*sum += f64::from(v);
*dst = *sum;
}
}
}
}
let prefix = Prefix::build(src, ctx, n, stride, prefix_len)?;
let mut result = Vec::new();
result.try_reserve_exact(output_len).ok()?;
let mut acc = Vec::new();
acc.try_reserve_exact(row_len).ok()?;
acc.resize(row_len, 0.0f64);
let x = i64::from(out.x0) - i64::from(src.rect.x0);
for y in out.y0..out.y1 {
acc.fill(0.0);
for &(dy, a, b) in &self.spans.rows {
let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy);
let Ok(yy) = usize::try_from(yy) else {
continue;
};
if yy >= h {
continue;
}
let row = prefix.get(yy.checked_mul(stride)?..yy.checked_add(1)?.checked_mul(stride)?)?;
let lo = x + i64::from(a);
let hi = x + i64::from(b) + 1;
let end = out.width() as i64;
if lo >= 0 && hi + end <= w as i64 {
let start_lo = usize::try_from(lo).ok()?.checked_mul(n)?;
let start_hi = usize::try_from(hi).ok()?.checked_mul(n)?;
let low = row.get(start_lo..start_lo.checked_add(row_len)?)?;
let high = row.get(start_hi..start_hi.checked_add(row_len)?)?;
for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) {
*acc += hi - lo;
}
} else {
// Direct kernel callers may omit the halo or request pixels outside src.
// Retain the scalar kernel's zero extension and fixed normalization.
for (i, px) in acc.chunks_exact_mut(n).enumerate() {
let low = (lo + i as i64).clamp(0, w as i64) as usize * n;
let high = (hi + i as i64).clamp(0, w as i64) as usize * n;
for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) {
*acc += hi - lo;
}
}
}
}
self.accumulate_row(&prefix, src, out, y, &mut acc)?;
for px in acc.chunks_exact(n) {
let mut sample = [0.0f32; crate::fxutil::MAXC];
let sample = sample.get_mut(..n)?;
@@ -120,4 +71,88 @@ impl Prepared {
}
Some(result)
}
/// Sum every kernel row's window for output row `y` into `acc`.
fn accumulate_row(&self, prefix: &Prefix, src: &Image, out: Rect, y: i32, acc: &mut [f64]) -> Option<()> {
let x = i64::from(out.x0) - i64::from(src.rect.x0);
acc.fill(0.0);
for &(dy, a, b) in &self.spans.rows {
let yy = i64::from(y) - i64::from(src.rect.y0) + i64::from(dy);
let Ok(yy) = usize::try_from(yy) else {
continue;
};
if yy >= prefix.h {
continue;
}
let row = prefix.row(yy)?;
prefix.add_window(row, acc, x + i64::from(a), x + i64::from(b) + 1, out.width() as i64)?;
}
Some(())
}
}
/// Per-row running channel sums (premultiplied when alpha is present), one
/// extra leading column per row so any window is a difference of two entries.
struct Prefix {
data: Vec<f64>,
stride: usize,
n: usize,
w: usize,
h: usize,
}
impl Prefix {
fn build(src: &Image, ctx: &Ctx, n: usize, stride: usize, len: usize) -> Option<Self> {
let w = src.rect.width() as usize;
let h = src.rect.height() as usize;
let input_row = w.checked_mul(n)?;
let mut data = Vec::new();
data.try_reserve_exact(len).ok()?;
data.resize(len, 0.0f64);
if input_row != 0 {
for (s, d) in src.data.chunks_exact(input_row).zip(data.chunks_exact_mut(stride)) {
let mut sum = [0.0f64; crate::fxutil::MAXC];
for (px, dst) in s.chunks_exact(n).zip(d.get_mut(n..)?.chunks_exact_mut(n)) {
let alpha = if ctx.alpha { *px.last()? } else { 1.0 };
for (c, ((v, sum), dst)) in px.iter().zip(sum.iter_mut()).zip(dst).enumerate() {
// Multiplication stays f32, exactly as premul_window.
let v = if ctx.alpha && c + 1 < n { *v * alpha } else { *v };
*sum += f64::from(v);
*dst = *sum;
}
}
}
}
Some(Self { data, stride, n, w, h })
}
fn row(&self, y: usize) -> Option<&[f64]> {
self.data.get(y.checked_mul(self.stride)?..y.checked_add(1)?.checked_mul(self.stride)?)
}
/// Add the `[lo, hi)` window difference of `row` for `acc.len() / n` output pixels.
fn add_window(&self, row: &[f64], acc: &mut [f64], lo: i64, hi: i64, end: i64) -> Option<()> {
let (n, w) = (self.n, self.w);
let row_len = acc.len();
if lo >= 0 && hi + end <= w as i64 {
let start_lo = usize::try_from(lo).ok()?.checked_mul(n)?;
let start_hi = usize::try_from(hi).ok()?.checked_mul(n)?;
let low = row.get(start_lo..start_lo.checked_add(row_len)?)?;
let high = row.get(start_hi..start_hi.checked_add(row_len)?)?;
for ((acc, hi), lo) in acc.iter_mut().zip(high).zip(low) {
*acc += hi - lo;
}
} else {
// Direct kernel callers may omit the halo or request pixels outside src.
// Retain the scalar kernel's zero extension and fixed normalization.
for (i, px) in acc.chunks_exact_mut(n).enumerate() {
let low = (lo + i as i64).clamp(0, w as i64) as usize * n;
let high = (hi + i as i64).clamp(0, w as i64) as usize * n;
for ((acc, hi), lo) in px.iter_mut().zip(row.get(high..high + n)?).zip(row.get(low..low + n)?) {
*acc += hi - lo;
}
}
}
Some(())
}
}
+69 -33
View File
@@ -77,58 +77,94 @@ pub(crate) fn run(src: &Image, out: Rect, ctx: &Ctx, amount: f32, radius: f32, t
let row_len = ow.checked_mul(n)?;
let th = oh.checked_add(2 * r)?;
let ww = ow.checked_add(2 * r)?;
let (x0, x1, y0) = (out.x0.checked_sub(r as i32)?, out.x1.checked_add(r as i32)?, out.y0.checked_sub(r as i32)?);
let pass = Pass { src, out, ctx, kernel: &kernel, ctl, n, row_len };
let mut row = zeros(ww.checked_mul(n)?)?;
let mut tmp = zeros(row_len.checked_mul(th)?)?;
let mut result = zeros(row_len.checked_mul(oh)?)?;
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
read_row(src, x0, x1, y0.checked_add(y as i32)?, &mut row)?;
premultiply(&mut row, n, ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
pass.horizontal(&mut row, &mut tmp)?;
pass.vertical(&tmp, &mut row, &mut result, amount / 100.0, threshold / 255.0)?;
Some(result)
}
/// The shared inputs of the two separable blur passes.
struct Pass<'a> {
src: &'a Image,
out: Rect,
ctx: &'a Ctx,
kernel: &'a [f32],
ctl: &'a Interrupt<'a>,
n: usize,
row_len: usize,
}
impl Pass<'_> {
/// Blur horizontally every source row the output (plus kernel halo) needs.
fn horizontal(&self, row: &mut [f32], tmp: &mut [f32]) -> Option<()> {
let (n, row_len) = (self.n, self.row_len);
let r = self.kernel.len() / 2;
let (x0, x1, y0) = (self.out.x0.checked_sub(r as i32)?, self.out.x1.checked_add(r as i32)?, self.out.y0.checked_sub(r as i32)?);
for (y, dst) in tmp.chunks_exact_mut(row_len).enumerate() {
if self.ctl.cancelled() {
return None;
}
read_row(self.src, x0, x1, y0.checked_add(y as i32)?, row)?;
premultiply(row, n, self.ctx.alpha);
// Tap-major traversal exposes contiguous independent samples to LLVM. No
// reduction, tap merging, symmetry or fused multiply-add changes the sums.
for (i, weight) in self.kernel.iter().enumerate() {
let start = i.checked_mul(n)?;
let samples = row.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
}
Some(())
}
let (amount, threshold) = (amount / 100.0, threshold / 255.0);
let cc = n - usize::from(ctx.alpha);
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if ctl.cancelled() {
return None;
}
for (i, weight) in kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
/// Blur vertically and blend each output row against its original pixels.
/// `originals` is scratch space reused for the source row being compared.
fn vertical(&self, tmp: &[f32], originals: &mut [f32], result: &mut [f32], amount: f32, threshold: f32) -> Option<()> {
let row_len = self.row_len;
for (y, dst) in result.chunks_exact_mut(row_len).enumerate() {
if self.ctl.cancelled() {
return None;
}
for (i, weight) in self.kernel.iter().enumerate() {
let start = y.checked_add(i)?.checked_mul(row_len)?;
let samples = tmp.get(start..start.checked_add(row_len)?)?;
for (d, s) in dst.iter_mut().zip(samples) {
*d += s * weight;
}
}
let originals = originals.get_mut(..row_len)?;
read_row(self.src, self.out.x0, self.out.x1, self.out.y0.checked_add(y as i32)?, originals)?;
self.sharpen_row(dst, originals, amount, threshold)?;
}
let originals = row.get_mut(..row_len)?;
read_row(src, out.x0, out.x1, out.y0.checked_add(y as i32)?, originals)?;
for (blurred, original) in dst.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if ctx.alpha && *original.last()? <= 0.0 {
Some(())
}
/// Replace each blurred pixel by the thresholded unsharp-mask value.
fn sharpen_row(&self, blurred_row: &mut [f32], originals: &[f32], amount: f32, threshold: f32) -> Option<()> {
let (n, alpha) = (self.n, self.ctx.alpha);
let cc = n - usize::from(alpha);
for (blurred, original) in blurred_row.chunks_exact_mut(n).zip(originals.chunks_exact(n)) {
if alpha && *original.last()? <= 0.0 {
blurred.copy_from_slice(original);
continue;
}
let a = if ctx.alpha { *blurred.last()? } else { 1.0 };
let a = if alpha { *blurred.last()? } else { 1.0 };
for (b, o) in blurred.iter_mut().zip(original).take(cc) {
// Keep the original unpremultiply cut-off and division, followed by
// the threshold epsilon and inclusive comparison, at every depth.
let b_value = if ctx.alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let b_value = if alpha { if a > 1e-7 { *b / a } else { 0.0 } } else { *b };
let d = o - b_value;
*b = if d.abs() + 1e-6 >= threshold { o + amount * d } else { *o };
}
if ctx.alpha {
if alpha {
*blurred.last_mut()? = *original.last()?;
}
}
Some(())
}
Some(result)
}
+164 -93
View File
@@ -128,9 +128,9 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio
let resident = samples.checked_mul(4)?;
let mut planner = FftPlanner::<f64>::new();
let horizontal = Plan::new(w.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
let vertical_plan = Plan::new(h.checked_add(2 * r)?, &kernel, &mut planner)?;
// A conservative allowance includes FFT plans/twiddles in addition to spectra.
let plans = horizontal.size.checked_add(vertical.size)?.checked_mul(256)?;
let plans = horizontal.size.checked_add(vertical_plan.size)?.checked_mul(256)?;
let resident = resident.checked_add(plans)?;
let workers = rayon::current_num_threads().max(1);
let horizontal_worker = read_width.checked_mul(n)?.checked_mul(4)?.checked_add(horizontal.size.checked_add(horizontal.scratch)?.checked_mul(16)?)?;
@@ -139,7 +139,7 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio
.checked_mul(n)?
.checked_mul(4)?
.checked_add(STRIPE.checked_mul(h)?.checked_mul(n)?.checked_mul(12)?)?
.checked_add(vertical.size.checked_add(vertical.scratch)?.checked_mul(16)?)?;
.checked_add(vertical_plan.size.checked_add(vertical_plan.scratch)?.checked_mul(16)?)?;
let group = group_size(resident, horizontal_worker.max(vertical_worker), w.div_ceil(STRIPE), workers)?;
let mut tmp = buffer(samples, 0.0f32)?;
let finished = AtomicUsize::new(0);
@@ -210,98 +210,29 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio
}
let mut out = surface.clone();
let count = w.div_ceil(STRIPE);
let vertical = VerticalPass {
surface,
selection,
tmp: &tmp,
plan: &vertical_plan,
ctl,
area,
n,
w,
h,
rows,
row_len,
r,
y0,
read_y0,
read_y1,
alpha,
amount,
kernel_len: kernel.len(),
};
for first in (0..count).step_by(group) {
let end = first.checked_add(group)?.min(count);
let results: Option<Vec<_>> = (first..end)
.into_par_iter()
.map(|index| {
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(w.checked_sub(x)?);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in tmp.chunks_exact(row_len).enumerate() {
if y % 64 == 0 && ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
let rect = Rect::new(area.x0.checked_add(x as i32)?, area.y0, area.x0.checked_add((x + width) as i32)?, area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = vertical.work()?;
for (c, samples) in columns.chunks_exact(rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = y0.checked_add(i as i32)?.clamp(read_y0, read_y1.checked_sub(1)?);
let start = usize::try_from(sy - read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
let sample = |c: usize| -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
};
*d = Number::new(sample(channel), sample(channel + 1));
}
vertical.convolve(&mut data, &mut scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
}
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * kernel.len() as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
if let Some(sel) = selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, alpha);
}
Some((rect, result))
})
.collect();
let results: Option<Vec<_>> = (first..end).into_par_iter().map(|index| vertical.stripe(index)).collect();
for (rect, data) in results? {
if ctl.cancelled() {
return None;
@@ -321,5 +252,145 @@ fn run(surface: &Surface, area: Rect, amount: f32, radius: f32, selection: Optio
Some(out)
}
/// Everything the vertical pass needs to turn one stripe of horizontally
/// convolved columns into finished, sharpened output pixels.
struct VerticalPass<'a> {
surface: &'a Surface,
selection: Option<&'a Surface>,
tmp: &'a [f32],
plan: &'a Plan,
ctl: &'a Interrupt<'a>,
area: Rect,
n: usize,
w: usize,
h: usize,
rows: usize,
row_len: usize,
r: usize,
y0: i32,
read_y0: i32,
read_y1: i32,
alpha: bool,
amount: f32,
kernel_len: usize,
}
/// Channel `c` of `px` scaled into [-1, 1], or zero when the column is all zero.
fn normalized(px: &[f32], scales: &[f64], c: usize) -> f64 {
match (px.get(c), scales.get(c)) {
(Some(v), Some(s)) if *s > 0.0 => f64::from(*v) / s,
_ => 0.0,
}
}
impl VerticalPass<'_> {
/// Process the `index`th stripe of columns, returning its rect and pixels.
fn stripe(&self, index: usize) -> Option<(Rect, Vec<f32>)> {
let (ctl, n, h) = (self.ctl, self.n, self.h);
if ctl.cancelled() {
return None;
}
let x = index.checked_mul(STRIPE)?;
let width = STRIPE.min(self.w.checked_sub(x)?);
let columns = self.gather_columns(x, width)?;
let rect = Rect::new(self.area.x0.checked_add(x as i32)?, self.area.y0, self.area.x0.checked_add((x + width) as i32)?, self.area.y1);
let len = width.checked_mul(h)?.checked_mul(n)?;
let mut result = buffer(len, 0.0f32)?;
let mut original = buffer(len, 0.0f32)?;
self.surface.read_region_into(rect, &mut original);
let (mut data, mut scratch) = self.plan.work()?;
for (c, samples) in columns.chunks_exact(self.rows * n).enumerate() {
if ctl.cancelled() {
return None;
}
self.convolve_column(samples, c, width, (&mut data, &mut scratch), &mut result)?;
}
self.sharpen(&mut result, &original)?;
if let Some(sel) = self.selection {
crate::mix_selection(&mut result, rect, sel, &Image { rect, ch: n, data: original }, self.alpha);
}
Some((rect, result))
}
/// Transpose the stripe at `x` of the horizontal results into column-major order.
fn gather_columns(&self, x: usize, width: usize) -> Option<Vec<f32>> {
let (n, rows) = (self.n, self.rows);
let mut columns = buffer(width.checked_mul(rows)?.checked_mul(n)?, 0.0f32)?;
for (y, row) in self.tmp.chunks_exact(self.row_len).enumerate() {
if y % 64 == 0 && self.ctl.cancelled() {
return None;
}
for (c, px) in row.get(x * n..(x + width) * n)?.chunks_exact(n).enumerate() {
let start = (c * rows + y) * n;
columns.get_mut(start..start + n)?.copy_from_slice(px);
}
}
Some(columns)
}
/// Convolve one gathered column, two channels per complex FFT, into `result`.
fn convolve_column(&self, samples: &[f32], c: usize, width: usize, work: (&mut [Number], &mut [Number]), result: &mut [f32]) -> Option<()> {
let (n, h, r) = (self.n, self.h, self.r);
let (data, scratch) = work;
let mut scales = [0.0f64; crate::fxutil::MAXC];
for px in samples.chunks_exact(n) {
for (s, v) in scales.iter_mut().zip(px) {
*s = s.max(f64::from(v.abs()));
}
}
for channel in (0..n).step_by(2) {
if self.ctl.cancelled() {
return None;
}
data.fill(Number::default());
for (i, d) in data.iter_mut().take(h + 2 * r).enumerate() {
let sy = self.y0.checked_add(i as i32)?.clamp(self.read_y0, self.read_y1.checked_sub(1)?);
let start = usize::try_from(sy - self.read_y0).ok()?.checked_mul(n)?;
let px = samples.get(start..start + n)?;
*d = Number::new(normalized(px, &scales, channel), normalized(px, &scales, channel + 1));
}
self.plan.convolve(data, scratch)?;
for (y, d) in data.get(2 * r..2 * r + h)?.iter().enumerate() {
let start = (y * width + c) * n;
*result.get_mut(start + channel)? = (d.re * scales.get(channel)?) as f32;
if let Some(v) = result.get_mut(start + channel + 1).filter(|_| channel + 1 < n) {
*v = (d.im * scales.get(channel + 1)?) as f32;
}
}
}
Some(())
}
/// Turn the blurred stripe into the unsharp-masked result against `original`.
/// Declines (`None`) wherever the fast path cannot match the exact kernel.
fn sharpen(&self, result: &mut [f32], original: &[f32]) -> Option<()> {
let (n, alpha) = (self.n, self.alpha);
for (px, o) in result.chunks_exact_mut(n).zip(original.chunks_exact(n)) {
if alpha && *o.last()? <= 0.0 {
px.copy_from_slice(o);
continue;
}
let a = if alpha { *px.last()? } else { 1.0 };
// Near the original unpremultiply cutoff, cancellation/FFT noise
// or f32 sum order can expose colour. Decline the whole fast path.
let uncertainty = 4.0 * self.kernel_len as f32 * f32::EPSILON;
if alpha && a <= 1e-7 + uncertainty {
return None;
}
for (v, o) in px.iter_mut().zip(o).take(n - usize::from(alpha)) {
let b = if alpha { *v / a } else { *v };
*v = o + self.amount / 100.0 * (o - b);
if !v.is_finite() {
return None;
}
}
if alpha {
*px.last_mut()? = *o.last()?;
}
}
Some(())
}
}
#[cfg(test)]
mod tests;
+176 -98
View File
@@ -20,7 +20,6 @@ pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> O
if [b.x0, b.y0, b.x1, b.y1, out.x0, out.y0, out.x1, out.y1].iter().any(|v| v.unsigned_abs() > MAX_COORD as u32) {
return None;
}
let n = src.ch;
let w = out.width() as usize;
let count = w.checked_mul(out.height() as usize)?;
if count > MAX_PIXELS {
@@ -44,111 +43,190 @@ pub(super) fn blocks(src: &Image, out: Rect, ctx: &Ctx, spec: &ExtrudeSpec) -> O
if i64::from(cx1 - cx0 + 1).checked_mul(i64::from(cy1 - cy0 + 1))? > MAX_CELLS {
return None;
}
let (bcx, bcy) = ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0);
let half_diag = ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0);
let half = cs as f32 / 2.0;
let cc = ncol(ctx, n);
let sub = subtractive(ctx);
let pass = BlockPass {
src,
out,
ctx,
spec,
b,
area,
cs,
k,
reach,
centre: ((b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0),
half_diag: ((b.width() as f32).hypot(b.height() as f32) / 2.0).max(1.0),
half: cs as f32 / 2.0,
};
// Ascending gy, then gx, is the tie order of every pixel's old candidate search.
for cy in cy0..=cy1 {
for cx in cx0..=cx1 {
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
let clipped = r.intersect(&b);
if spec.mask_incomplete && clipped != r {
continue;
}
let mut col = [0.0f32; MAXC];
let mut cnt = 0.0;
let step = (cs / 8).max(1) as usize;
for yy in (clipped.y0..clipped.y1).step_by(step) {
for xx in (clipped.x0..clipped.x1).step_by(step) {
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
for (c, v) in col.iter_mut().enumerate().take(n) {
let s = src.get(xx, yy, c);
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
}
cnt += 1.0;
}
}
if cnt > 0.0 {
for v in col.iter_mut().take(n) {
*v /= cnt;
}
}
unpremul_px(&mut col[..n], ctx.alpha);
let h = if spec.level_based { luma(ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, spec.seed) };
if !h.is_finite() {
return None;
}
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
let off = reach * h / half_diag;
let (ox, oy) = ((mx - bcx) * off, (my - bcy) * off);
// Two extra pixels conservatively cover rounding in the unchanged interval test.
let sweep = Rect::new(
(mx.min(mx + ox) - half).floor() as i32 - 2,
(my.min(my + oy) - half).floor() as i32 - 2,
(mx.max(mx + ox) + half).ceil() as i32 + 2,
(my.max(my + oy) + half).ceil() as i32 + 2,
)
.intersect(&area);
for y in sweep.y0..sweep.y1 {
let gy = (y - b.y0).div_euclid(cs);
if (cy - gy).abs() > k {
continue;
}
let fy = y as f32 + 0.5;
let Some(iy) = interval(fy, my, oy, half) else { continue };
for x in sweep.x0..sweep.x1 {
if (cx - (x - b.x0).div_euclid(cs)).abs() > k {
continue;
}
let i = (y - out.y0) as usize * w + (x - out.x0) as usize;
let height = heights.get_mut(i)?;
if *height >= h {
continue;
}
let fx = x as f32 + 0.5;
let Some(ix) = interval(fx, mx, ox, half) else { continue };
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
if lo > hi {
continue;
}
*height = h;
let face = if hi >= 1.0 {
0
} else if ix.1 < iy.1 {
if ox > 0.0 { 1 } else { 2 }
} else if oy > 0.0 {
3
} else {
4
};
let px = res.get_mut(i * n..(i + 1) * n)?;
if face == 0 && !spec.solid_front {
let (sx, sy) = ((fx - (mx - bcx) * off).floor() as i32, (fy - (my - bcy) * off).floor() as i32);
read_px(src, sx.clamp(r.x0, r.x1 - 1), sy.clamp(r.y0, r.y1 - 1), px);
} else {
let shade = match face {
0 => 1.0,
1 => 0.85,
2 => 0.55,
3 => 0.95,
_ => 0.45,
};
for (v, c) in px.iter_mut().zip(&col).take(cc) {
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
}
if ctx.alpha {
*px.last_mut()? = col.get(n - 1).copied()?;
}
}
}
}
pass.paint_cell(cx, cy, &mut heights, &mut res)?;
}
}
Some(res)
}
/// The constants shared by every swept block of one `blocks` call.
struct BlockPass<'a> {
src: &'a Image,
out: Rect,
ctx: &'a Ctx,
spec: &'a ExtrudeSpec,
b: Rect,
area: Rect,
cs: i32,
k: i32,
reach: f32,
centre: (f32, f32),
half_diag: f32,
half: f32,
}
/// One cell's flat colour and where its extruded block sweeps to.
struct Block {
r: Rect,
cx: i32,
m: (f32, f32),
o: (f32, f32),
off: f32,
h: f32,
col: [f32; MAXC],
}
impl BlockPass<'_> {
/// Average the (premultiplied) source over `clipped`, sampling every `step` pixels.
fn cell_colour(&self, clipped: Rect) -> [f32; MAXC] {
let (src, ctx, n) = (self.src, self.ctx, self.src.ch);
let mut col = [0.0f32; MAXC];
let mut cnt = 0.0;
let step = (self.cs / 8).max(1) as usize;
for yy in (clipped.y0..clipped.y1).step_by(step) {
for xx in (clipped.x0..clipped.x1).step_by(step) {
let a = if ctx.alpha { src.get(xx, yy, n - 1) } else { 1.0 };
for (c, v) in col.iter_mut().enumerate().take(n) {
let s = src.get(xx, yy, c);
*v += if ctx.alpha && c < n - 1 { s * a } else { s };
}
cnt += 1.0;
}
}
if cnt > 0.0 {
for v in col.iter_mut().take(n) {
*v /= cnt;
}
}
unpremul_px(&mut col[..n], ctx.alpha);
col
}
/// Rasterize the block of grid cell (`cx`, `cy`) over the pixels it can hit.
fn paint_cell(&self, cx: i32, cy: i32, heights: &mut [f32], res: &mut [f32]) -> Option<()> {
let (b, cs, half) = (self.b, self.cs, self.half);
let r = Rect::new(b.x0 + cx * cs, b.y0 + cy * cs, b.x0 + (cx + 1) * cs, b.y0 + (cy + 1) * cs);
let clipped = r.intersect(&b);
if self.spec.mask_incomplete && clipped != r {
return Some(());
}
let col = self.cell_colour(clipped);
let n = self.src.ch;
let h = if self.spec.level_based { luma(self.ctx, &col[..n]).clamp(0.0, 1.0) } else { hash01(cx, cy, 21, self.spec.seed) };
if !h.is_finite() {
return None;
}
let (mx, my) = (r.x0 as f32 + half, r.y0 as f32 + half);
let off = self.reach * h / self.half_diag;
let o = ((mx - self.centre.0) * off, (my - self.centre.1) * off);
// Two extra pixels conservatively cover rounding in the unchanged interval test.
let sweep = Rect::new(
(mx.min(mx + o.0) - half).floor() as i32 - 2,
(my.min(my + o.1) - half).floor() as i32 - 2,
(mx.max(mx + o.0) + half).ceil() as i32 + 2,
(my.max(my + o.1) + half).ceil() as i32 + 2,
)
.intersect(&self.area);
let block = Block { r, cx, m: (mx, my), o, off, h, col };
for y in sweep.y0..sweep.y1 {
let gy = (y - b.y0).div_euclid(cs);
if (cy - gy).abs() > self.k {
continue;
}
for x in sweep.x0..sweep.x1 {
self.paint_pixel(&block, x, y, heights, res)?;
}
}
Some(())
}
/// Paint pixel (`x`, `y`) if `block` covers it and is taller than what is there.
fn paint_pixel(&self, block: &Block, x: i32, y: i32, heights: &mut [f32], res: &mut [f32]) -> Option<()> {
let (b, cs, half, n) = (self.b, self.cs, self.half, self.src.ch);
let (mx, my) = block.m;
let (ox, oy) = block.o;
if (block.cx - (x - b.x0).div_euclid(cs)).abs() > self.k {
return Some(());
}
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let i = (y - self.out.y0) as usize * self.out.width() as usize + (x - self.out.x0) as usize;
let height = heights.get_mut(i)?;
if *height >= block.h {
return Some(());
}
let Some(iy) = interval(fy, my, oy, half) else { return Some(()) };
let Some(ix) = interval(fx, mx, ox, half) else { return Some(()) };
let (lo, hi) = (ix.0.max(iy.0), ix.1.min(iy.1));
if lo > hi {
return Some(());
}
*height = block.h;
let face = block_face(hi, ix.1 < iy.1, ox, oy);
let px = res.get_mut(i * n..(i + 1) * n)?;
self.write_face(block, face, (fx, fy), px)
}
/// Write `face` of `block` into pixel `px` centred at `(fx, fy)`: the front
/// face shows the shifted source, every face can instead be flat-shaded.
fn write_face(&self, block: &Block, face: u8, (fx, fy): (f32, f32), px: &mut [f32]) -> Option<()> {
let n = self.src.ch;
if face == 0 && !self.spec.solid_front {
let (sx, sy) = ((fx - (block.m.0 - self.centre.0) * block.off).floor() as i32, (fy - (block.m.1 - self.centre.1) * block.off).floor() as i32);
read_px(self.src, sx.clamp(block.r.x0, block.r.x1 - 1), sy.clamp(block.r.y0, block.r.y1 - 1), px);
} else {
let shade = face_shade(face);
let sub = subtractive(self.ctx);
for (v, c) in px.iter_mut().zip(&block.col).take(ncol(self.ctx, n)) {
*v = if sub { 1.0 - (1.0 - *c) * shade } else { *c * shade }.clamp(0.0, 1.0);
}
if self.ctx.alpha {
*px.last_mut()? = block.col.get(n - 1).copied()?;
}
}
Some(())
}
}
/// Which face of the swept block a pixel shows: 0 front, 1/2 right/left, 3/4 bottom/top.
fn block_face(hi: f32, x_exits_first: bool, ox: f32, oy: f32) -> u8 {
if hi >= 1.0 {
0
} else if x_exits_first {
if ox > 0.0 { 1 } else { 2 }
} else if oy > 0.0 {
3
} else {
4
}
}
fn face_shade(face: u8) -> f32 {
match face {
0 => 1.0,
1 => 0.85,
2 => 0.55,
3 => 0.95,
_ => 0.45,
}
}
fn interval(p: f32, m: f32, o: f32, half: f32) -> Option<(f32, f32)> {
if o.abs() < 1e-6 {
return ((p - m).abs() <= half).then_some((0.0, 1.0));
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
photocraft-doc = { workspace = true }
photocraft-raster = { workspace = true }
photocraft-compose = { workspace = true }
photocraft-codecs = { workspace = true }
photocraft-io = { workspace = true }
+16 -2
View File
@@ -33,9 +33,23 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
let document = photocraft_format::load_path(path)?;
return Ok(Opened { warnings: photocraft_io::open_warnings(&document), document, source_read_only: false });
}
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
open_bytes(&name, &bytes)
// A PSD/PSB is parsed from the open file (channel data read as it is decoded), so a CLI
// convert or info of a 2 GB file does not hold the file in memory next to the document.
#[cfg(any(unix, windows))]
{
let r =
photocraft_io::import_file(&name, path, &photocraft_io::ImportOptions::default(), &photocraft_raster::Interrupt::NONE).map_err(|e| match e {
photocraft_io::IoError::Read(msg) => AutomationError::Io(msg),
e => e.into(),
})?;
Ok(Opened { document: r.document, warnings: r.warnings, source_read_only: r.source_read_only })
}
#[cfg(not(any(unix, windows)))]
{
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
open_bytes(&name, &bytes)
}
}
/// Previews for a `.pcraft` bundle.
+5 -2
View File
@@ -1,7 +1,10 @@
//! Catalog metadata, strict tool envelopes and live resources.
use super::*;
use super::{Arc, Backend, BatchParams, DocIndex, ListParams, McpError, Parameters, PhotocraftMcp, RunParams, ServerHandler, Value, json};
use rmcp::RoleServer;
use rmcp::model::*;
use rmcp::model::{
CallToolRequestParams, CallToolResponse, Implementation, ListResourcesResult, ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams,
ReadResourceResponse, ReadResourceResult, ServerCapabilities, ServerConfig, Tool, ToolAnnotations,
};
use rmcp::service::RequestContext;
const DOCUMENT: &str = "photocraft://document";
+1 -1
View File
@@ -1,5 +1,5 @@
//! Render Video runs on the existing blocking worker; rmcp remains available for ping/cancel.
use super::*;
use super::{CallToolResult, PhotocraftMcp, Value, fail, no_backend, ok_json};
use rmcp::RoleServer;
use rmcp::model::ProgressNotificationParam;
use rmcp::service::RequestContext;
+1 -1
View File
@@ -1,5 +1,5 @@
//! Recover malformed JSON lines before passing valid messages to rmcp.
use super::*;
use super::{AutomationError, PhotocraftMcp, ServiceExt, Value, json};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::security::MAX_REQUEST_BYTES;
+4
View File
@@ -338,6 +338,10 @@ fn filesystem_commands_fail_closed() {
for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers"] {
assert!(authorize_engine_command(id, &serde_json::json!({"path": "/outside/source.psb"})).is_err(), "{id}");
}
}
#[test]
fn filesystem_command_paths_and_profiles_fail_closed() {
// The UI-level examples in docs/control-protocol.md.
for id in ["view.zoomIn", "window.theme.pro", "edit.search"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({})).is_ok(), "{id}");
+2 -1
View File
@@ -187,7 +187,7 @@ fn decompress(compression: Compression, packed: &[u8], expected: usize, what: &s
// `read_to_end`, whose doubling reserves past `expected`. Once `expected` bytes are
// in, a one-byte read checks that the stream ends there.
let malformed = || err(format!("{what}: zlib data is malformed"));
let mut zlib = flate2::read::ZlibDecoder::new(packed);
let mut zlib = flate2::bufread::ZlibDecoder::new(packed);
let mut out = Vec::new();
let mut filled = 0;
while filled < expected {
@@ -756,6 +756,7 @@ mod tests {
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(&p).expect("write to a Vec");
let zips = z.finish().expect("finish a Vec");
assert!(decompress(Compression::ZIP1, &zips[..zips.len() - 1], n, "the table").is_err());
// RLE: the first byte as a literal, then runs of up to 128 copies of the next.
let mut rle = vec![0xFF, p[0]];
for run in p[1..].chunks(128) {
+15 -1
View File
@@ -39,16 +39,30 @@ pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result<MetaData, Codec
let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?;
// Flat decoding reads every part at once, so the parts' sizes are also capped together.
let mut total: u64 = 0;
// DWAA/DWAB parts are counted apart: they go past 2^16:1 (a flat 8192x4320 frame with 12
// alpha-scheme mattes is about 30 KB, over 75000:1).
let mut dwa: u64 = 0;
for header in meta.headers.iter() {
let size = header.layer_size;
let (w, h) = (u32::try_from(size.0).unwrap_or(u32::MAX), u32::try_from(size.1).unwrap_or(u32::MAX));
let bpp = header.channels.list.len().max(1) as u64 * 4;
limits.check_bytes(w, h, bpp)?;
total = total.saturating_add(u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp));
let bytes = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp);
total = total.saturating_add(bytes);
if matches!(header.compression, Compression::DWAA(_) | Compression::DWAB(_)) {
dwa = dwa.saturating_add(bytes);
}
}
if total > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("{total} bytes across {} parts exceed max_alloc {}", meta.headers.len(), limits.max_alloc)));
}
// The `exr` crate allocates a flat part's samples before reading its chunks, so the file
// must be able to hold them (#8597). The other compressions stay far below 2^16:1 even
// against this f32 budget. DWA's run-length channels are zlib-packed on top and reach about
// 132000:1, so DWA parts get 2^18:1. Deep parts size their samples from the file's own counts.
if !meta.requirements.has_deep_data {
Limits::check_plausible(total.saturating_sub(dwa).saturating_add(dwa / 4), bytes.len(), 1 << 16)?;
}
Ok(meta)
}
+74 -13
View File
@@ -150,8 +150,13 @@ fn data_end(b: &[u8]) -> (DataEnd, u64) {
loop {
let Some(&byte) = b.get(i) else { return cut(scan_data, scanned) };
if byte != 0xFF {
// Not where a marker should be: too odd to judge.
return (DataEnd::Complete, scanned);
// Not where a marker should be: skip to the next one, as a lenient decoder does, so
// segments after the stray bytes (padding among them) are still recognised. Stray
// bytes are not scan data, so they never count (junk would buy budget as padding
// did); with no marker left, the image data ends here.
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else { return cut(scan_data, scanned) };
i += p;
continue;
}
let Some(&code) = b.get(i + 1) else { return cut(scan_data, scanned) };
match code {
@@ -232,9 +237,10 @@ fn decode_craft_jpeg(bytes: &[u8], limits: &Limits, adobe_transform: Option<u8>)
Ok((img, d.damaged))
}
/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few
/// bits per 8×8 block, and truncated photographs are far above this.
const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048;
/// Most pixels one byte of a JPEG may account for. Every 8×8 block of a frame needs at least a
/// one-bit DC code (512 pixels a byte for a flat image under ideal Huffman tables), and real files,
/// truncated or not, are far above this.
const MAX_PIXELS_PER_BYTE: u64 = 2048;
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (end, scan_bytes) = data_end(bytes);
@@ -252,7 +258,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
if let Some((w, h, _)) = meta.frame
&& end == DataEnd::Truncated
&& u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes
// Only truncated scans: arithmetic coding (SOF9–SOF11) can spend well under a bit on
// a flat block, so a complete scan may be shorter than this bound.
&& u64::from(w) * u64::from(h) / MAX_PIXELS_PER_BYTE > scan_bytes
{
return Err(err("the truncated scan is too short for the frame it declares"));
}
@@ -277,12 +285,14 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
_ => (ColorSpace::RGB, ChannelLayout::Rgb),
};
limits.check(w, h, layout, SampleType::U8)?;
// The backend fills whatever the scan no longer describes, so a short scan under a large
// frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is
// measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily
// without describing one more pixel.
if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes {
return Err(err("the truncated scan is too short for the frame it declares"));
// The backend fills whatever the scan no longer describes, and allocates the whole output
// once a scan holds a byte, so a short scan under a large frame is a gigabyte of synthesized
// output from a few kilobytes of file (#8597), truncated or not. The budget is the scan data
// itself, not `bytes.len()`, which APP padding can inflate without describing one more pixel;
// where the walk stops early on an odd layout, what it couldn't read counts as scan data.
if u64::from(w) * u64::from(h) / MAX_PIXELS_PER_BYTE > scan_bytes {
let what = if end == DataEnd::Truncated { "the truncated scan" } else { "the scan data" };
return Err(err(format!("{what} is too short for the frame it declares")));
}
dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs));
let mut px = dec.decode().map_err(err)?;
@@ -552,7 +562,7 @@ mod tests {
assert_eq!(data_end(&padded).0, DataEnd::Truncated);
// Well above the frame's pixels / budget, yet far above the scan data: the old
// file-length bound admitted this frame.
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small");
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_PIXELS_PER_BYTE) as usize, "padding too small");
assert!(crate::decode_as(Format::Jpeg, &padded).is_ok());
let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
let mut huge = padded.clone();
@@ -561,6 +571,57 @@ mod tests {
assert!(e.to_string().contains("too short"), "{e}");
}
/// A complete file is measured on its scan data too: padding can't admit an oversized frame.
/// The same padded file at its real size decodes, and so does an odd layout whose unread tail
/// counts as scan data.
#[test]
fn app_padding_cannot_admit_an_oversized_complete_frame() {
let img = Image::from_raw(16, 16, ChannelLayout::Rgb, SampleType::U8, vec![128; 16 * 16 * 3]).unwrap();
let full = crate::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
let fill = [b'p'; 65533];
let mut padded = full[..2].to_vec();
for _ in 0..4 {
padded.extend_from_slice(&[0xFF, 0xFE]);
padded.extend_from_slice(&0xFFFFu16.to_be_bytes());
padded.extend_from_slice(&fill);
}
padded.extend_from_slice(&full[2..]);
let (end, scanned) = data_end(&padded);
assert_eq!(end, DataEnd::Complete);
assert!(scanned < 1024, "the padding isn't scan data: {scanned}");
assert!(padded.len() as u64 > u64::from(20_000u32) * 20_000 / MAX_PIXELS_PER_BYTE, "padding too small");
assert!(crate::decode_as(Format::Jpeg, &padded).is_ok());
let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
let mut huge = padded.clone();
huge[sof + 5..sof + 9].copy_from_slice(&[0x4E, 0x20, 0x4E, 0x20]);
let e = crate::decode_as(Format::Jpeg, &huge).unwrap_err();
assert!(e.to_string().contains("too short"), "{e}");
// A stray byte where a marker should be, before the padding: the walk skips it and still
// recognises the padding, so an oversized frame is refused there too.
let mut odd = huge.clone();
odd.insert(2, 0x00);
let (end, scanned) = data_end(&odd);
assert_eq!(end, DataEnd::Complete);
assert!(scanned < 1024, "the padding after a stray byte isn't scan data: {scanned}");
let e = crate::decode_as(Format::Jpeg, &odd).unwrap_err();
assert!(e.to_string().contains("too short"), "{e}");
// Stray bytes never count: 256 KiB of junk where a marker should be (the decoder skips
// it), then the oversized frame's segments, is refused. (Bytes after a scan starts are
// entropy-coded data, any value, and count as such.)
let mut junk = huge[..2].to_vec();
junk.extend(std::iter::repeat_n(0x00u8, 4 * 65536));
junk.extend_from_slice(&huge[2..]);
let (end, scanned) = data_end(&junk);
assert_eq!(end, DataEnd::Complete);
assert!(scanned < 1024, "junk isn't scan data: {scanned}");
let e = crate::decode_as(Format::Jpeg, &junk).unwrap_err();
assert!(e.to_string().contains("too short"), "{e}");
// Junk with no marker after it: no image data at all.
let mut tail = vec![0xFF, 0xD8];
tail.extend(std::iter::repeat_n(0x00u8, 4096));
assert_eq!(data_end(&tail), (DataEnd::Empty, 0));
}
/// CMYK JPEG output must omit JFIF while retaining Adobe's CMYK marker.
#[test]
fn cmyk_jpeg_omits_jfif_but_keeps_adobe_cmyk_marker() {
+9 -4
View File
@@ -419,7 +419,7 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression
}
let lvl = match level {
PngCompression::None => flate2::Compression::none(),
PngCompression::Fast => flate2::Compression::fast(),
PngCompression::Fast => FAST,
PngCompression::Default => flate2::Compression::default(),
PngCompression::Best => flate2::Compression::best(),
};
@@ -428,6 +428,11 @@ fn adam7_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompression
z.finish().map_err(|e| CodecError::encode(F, e))
}
/// The deflate level of [`PngCompression::Fast`]: zlib-rs level 1 writes static Huffman blocks
/// only (20-30% bigger on filtered photos); level 3 is the lowest that is never bigger than
/// miniz's level 1 was, at about twice its time.
const FAST: flate2::Compression = flate2::Compression::new(3);
/// Images at least this large (bytes) are filtered and deflated on all cores.
const PARALLEL_MIN_BYTES: usize = 4 << 20;
@@ -456,7 +461,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio
let band_rows = (BAND_BYTES / stride.saturating_add(1)).max(1);
let bands: Vec<(usize, usize)> = (0..h).step_by(band_rows).map(|y| (y, (y + band_rows).min(h))).collect();
let lvl = match level {
PngCompression::Fast => flate2::Compression::fast(),
PngCompression::Fast => FAST,
PngCompression::Best => flate2::Compression::best(),
_ => flate2::Compression::default(),
};
@@ -494,7 +499,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio
let mut out = Vec::with_capacity(parts.iter().map(|p| p.1.len()).sum::<usize>() + 6);
// zlib header: deflate, 32 K window; FLEVEL only advertises the effort.
out.extend_from_slice(match level {
PngCompression::Fast => &[0x78, 0x01],
PngCompression::Fast => &[0x78, 0x5E],
PngCompression::Best => &[0x78, 0xDA],
_ => &[0x78, 0x9C],
});
@@ -509,7 +514,7 @@ fn banded_idat(data: &[u8], w: usize, h: usize, bpp: usize, level: PngCompressio
/// Raw deflate of one band: ends on a sync flush, or finishes the stream for the `last` band.
/// The output buffer is sized past deflate's worst case so that one call takes the whole band
/// and completes the flush: when a sync flush fills the buffer, miniz_oxide (flate2's Rust
/// and completes the flush: when a sync flush fills the buffer, miniz_oxide (flate2's former Rust
/// backend) can return before compressing the last lookahead bytes, and the next call only
/// drains the pending output, so a resumed flush silently drops data. `None` (the caller then
/// uses the serial encoder) if the single call did not finish.
+1 -1
View File
@@ -27,7 +27,7 @@
//! (see [`writes_little_endian`]), which the layer data must match.
use std::borrow::Cow;
use std::io::{self, Cursor, Read, Seek, SeekFrom};
use std::io::{self, Read, Seek, SeekFrom};
use half::f16;
use tiff::decoder::{ChunkType, Decoder};
+1 -1
View File
@@ -1,5 +1,5 @@
use super::decode::{Layout, Patched, Src, is_eof};
use super::*;
use super::{CodecError, Decoder, SampleType, err, f16, map_err};
/// The a* and b* samples (1 and 2 of every pixel) of a Lab image between the file's encoding and
/// PhotoCraft's (8 bits: `a + 128`; 16 bits: `(a + 128) · 257`). In the file, CIELab holds them
+115 -96
View File
@@ -1,6 +1,9 @@
use super::bands::{convert_ab, decode_bands, extra_sample_indices, split_extra_samples, zeroed};
use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS, photoshop_tags};
use super::*;
use super::{
ChannelLayout, ChunkType, CodecError, DecodeWarning, Decoder, ExifDirs, File, Ifd, Image, Limits, Metadata, Read, SampleType, Seek, SeekFrom, Tag,
camera_raw, err, ifd, io, map_err, tiff_exif,
};
/// Decodes the page Photoshop would open: the first full-resolution directory.
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
@@ -235,6 +238,112 @@ pub(super) fn is_eof(e: &tiff::TiffError) -> bool {
matches!(e, tiff::TiffError::IoError(io) if io.kind() == io::ErrorKind::UnexpectedEof)
}
/// The sample type of a (SampleFormat, BitsPerSample) pair.
fn sample_src(format: u64, bits: u64) -> Result<Src, CodecError> {
Ok(match (format, bits) {
(1, 1 | 2 | 4) => Src::Bits(bits as u8),
(1, 8) => Src::U8,
(1, 16) => Src::U16,
(1, 32) => Src::U32,
(1, 64) => Src::U64,
(3, 16) => Src::F16,
(3, 32) => Src::F32,
(3, 64) => Src::F64,
(2, _) => return Err(CodecError::unsupported(F, "signed integer samples")),
_ => return Err(CodecError::unsupported(F, format!("{bits}-bit samples (sample format {format})"))),
})
}
/// The ColorMap of a palette image as RGB entries.
fn read_palette(f: &File<'_>, dir: &Ifd, spp: u64, bits: u64, src: Src) -> Result<Vec<[u16; 3]>, CodecError> {
if spp != 1 || !matches!(src, Src::Bits(_) | Src::U8 | Src::U16) {
return Err(CodecError::unsupported(F, format!("palette image with {spp} samples of {bits} bits")));
}
let n = 1u64 << bits;
let map = dir.get(ifd::TAG_COLOR_MAP).filter(|e| e.ty == 3 && e.count == 3 * n).ok_or_else(|| err("palette image without a valid ColorMap"))?;
let at = |i: u64| f.uint(map, i).and_then(|v| u16::try_from(v).ok());
let table: Option<Vec<[u16; 3]>> = (0..n).map(|i| Some([at(i)?, at(n + i)?, at(2 * n + i)?])).collect();
table.ok_or_else(|| err("palette ColorMap cut off"))
}
/// The layout of a multiband read that was asked for every sample.
fn multiband_layout(photometric: u64, has_alpha: bool, gray_alpha: bool) -> ChannelLayout {
match (photometric, has_alpha) {
(2 | 8 | 9, true) => ChannelLayout::Rgba,
(2 | 8 | 9, false) => ChannelLayout::Rgb,
(5, true) => ChannelLayout::CmykA,
(5, false) => ChannelLayout::Cmyk,
_ if gray_alpha => ChannelLayout::GrayA,
_ => ChannelLayout::Gray,
}
}
/// The chunk tables must match the geometry. Checked in 64 bits, before the `tiff` crate does
/// the same arithmetic in narrower types.
fn check_chunk_tables(f: &File<'_>, dir: &Ifd, (w, h): (u32, u32), spp: u64, planar: bool, tiled: bool) -> Result<(u64, u64), CodecError> {
let need = |tag: u16, name: &str| f.tag_uint(dir, tag).ok_or_else(|| err(format!("missing or unreadable {name}")));
let (cw, ch) = if tiled {
let tw = need(ifd::TAG_TILE_WIDTH, "TileWidth")?;
let tl = need(ifd::TAG_TILE_LENGTH, "TileLength")?;
(tw, tl)
} else {
(u64::from(w), f.tag_uint(dir, ifd::TAG_ROWS_PER_STRIP).unwrap_or(u64::from(h)).min(u64::from(h)))
};
if cw == 0 || ch == 0 {
return Err(err("zero tile or strip size"));
}
// The chunk tables must match the geometry. Checked here, in 64 bits, before the `tiff`
// crate does the same arithmetic in narrower types.
let planes_in_file = if planar { spp } else { 1 };
let expected = u64::from(w)
.div_ceil(cw)
.checked_mul(u64::from(h).div_ceil(ch))
.and_then(|n| n.checked_mul(planes_in_file))
.filter(|&n| n <= u64::from(u32::MAX))
.ok_or_else(|| err("too many strips or tiles"))?;
let (offsets_tag, counts_tag) = if tiled { (324, 325) } else { (273, 279) };
let counts = (dir.get(offsets_tag).map(|e| e.count), dir.get(counts_tag).map(|e| e.count));
if counts != (Some(expected), Some(expected)) {
return Err(err(format!("{expected} strips or tiles expected, the tables list {counts:?}")));
}
Ok((cw, ch))
}
/// Byte patches the `tiff` crate reads through: the directory is seen as the last of its chain,
/// and PhotometricInterpretation is handed over as black-is-zero (or RGB for Lab) when asked to.
fn header_patches(
f: &File<'_>,
dir: &Ifd,
photometric_entry: Option<ifd::Entry>,
patch_photometric: bool,
lab_only: bool,
) -> Result<Vec<(u64, Vec<u8>)>, CodecError> {
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
let encode_u = |v: u64, len: usize| -> Vec<u8> {
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
if f.le { b.get(..len).map(<[u8]>::to_vec).unwrap_or_default() } else { b.get(8 - len..).map(<[u8]>::to_vec).unwrap_or_default() }
};
if f.big {
patches.push((8, encode_u(dir.at, 8)));
} else {
let at = u32::try_from(dir.at).map_err(|_| err("directory offset out of range"))?;
patches.push((4, encode_u(u64::from(at), 4)));
}
// The directory is seen as the last of its chain: the chain was walked already, and a
// cycle through it must not fail this page.
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
if patch_photometric {
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
let len = match e.ty {
3 if e.count == 1 => 2,
4 if e.count == 1 => 4,
_ => return Err(err("malformed PhotometricInterpretation")),
};
patches.push((e.value_at, encode_u(if lab_only { 2 } else { 1 }, len)));
}
Ok(patches)
}
/// Decodes the pixels of one directory.
fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecError> {
// The values the `tiff` crate reads must exist: a count past the end of the file is
@@ -263,59 +372,13 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
let planar = f.tag_uint(dir, ifd::TAG_PLANAR) == Some(2);
let extra = f.tag_uints(dir, ifd::TAG_EXTRA_SAMPLES, 64).unwrap_or_default();
let tiled = dir.get(ifd::TAG_TILE_OFFSETS).is_some();
let (cw, ch) = if tiled {
let tw = need(ifd::TAG_TILE_WIDTH, "TileWidth")?;
let tl = need(ifd::TAG_TILE_LENGTH, "TileLength")?;
(tw, tl)
} else {
(u64::from(w), f.tag_uint(dir, ifd::TAG_ROWS_PER_STRIP).unwrap_or(u64::from(h)).min(u64::from(h)))
};
if cw == 0 || ch == 0 {
return Err(err("zero tile or strip size"));
}
// The chunk tables must match the geometry. Checked here, in 64 bits, before the `tiff`
// crate does the same arithmetic in narrower types.
let planes_in_file = if planar { spp } else { 1 };
let expected = u64::from(w)
.div_ceil(cw)
.checked_mul(u64::from(h).div_ceil(ch))
.and_then(|n| n.checked_mul(planes_in_file))
.filter(|&n| n <= u64::from(u32::MAX))
.ok_or_else(|| err("too many strips or tiles"))?;
let (offsets_tag, counts_tag) = if tiled { (324, 325) } else { (273, 279) };
let counts = (dir.get(offsets_tag).map(|e| e.count), dir.get(counts_tag).map(|e| e.count));
if counts != (Some(expected), Some(expected)) {
return Err(err(format!("{expected} strips or tiles expected, the tables list {counts:?}")));
}
let src = match (format, bits) {
(1, 1 | 2 | 4) => Src::Bits(bits as u8),
(1, 8) => Src::U8,
(1, 16) => Src::U16,
(1, 32) => Src::U32,
(1, 64) => Src::U64,
(3, 16) => Src::F16,
(3, 32) => Src::F32,
(3, 64) => Src::F64,
(2, _) => return Err(CodecError::unsupported(F, "signed integer samples")),
_ => return Err(CodecError::unsupported(F, format!("{bits}-bit samples (sample format {format})"))),
};
let (cw, ch) = check_chunk_tables(f, dir, (w, h), spp, planar, tiled)?;
let src = sample_src(format, bits)?;
// WhiteIsZero and palette images are handed to the `tiff` crate as BlackIsZero: it then
// returns the stored samples, and the interpretation is applied here (for every depth and
// with alpha, which the crate's own inversion does not cover).
let invert = photometric == 0;
let palette = if photometric == 3 {
if spp != 1 || !matches!(src, Src::Bits(_) | Src::U8 | Src::U16) {
return Err(CodecError::unsupported(F, format!("palette image with {spp} samples of {bits} bits")));
}
let n = 1u64 << bits;
let map = dir.get(ifd::TAG_COLOR_MAP).filter(|e| e.ty == 3 && e.count == 3 * n).ok_or_else(|| err("palette image without a valid ColorMap"))?;
let at = |i: u64| f.uint(map, i).and_then(|v| u16::try_from(v).ok());
let table: Option<Vec<[u16; 3]>> = (0..n).map(|i| Some([at(i)?, at(n + i)?, at(2 * n + i)?])).collect();
Some(table.ok_or_else(|| err("palette ColorMap cut off"))?)
} else {
None
};
let palette = if photometric == 3 { Some(read_palette(f, dir, spp, bits, src)?) } else { None };
// CIE L*a*b*: CIELab (8; a* and b* signed) and ICCLab (9; a* and b* offset) are handed to the
// `tiff` crate as RGB, then labelled Lab with a* and b* in PhotoCraft's encoding
// (`convert_ab`).
@@ -337,29 +400,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
// asked (photometric patched to black-is-zero below); gray already comes back as multiband.
let unused = !extra.is_empty() && (spp as usize) > color.saturating_add(usize::from(alpha_at.is_some()));
let all_samples = palette.is_none() && matches!(photometric, 2 | 5 | 8 | 9) && (alpha_at.is_some_and(|a| a > 0) || (!planar && unused));
let mut patches: Vec<(u64, Vec<u8>)> = Vec::new();
let encode_u = |v: u64, len: usize| -> Vec<u8> {
let b = if f.le { v.to_le_bytes() } else { v.to_be_bytes() };
if f.le { b.get(..len).map(<[u8]>::to_vec).unwrap_or_default() } else { b.get(8 - len..).map(<[u8]>::to_vec).unwrap_or_default() }
};
if f.big {
patches.push((8, encode_u(dir.at, 8)));
} else {
let at = u32::try_from(dir.at).map_err(|_| err("directory offset out of range"))?;
patches.push((4, encode_u(u64::from(at), 4)));
}
// The directory is seen as the last of its chain: the chain was walked already, and a
// cycle through it must not fail this page.
patches.push((dir.next_at, vec![0; if f.big { 8 } else { 4 }]));
if invert || palette.is_some() || all_samples || lab {
let e = photometric_entry.ok_or_else(|| err("missing PhotometricInterpretation"))?;
let len = match e.ty {
3 if e.count == 1 => 2,
4 if e.count == 1 => 4,
_ => return Err(err("malformed PhotometricInterpretation")),
};
patches.push((e.value_at, encode_u(if lab && !all_samples { 2 } else { 1 }, len)));
}
let patches = header_patches(f, dir, photometric_entry, invert || palette.is_some() || all_samples || lab, lab && !all_samples)?;
let tl = {
let mut l = tiff::decoder::Limits::default();
@@ -380,29 +421,7 @@ fn decode_ifd(f: &File<'_>, dir: &Ifd, limits: &Limits) -> Result<Image, CodecEr
tiff::ColorType::Gray(_) => (ChannelLayout::Gray, 1),
tiff::ColorType::Multiband { num_samples, .. } if all_samples => {
// Lab reads as RGB here and is relabelled below.
let layout = match photometric {
2 | 8 | 9 => {
if alpha_at.is_some() {
ChannelLayout::Rgba
} else {
ChannelLayout::Rgb
}
}
5 => {
if alpha_at.is_some() {
ChannelLayout::CmykA
} else {
ChannelLayout::Cmyk
}
}
_ => {
if gray_alpha {
ChannelLayout::GrayA
} else {
ChannelLayout::Gray
}
}
};
let layout = multiband_layout(photometric, alpha_at.is_some(), gray_alpha);
(layout, usize::from(num_samples))
}
tiff::ColorType::Multiband { num_samples, .. } if photometric <= 1 => {
+6 -1
View File
@@ -1,6 +1,11 @@
use super::bands::convert_ab;
use super::tags::{F, TAG_IMAGE_SOURCE_DATA, TAG_PHOTOSHOP, TAG_XMP, TEXT_TAGS};
use super::*;
use super::{
ChannelLayout, CmykA16, CmykA32F, CodecError, Cow, EncodeOptions, ExifDirs, GrayA8, GrayA16, GrayA32F, Image, Lab8, Lab16, LabA8, LabA16,
PhotometricInterpretation, Plan, Rational, ResolutionUnit, SampleFormat, SampleType, Tag, TiffColorType, TiffCompression, TiffEncoder, TiffKind,
TiffKindBig, TiffKindStandard, TiffValue, Type, colortype, io, tiff_exif,
};
use std::io::Cursor;
// ---------------------------------------------------------------------------
// Encoding
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Format, Tag};
pub(super) const F: Format = Format::Tiff;
+54 -3
View File
@@ -93,7 +93,25 @@ fn open<'a>(f: Format, bytes: &'a [u8], limits: &Limits) -> Result<impl ImageDec
reader.limits(il);
let dec = reader.into_decoder().map_err(|e| map_err(f, e))?;
let (w, h) = dec.dimensions();
limits.check_bytes(w, h, dec.color_type().bytes_per_pixel() as u64)?;
let bpp = dec.color_type().bytes_per_pixel() as u64;
limits.check_bytes(w, h, bpp)?;
// `DynamicImage::from_decoder` allocates the whole image before reading a pixel, so a
// header-only file must not be able to ask for gigabytes (#8597). GIF's LZW tops out near
// 11000:1 of RGBA output and TGA's and QOI's runs far below; AVIF has already decoded by now.
// Radiance HDR's old-style RLE repeat markers multiply their counts by 256 each in a row, and
// a BMP's RLE escapes (end of line, end of bitmap, delta) leave pixels unwritten, so no ratio
// bounds a valid file: only the allocation limit above applies to them.
let declared = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(bpp);
// A GIF's first frame is what the file must hold: the logical screen around it needs no
// data, so up to 256 MiB of screen (8192x8192) is free and only the rest must be backed.
let frame = if f == Format::Gif { gif_first_frame(bytes) } else { None };
let declared = match frame {
Some((fw, fh)) => u64::from(fw).saturating_mul(u64::from(fh)).saturating_mul(bpp).max(declared.saturating_sub(1 << 28)),
_ => declared,
};
if !matches!(f, Format::Avif | Format::Hdr) && !(f == Format::Bmp && bmp_is_rle(bytes)) {
Limits::check_plausible(declared, bytes.len(), 1 << 14)?;
}
Ok(dec)
}
@@ -123,9 +141,36 @@ pub(crate) fn probe(f: Format, bytes: &[u8], limits: &Limits) -> Result<(u32, u3
Ok((w, h, dec.exif_metadata().ok().flatten()))
}
/// Whether a BMP's `biCompression` is RLE8 or RLE4 (only headers of 40 bytes or more have one).
fn bmp_is_rle(b: &[u8]) -> bool {
let u32_at = |at: usize| b.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
u32_at(14).is_some_and(|n| n >= 40) && matches!(u32_at(30), Some(1 | 2))
}
/// The width and height of a GIF's first image descriptor, if the blocks reach one.
fn gif_first_frame(b: &[u8]) -> Option<(u32, u32)> {
let mut at = None;
gif_walk(b, |i| {
at = Some(i);
false
});
let u16_at = |at: usize| b.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
// Descriptor: separator, left, top, width, height (little-endian u16s), packed fields.
let i = at?;
Some((u32::from(u16_at(i + 5)?), u32::from(u16_at(i + 7)?)))
}
/// Counts a GIF's frames (image descriptors) by walking its blocks, without decompressing any.
/// The total is unknown when the blocks end before the trailer or break off.
fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
let (frames, total) = gif_walk(b, |_| true);
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
}
/// Walks a GIF's blocks without decompressing any, handing `frame` the offset of each image
/// descriptor until it returns false. Gives the descriptors seen, and the frame count if the
/// blocks reached the trailer.
fn gif_walk(b: &[u8], mut frame: impl FnMut(usize) -> bool) -> (u32, Option<u32>) {
// Size of the colour table a packed-fields byte announces.
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
// Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length).
@@ -139,7 +184,10 @@ fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
}
};
// Header (6 bytes), logical screen descriptor (7) and the global colour table.
let mut i = 13 + table(*b.get(10)?);
let Some(&flags) = b.get(10) else {
return (0, None);
};
let mut i = 13 + table(flags);
let mut frames = 0u32;
let total = loop {
let next = match b.get(i) {
@@ -147,6 +195,9 @@ fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
Some(0x21) => skip(i + 2),
Some(0x2C) => {
frames = frames.saturating_add(1);
if !frame(i) {
break None;
}
// Descriptor (10 bytes, packed fields last), local colour table, LZW code size.
b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags)))
}
@@ -157,7 +208,7 @@ fn gif_more_frames(b: &[u8]) -> Option<DecodeWarning> {
None => break None,
}
};
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
(frames, total)
}
pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecError> {
+65 -56
View File
@@ -19,7 +19,11 @@ mod transform;
use bool_encoder::BoolEncoder;
use predict::{Plane, SubEdges, predict_block, predict_sub};
use tables::*;
use tables::{
AC_QLOOKUP, B_DC_PRED, B_HE_PRED, B_PRED, B_TM_PRED, B_VE_PRED, BMODE_TREE, CAT_BASE, COEFF_BANDS, COEFF_TREE, COEFF_UPDATE_PROBS, DC_PRED, DC_QLOOKUP,
DCT_0, DCT_1, DCT_2, DCT_3, DCT_4, DCT_CAT1, DCT_CAT2, DCT_CAT3, DCT_CAT4, DCT_CAT5, DCT_CAT6, DCT_EOB, DEFAULT_COEFF_PROBS, H_PRED, KF_BMODE_PROBS,
KF_UV_MODE_PROB, KF_YMODE_PROB, KF_YMODE_TREE, PCAT, TM_PRED, UV_MODE_TREE, V_PRED, ZIGZAG,
};
use transform::{fdct, fwht, idct, iwht};
/// The largest dimension a VP8 frame header can hold (14 bits).
@@ -353,6 +357,65 @@ impl<'a> Encoder<'a> {
.sum()
}
/// Chroma of one macroblock: one mode for both planes; returns it with the U and V blocks.
fn encode_chroma(&mut self, mbx: usize, mby: usize) -> (u8, Vec<Block>, Vec<Block>) {
let (cx0, cy0) = (mbx * 8, mby * 8);
let mut src_u = [0i32; 64];
let mut src_v = [0i32; 64];
for r in 0..8 {
for c in 0..8 {
src_u[r * 8 + c] = self.src_c(&self.src.u, cx0 + c, cy0 + r);
src_v[r * 8 + c] = self.src_c(&self.src.v, cx0 + c, cy0 + r);
}
}
let mut uv_mode = DC_PRED;
let mut uv_best = u64::MAX;
let mut pred_u = [0u8; 64];
let mut pred_v = [0u8; 64];
let mut pu = [0u8; 64];
let mut pv = [0u8; 64];
for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] {
predict_block(&self.rec_u, cx0, cy0, 8, mode, &mut pu);
predict_block(&self.rec_v, cx0, cy0, 8, mode, &mut pv);
let e = Self::sse(&pu, &src_u) + Self::sse(&pv, &src_v);
if e < uv_best {
uv_best = e;
uv_mode = mode;
pred_u = pu;
pred_v = pv;
}
}
let mut u_blocks = Vec::with_capacity(4);
let mut v_blocks = Vec::with_capacity(4);
for (plane_src, pred, blocks, is_u) in [(&src_u, &pred_u, &mut u_blocks, true), (&src_v, &pred_v, &mut v_blocks, false)] {
for b in 0..4 {
let mut residual = [0i32; 16];
for r in 0..4 {
for c in 0..4 {
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
residual[r * 4 + c] = plane_src[i] - i32::from(pred[i]);
}
}
let (quantized, dequantized) = Self::code_block(&residual, self.q.uv_dc, self.q.uv_ac, true);
let recon = idct(&dequantized);
let (bx, by) = (cx0 + (b & 1) * 4, cy0 + (b >> 1) * 4);
for r in 0..4 {
for c in 0..4 {
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
let value = (i32::from(pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8;
if is_u {
self.rec_u.set(bx + c, by + r, value);
} else {
self.rec_v.set(bx + c, by + r, value);
}
}
}
blocks.push(Block::from_raster(&quantized, 0));
}
}
(uv_mode, u_blocks, v_blocks)
}
// Sub-block indices address several arrays by geometry; ranges read better than iterators here.
#[allow(clippy::needless_range_loop)]
fn encode_macroblock(&mut self, mbx: usize, mby: usize) {
@@ -476,61 +539,7 @@ impl<'a> Encoder<'a> {
(best_mode, Some(Block::from_raster(&y2_q, 0)), blocks)
};
// Chroma: one mode for both planes.
let (cx0, cy0) = (mbx * 8, mby * 8);
let mut src_u = [0i32; 64];
let mut src_v = [0i32; 64];
for r in 0..8 {
for c in 0..8 {
src_u[r * 8 + c] = self.src_c(&self.src.u, cx0 + c, cy0 + r);
src_v[r * 8 + c] = self.src_c(&self.src.v, cx0 + c, cy0 + r);
}
}
let mut uv_mode = DC_PRED;
let mut uv_best = u64::MAX;
let mut pred_u = [0u8; 64];
let mut pred_v = [0u8; 64];
let mut pu = [0u8; 64];
let mut pv = [0u8; 64];
for mode in [DC_PRED, V_PRED, H_PRED, TM_PRED] {
predict_block(&self.rec_u, cx0, cy0, 8, mode, &mut pu);
predict_block(&self.rec_v, cx0, cy0, 8, mode, &mut pv);
let e = Self::sse(&pu, &src_u) + Self::sse(&pv, &src_v);
if e < uv_best {
uv_best = e;
uv_mode = mode;
pred_u = pu;
pred_v = pv;
}
}
let mut u_blocks = Vec::with_capacity(4);
let mut v_blocks = Vec::with_capacity(4);
for (plane_src, pred, blocks, is_u) in [(&src_u, &pred_u, &mut u_blocks, true), (&src_v, &pred_v, &mut v_blocks, false)] {
for b in 0..4 {
let mut residual = [0i32; 16];
for r in 0..4 {
for c in 0..4 {
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
residual[r * 4 + c] = plane_src[i] - i32::from(pred[i]);
}
}
let (quantized, dequantized) = Self::code_block(&residual, self.q.uv_dc, self.q.uv_ac, true);
let recon = idct(&dequantized);
let (bx, by) = (cx0 + (b & 1) * 4, cy0 + (b >> 1) * 4);
for r in 0..4 {
for c in 0..4 {
let i = ((b >> 1) * 4 + r) * 8 + (b & 1) * 4 + c;
let value = (i32::from(pred[i]) + recon[r * 4 + c]).clamp(0, 255) as u8;
if is_u {
self.rec_u.set(bx + c, by + r, value);
} else {
self.rec_v.set(bx + c, by + r, value);
}
}
}
blocks.push(Block::from_raster(&quantized, 0));
}
}
let (uv_mode, u_blocks, v_blocks) = self.encode_chroma(mbx, mby);
let skip = y2.as_ref().is_none_or(|b| !b.has_coeffs())
&& y_blocks.iter().all(|b| !b.has_coeffs())
+3 -1
View File
@@ -2,7 +2,9 @@
//! sub-block modes. Predictions read the frame's *unfiltered* reconstruction, which is what the
//! decoder predicts from too (the loop filter runs on the finished frame).
use super::tables::*;
#[cfg(test)]
use super::tables::{B_DC_PRED, DC_PRED};
use super::tables::{B_HD_PRED, B_HE_PRED, B_HU_PRED, B_LD_PRED, B_RD_PRED, B_TM_PRED, B_VE_PRED, B_VL_PRED, B_VR_PRED, H_PRED, TM_PRED, V_PRED};
/// A plane of the reconstruction with the virtual border VP8 defines: the row above the frame
/// reads as 127, the column left of it as 129, and the pixel above-left of the frame as 127.
+182
View File
@@ -398,6 +398,12 @@ fn pnm_header_alone_reserves_nothing_without_limits() {
}
}
#[test]
fn hdr_header_sizes_that_overflow_u64_are_errors_without_limits() {
let b = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2147483648 +X 2147483648\n";
assert!(decode_with(b, &opts(Limits::none())).is_err());
}
#[test]
fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() {
let m = u32::MAX;
@@ -456,3 +462,179 @@ fn limits_check_api() {
assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err());
assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok());
}
/// #8597: a small file whose header declares about 3 GB, inside the default limits, is refused
/// by the size-against-file check before the decoder allocates its output.
fn refused_as_implausible(f: Format, b: &[u8]) {
match decode(b) {
Err(CodecError::LimitExceeded(m)) => assert!(m.contains("cannot hold"), "{f:?}: {m}"),
other => panic!("{f:?}: expected an implausible-size refusal, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
fn find(b: &[u8], needle: &[u8]) -> usize {
b.windows(needle.len()).position(|w| w == needle).unwrap()
}
#[test]
fn jpeg_header_declaring_gigabytes_needs_a_plausible_file_size() {
let mut b = encoded(Format::Jpeg);
// SOF0: marker, length, precision, then height and width (big-endian).
let sof = find(&b, &[0xFF, 0xC0]);
b[sof + 5..sof + 7].copy_from_slice(&16000u16.to_be_bytes());
b[sof + 7..sof + 9].copy_from_slice(&65000u16.to_be_bytes());
// The decoder's own scan-size guard refuses it (one refusal path for truncated and complete
// files alike), before the backend allocates the frame.
match decode(&b) {
Err(e) => assert!(e.to_string().contains("too short for the frame"), "{e}"),
Ok(i) => panic!("decoded a {}×{} frame from {} bytes", i.width(), i.height(), b.len()),
}
}
#[test]
fn image_crate_headers_declaring_gigabytes_need_a_plausible_file_size() {
let side = 30000u32;
let mut bmp = encoded(Format::Bmp);
bmp[18..22].copy_from_slice(&(side as i32).to_le_bytes());
bmp[22..26].copy_from_slice(&(side as i32).to_le_bytes());
refused_as_implausible(Format::Bmp, &bmp);
let mut qoi = encoded(Format::Qoi);
// The qoi crate refuses more than 400 M pixels itself, so 20000 x 20000 (1.6 GB).
qoi[4..8].copy_from_slice(&20000u32.to_be_bytes());
qoi[8..12].copy_from_slice(&20000u32.to_be_bytes());
refused_as_implausible(Format::Qoi, &qoi);
let mut gif = encoded(Format::Gif);
gif[6..8].copy_from_slice(&(side as u16).to_le_bytes());
gif[8..10].copy_from_slice(&(side as u16).to_le_bytes());
refused_as_implausible(Format::Gif, &gif);
// The frame too. Descriptor: separator, left, top, width, height.
let frame = find(&gif, &[0x2C, 0, 0, 0, 0, 64, 0, 48, 0]);
gif[frame + 5..frame + 7].copy_from_slice(&(side as u16).to_le_bytes());
gif[frame + 7..frame + 9].copy_from_slice(&(side as u16).to_le_bytes());
refused_as_implausible(Format::Gif, &gif);
}
/// GIF89a with a two-colour global table and one frame at the top left holding no pixels or
/// one pixel (LZW minimum code size 2: clear, index 0, end of information).
fn tiny_gif(screen: (u16, u16), frame: (u16, u16)) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
for v in [screen.0, screen.1] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&[0x80, 0, 0, 0, 0, 0, 255, 255, 255, 0x2C, 0, 0, 0, 0]);
for v in [frame.0, frame.1] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&[0, 2, 2, 0x44, 0x01, 0, 0x3B]);
b
}
#[test]
fn a_gif_frame_much_smaller_than_its_screen_still_decodes() {
let img = decode(&tiny_gif((4000, 4000), (1, 1))).unwrap();
assert_eq!((img.width(), img.height()), (4000, 4000));
}
#[test]
fn a_tiny_gif_frame_does_not_admit_a_gigabyte_screen() {
refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (1, 1)));
// 324 MB of screen: past the 256 MiB that needs no data.
refused_as_implausible(Format::Gif, &tiny_gif((9000, 9000), (1, 1)));
refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (0, 0)));
// A frame bigger than its screen is decoded at its own size.
refused_as_implausible(Format::Gif, &tiny_gif((100, 100), (40000, 40000)));
}
#[test]
fn an_rle_bmp_ending_early_still_decodes() {
// 4000x4000 RLE8 whose only data is the end-of-bitmap escape: every pixel is left at index 0.
let data = 14 + 40 + 1024;
let mut b = b"BM".to_vec();
for v in [data + 2, 0, data, 40, 4000, 4000] {
b.extend_from_slice(&(v as u32).to_le_bytes());
}
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&8u16.to_le_bytes());
for v in [1, 2, 0, 0, 0, 0] {
b.extend_from_slice(&(v as u32).to_le_bytes());
}
b.resize(data, 0);
b.extend_from_slice(&[0, 1]);
let img = decode(&b).unwrap();
assert_eq!((img.width(), img.height()), (4000, 4000));
}
#[test]
fn pcx_header_declaring_gigabytes_needs_a_plausible_file_size() {
// 24-bit (three 8-bit planes) 65535 x 16384, with one row of data.
let mut b = vec![0u8; 128];
b[0] = 0x0a;
b[1] = 5;
b[2] = 1;
b[3] = 8;
b[8..10].copy_from_slice(&65534u16.to_le_bytes());
b[10..12].copy_from_slice(&16383u16.to_le_bytes());
b[65] = 3;
b[66..68].copy_from_slice(&65535u16.to_le_bytes());
b[68..70].copy_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&[0xff, 0]);
// PCX's own minimum-packet check refuses it before the output is allocated.
match decode(&b) {
Err(CodecError::Malformed { message, .. }) => assert!(message.contains("too short"), "{message}"),
other => panic!("expected a too-short refusal, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
#[test]
fn xpm_rows_shorter_than_the_header_declares_are_refused_before_allocating() {
let b = b"/* XPM */\nstatic char *x[] = {\n\"30000 30000 1 1\",\n\". c #000000\",\n\".\",\n};\n".to_vec();
match decode(&b) {
Err(CodecError::Malformed { .. }) => {}
other => panic!("expected a malformed-row error, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
#[test]
fn exr_header_declaring_gigabytes_needs_a_plausible_file_size() {
let mut b = encoded(Format::OpenExr);
// dataWindow: name, type, size, then xMin, yMin, xMax, yMax (little-endian i32).
let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4;
b[at + 8..at + 12].copy_from_slice(&15999i32.to_le_bytes());
b[at + 12..at + 16].copy_from_slice(&15999i32.to_le_bytes());
refused_as_implausible(Format::OpenExr, &b);
}
#[test]
fn a_dwab_exr_gets_a_looser_file_size_bound() {
// DWA passes 2^16:1 (a flat 8K frame with 12 alpha-scheme mattes is about 30 KB), so a DWAB
// part may declare 4 times more than a ZIP part from the same file, and no more.
let img = synth(1, 1, ChannelLayout::Rgba, SampleType::U8, 1, 0.1);
let mut b = encode(&img, Format::OpenExr, &EncodeOptions::default()).unwrap();
let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4;
let c = find(&b, b"compression\0compression\0") + b"compression\0compression\0".len() + 4;
// RGBA as f32: 16 bytes a pixel against a file of a few hundred bytes.
let size = |b: &mut Vec<u8>, side: i32| {
b[at + 8..at + 12].copy_from_slice(&(side - 1).to_le_bytes());
b[at + 12..at + 16].copy_from_slice(&(side - 1).to_le_bytes());
};
let refused = |b: &[u8]| matches!(exr_info(b, &Limits::default()), Err(CodecError::LimitExceeded(m)) if m.contains("cannot hold"));
size(&mut b, 2048);
b[c] = 3; // ZIP
assert!(refused(&b), "{} bytes", b.len());
for dwa in [8, 9] {
b[c] = dwa; // DWAA, DWAB
let parts = exr_info(&b, &Limits::default()).unwrap();
assert_eq!((parts[0].width, parts[0].height), (2048, 2048));
}
size(&mut b, 4096);
assert!(refused(&b), "{} bytes", b.len());
}
#[test]
fn highly_compressible_jpeg_and_gif_still_decode() {
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
for f in [Format::Jpeg, Format::Gif, Format::Qoi, Format::OpenExr] {
let b = encode(&img, f, &EncodeOptions::default()).unwrap();
assert_eq!(decode(&b).unwrap().width(), 3000, "{f:?}");
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use super::color::{bayer4, gradient};
use super::tone::{LUT_SIZE, lut, map_rgb, modern_brightness, modern_contrast, modern_lut, posterize_in, tone_luts_depth};
use super::*;
use super::{Adjustment, Buffer, ColorMode, LEGACY_PIVOT, SampleType, ToneSpace, Transfer, apply_depth, lab_to_srgb, rgb_to_gray, srgb_to_lab};
/// Legacy Brightness/Contrast on a 32-bit document's samples: unclipped, with contrast pivoting at
/// 127 of 255 (photoshop corpus rgb32 and gray32 brightness-contrast-legacy.psd: every pixel
+1 -1
View File
@@ -1,5 +1,5 @@
use super::tone::LUT_SIZE;
use super::*;
use super::{D50, HueRange, SRGB_TO_XYZ_D50, SampleType, Transfer, XYZ_D50_TO_SRGB, mat3_mul, srgb_to_linear};
/// Hue/Saturation's range edits as three hue-indexed tables (hue shift in degrees, saturation and
/// lightness as fractions), sampled at hue `k / (len - 1)` turns. Ranges add up where they overlap.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Adjustment, Buffer, ColorMode, CurvePoint, LevelsChannel, SampleType, ToneSpace, lab_to_srgb, srgb_to_lab};
/// Float RGB curve channels with no edit must preserve samples outside the LUT domain; edited
/// ones extend past it (see [`float_tone_extrapolates`]).
+1 -1
View File
@@ -2,7 +2,7 @@ use super::content::{channel_weights, empty_in, recycle_buffer, render_content,
use super::ctx::Ctx;
use super::extents::{effects_frame, mask_k, mask_vals, shapeless_stroke_bounds};
use super::fx_cache::effect_maps;
use super::*;
use super::{BlendMode, Buffer, Fill, Layer, LayerContent, Rect, Surface, advanced, blend, effects, psblend, shape_split};
/// Composite an isolated group's sibling list (bottom→top) onto `backdrop` (its own buffer).
pub(crate) fn composite_stack(layers: &[Layer], backdrop: &mut Buffer, cx: &Ctx) {
+1 -1
View File
@@ -2,7 +2,7 @@ use super::composite::{adjustment_quantum, blend_into, composite_stack};
use super::ctx::Ctx;
use super::extents::{effect_shape, effect_source_bounds, mask_vals, shapeless_stroke_bounds, transparent_outside};
use super::fx_cache::effect_maps;
use super::*;
use super::{BlendMode, Buffer, Document, Fill, Layer, LayerContent, Pattern, Rect, Surface, bounds, effects, gradient_fill, pattern, shape_split};
/// Tile size for parallel rendering (tile results are independent).
pub const RENDER_TILE: i32 = 256;
+1 -1
View File
@@ -1,6 +1,6 @@
use super::content::empty_in;
use super::fx_cache::{effect_maps, fx_over_budget, layer_identity};
use super::*;
use super::{Buffer, Document, Layer, LayerContent, Rect, adjust, advanced, effects, pattern};
type VectorMaskSlot = std::sync::Arc<std::sync::OnceLock<photocraft_vector::CompiledVectorMask>>;
+1 -1
View File
@@ -1,7 +1,7 @@
use super::blur::tent;
use super::distance::{INSIDE_EPS, bevel_dists, par_fill, par_units};
use super::paint::{apply_lut, bevel_contour_lut, contour_lut, lut_at, offset, satin_lut};
use super::*;
use super::{Bevel, BevelStyle, BevelTechnique, GlobalLight, Map, Pattern, Placement, PreparedPatterns, Rect};
/// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the
/// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside).
+1 -1
View File
@@ -1,5 +1,5 @@
use super::distance::{Metric, dist_outside_by};
use super::*;
use super::{MAX_REACH, Map};
/// The soft falloff of an effect of `size` pixels: two box passes of width `size` (a tent),
/// as Photoshop does. Fitted on psd-tools layer_effects.psd against the earlier Gaussian
+367 -228
View File
@@ -1,7 +1,10 @@
use super::bevel::{BevelPaint, TextureCtx, bevel_geom, bevel_maps, satin_map};
use super::distance::{INSIDE_EPS, Metric, dist_inside_by, dist_outside_by, local_coverage, stroke_outside_exact};
use super::paint::{dissolves_in, glow_map, paint_color, paint_fx, paint_glow, paint_pattern, rgb, shadow_map};
use super::*;
use super::{
BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement, PreparedPatterns, Rect,
StrokePosition, psblend,
};
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLight, tex: &TextureCtx) -> FxMaps {
@@ -176,230 +179,355 @@ pub(crate) fn composite_with_effects_prepared(
patterns: &PreparedPatterns<'_>,
vstroke: Option<VectorStroke<'_>>,
) {
let big = content.rect;
let (w, h) = (big.width() as usize, big.height() as usize);
// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
// Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were
// built), and the content's own transparency acts like fill opacity within it.
let shapeless = !layer.advanced.transparency_shapes;
let shape = if shapeless {
maps.crop(&maps.shape, big, 0.0)
} else if maps.outline {
let o = maps.crop(&maps.shape, big, 0.0);
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
} else {
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
};
let relative = shapeless || maps.outline || vstroke.is_some();
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
// Layer bounds (gradients aligned with the layer use the whole layer,
// independent of the render rect).
let sb = layer_bounds;
// Linked patterns tile from the effects reference point (else the layer's top-left).
let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0)));
let pass = EffectPass::new(layer, maps, content.rect, layer_bounds, patterns, vstroke.as_ref());
let big = pass.big;
let shape = pass.effect_shape(content);
let rect = backdrop.rect;
let before = backdrop.clone();
// Exterior effects are painted on a backdrop copy extended to `big`.
let mut work = Buffer::transparent(big);
for y in rect.y0.max(big.y0)..rect.y1.min(big.y1) {
for x in rect.x0.max(big.x0)..rect.x1.min(big.x1) {
work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] = before.get(x, y);
}
}
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
// Multiple instances: the first listed is on top, so paint in reverse.
let rev = || items.iter().copied().enumerate().rev();
for (i, e) in rev() {
if let Effect::DropShadow(s) = e {
let mut m = fx(i, 0);
if s.knocks_out {
// The layer hides the shadow beneath it where its fill is see-through (at 100 %
// fill in a normal mode it covers it anyway; at 0 % the shape shows the bare
// backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]).
knock_out_exterior(&mut m, &shape, 1.0 - shadow_see_through(layer));
}
paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in rev() {
if let Effect::OuterGlow(g) = e {
let mut m = fx(i, 0);
// The glow is behind the shape, not painted across its interior when Fill is 0 %.
// Preserve the existing anti-aliased edge when the fill is fully opaque.
knock_out_exterior(&mut m, &shape, layer.fill_opacity);
paint_glow(&mut work, &m, g, sb, anchor, big, patterns);
work.px[((y - big.y0) as usize) * pass.w + (x - big.x0) as usize] = before.get(x, y);
}
}
pass.paint_exterior(&mut work, &shape);
// The layer: content at fill opacity, then interior effects. Interior effects are painted
// relative to the layer's shape (alpha = coverage within the shape), then the shape's alpha
// applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and
// keeps its alpha, as in Photoshop.
let fill = layer.fill_opacity;
// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are
// combined with the content first, and fill opacity applies to the combination.
let interior_group = layer.advanced.blend_interior && fill < 1.0;
let content_fill = if interior_group { 1.0 } else { fill };
let inside = |a: f32| a > INSIDE_EPS;
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
if !inside(a) {
0.0
} else if relative {
content_fill * (kmask(i) * p[3] / a).min(1.0)
} else {
content_fill
}
};
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
let gamma = crate::text_gamma(layer);
let premerge = premerges_layer(layer);
let mut lay = pass.layer_pixels(content, &shape);
if premerge {
for (lp, wp) in lay.px.iter_mut().zip(&work.px) {
*lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma);
}
}
let rel = |m: Map| -> Map {
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
Map { w: m.w, h: m.h, v }
};
let full = Map { w, h, v: shape.v.iter().map(|a| if inside(*a) { 1.0 } else { 0.0 }).collect() };
for (_, e) in rev() {
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e {
paint_pattern(&mut lay, &full, patterns, name, id, Placement::anchored(anchor, *link, *phase, *scale, *angle), big, common.blend, common.opacity);
}
}
for (_, e) in rev() {
if let Effect::GradientOverlay { common, gradient, .. } = e {
paint_fx(&mut lay, &full, &FxPaint::Gradient(gradient.clone()), sb, anchor, big, common.blend, common.opacity, patterns)
}
}
for (_, e) in rev() {
if let Effect::ColorOverlay { common, color } = e {
paint_color(&mut lay, &full, rgb(color), common.blend, common.opacity);
}
}
for (i, e) in rev() {
if let Effect::Satin(s) = e {
let m = rel(fx(i, 0));
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in rev() {
if let Effect::InnerGlow(g) = e {
paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns);
}
}
if interior_group {
for p in &mut lay.px {
p[3] *= fill.max(0.0);
}
}
for (i, e) in rev() {
if let Effect::InnerShadow(s) = e {
let m = rel(fx(i, 0));
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
// interior effects, below the stroke effect (psd-tools stroke-composite).
if let Some(vs) = &vstroke {
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
if s[3] > 0.0 && inside(*a) {
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0);
}
}
}
// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
// are slid beneath it, so the first listed (top) instance is processed first.
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
let ready = !strokes.is_empty();
let din = if ready { maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default() } else { Vec::new() };
// A stroke's outer edge picks the chamfer or the exact field by its width
// ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist.
let crop_out = |v: &Option<Vec<f32>>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR));
let (dout, dout_exact, vdout, vdout_exact) =
if ready { (crop_out(&maps.dout), crop_out(&maps.dout_exact), crop_out(&maps.vdout), crop_out(&maps.vdout_exact)) } else { (None, None, None, None) };
let widths = stroke_widths;
// A gradient stroke aligned with the layer spans the stroke's own extent.
let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb);
for st in strokes.iter().rev().copied() {
let (in_w, _) = widths(st);
if in_w <= 0.0 {
continue;
}
let mut m = Map::new(w, h, 0.0);
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) {
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
}
paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns);
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Inner
{
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut lay, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
let strokes: Vec<&photocraft_doc::StrokeFx> = pass.items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
let fields = pass.stroke_fields(!strokes.is_empty());
pass.paint_interior(&mut lay, &shape);
pass.paint_vector_stroke(&mut lay, &shape);
pass.paint_inside_strokes(&mut lay, &shape, &strokes, &fields);
pass.paint_bevel(&mut lay, BevelPaint::Inner, Some(&shape));
// The shape's own alpha.
for (p, a) in lay.px.iter_mut().zip(&shape.v) {
p[3] *= a.min(1.0);
}
// Shape layers without a filled outline stroke their vector outline, estimated from local
// coverage; every other layer strokes its shape. The width then picks the field.
let vector_outline = vector_shape && !maps.outline;
// Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A
// higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider
// Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its
// coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking
// them top over bottom.
if strokes.iter().any(|st| widths(st).1 > 0.0) {
pass.paint_outside_strokes(&mut work, &lay, &shape, &strokes, &fields);
pass.paint_bevel(&mut work, BevelPaint::Outer, None);
// Layer (with interior effects) onto the exterior result, in the layer's mode.
let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
for (wp, lp) in work.px.iter_mut().zip(&lay.px) {
if lp[3] > 0.0 {
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
}
}
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
pass.paint_bevel(&mut work, BevelPaint::Both, None);
pass.write_back(backdrop, &before, &work);
}
/// `true` where the effect shape covers a pixel enough for interior effects to paint.
fn inside(a: f32) -> bool {
a > INSIDE_EPS
}
/// An effect map as a fraction of the shape's own coverage.
fn relative_to_shape(m: Map, shape: &Map) -> Map {
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
Map { w: m.w, h: m.h, v }
}
/// A stroke's distance fields, cropped to the render region; only the ones a stroke needs exist.
#[derive(Default)]
struct StrokeFields {
/// Distance inside the shape.
din: Vec<f32>,
dout: Option<Vec<f32>>,
dout_exact: Option<Vec<f32>>,
vdout: Option<Vec<f32>>,
vdout_exact: Option<Vec<f32>>,
}
/// What compositing one layer's effects shares between its stages.
struct EffectPass<'a> {
layer: &'a Layer,
maps: &'a FxMaps,
patterns: &'a PreparedPatterns<'a>,
vstroke: Option<&'a VectorStroke<'a>>,
/// The region effects are painted over: the content's rect.
big: Rect,
w: usize,
h: usize,
/// Layer bounds (gradients aligned with the layer use the whole layer, independent of the
/// render rect).
sb: Rect,
/// Linked patterns tile from the effects reference point (else the layer's top-left).
anchor: (f64, f64),
/// The enabled effects. Multiple instances: the first listed is on top, so paint in reverse.
items: Vec<&'a Effect>,
}
impl<'a> EffectPass<'a> {
fn new(
layer: &'a Layer,
maps: &'a FxMaps,
big: Rect,
layer_bounds: Rect,
patterns: &'a PreparedPatterns<'a>,
vstroke: Option<&'a VectorStroke<'a>>,
) -> Self {
let sb = layer_bounds;
Self {
layer,
maps,
patterns,
vstroke,
big,
w: big.width() as usize,
h: big.height() as usize,
sb,
anchor: layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0))),
items: layer.effects.items.iter().filter(|e| e.enabled()).collect(),
}
}
/// The enabled effects with their indexes, last listed first.
fn rev(&self) -> impl Iterator<Item = (usize, &'a Effect)> + '_ {
self.items.iter().copied().enumerate().rev()
}
/// Effect `i`'s map `k`, cropped to the region.
fn fx(&self, i: usize, k: usize) -> Map {
self.maps.crop(&self.maps.per[i][k], self.big, 0.0)
}
/// The vector stroke's layer-mask value at pixel `i` (1 with no mask).
fn kmask(&self, i: usize) -> f32 {
self.vstroke.and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0)
}
/// Alpha `a` of pixel `i` joined with the vector stroke's.
fn union(&self, i: usize, a: f32) -> f32 {
self.vstroke.and_then(|v| v.stroke.px.get(i)).map_or(a, |s| self.kmask(i) * (a + s[3] * (1.0 - a)))
}
/// Within an outline (or a split-off vector stroke), or without Transparency Shapes Layer,
/// the content's own transparency acts like fill opacity: the effects still cover the whole
/// shape.
fn relative(&self) -> bool {
!self.layer.advanced.transparency_shapes || self.maps.outline || self.vstroke.is_some()
}
/// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
/// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
fn effect_shape(&self, content: &Buffer) -> Map {
let (w, h, big) = (self.w, self.h, self.big);
// Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were
// built), and the content's own transparency acts like fill opacity within it.
if !self.layer.advanced.transparency_shapes {
self.maps.crop(&self.maps.shape, big, 0.0)
} else if self.maps.outline {
let o = self.maps.crop(&self.maps.shape, big, 0.0);
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(self.union(i, p[3]))).collect() }
} else {
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| self.union(i, p[3])).collect() }
}
}
/// Paints the effects behind the layer: drop shadows, then outer glows.
fn paint_exterior(&self, work: &mut Buffer, shape: &Map) {
for (i, e) in self.rev() {
if let Effect::DropShadow(s) = e {
let mut m = self.fx(i, 0);
if s.knocks_out {
// The layer hides the shadow beneath it where its fill is see-through (at 100 %
// fill in a normal mode it covers it anyway; at 0 % the shape shows the bare
// backdrop), and wholly in a mode that blends with it ([`shadow_see_through`]).
knock_out_exterior(&mut m, shape, 1.0 - shadow_see_through(self.layer));
}
paint_color(work, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in self.rev() {
if let Effect::OuterGlow(g) = e {
let mut m = self.fx(i, 0);
// The glow is behind the shape, not painted across its interior when Fill is 0 %.
// Preserve the existing anti-aliased edge when the fill is fully opaque.
knock_out_exterior(&mut m, shape, self.layer.fill_opacity);
paint_glow(work, &m, g, self.sb, self.anchor, self.big, self.patterns);
}
}
}
/// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are
/// combined with the content first, and fill opacity applies to the combination.
fn interior_group(&self) -> bool {
self.layer.advanced.blend_interior && self.layer.fill_opacity < 1.0
}
/// The layer's own pixels at fill opacity, with their alpha made relative to `shape`.
fn layer_pixels(&self, content: &Buffer, shape: &Map) -> Buffer {
let content_fill = if self.interior_group() { 1.0 } else { self.layer.fill_opacity };
let relative = self.relative();
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
if !inside(a) {
0.0
} else if relative {
content_fill * (self.kmask(i) * p[3] / a).min(1.0)
} else {
content_fill
}
};
Buffer { rect: self.big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }
}
/// Paints the interior effects other than strokes and bevels: overlays, satin, inner glow
/// and inner shadow.
fn paint_interior(&self, lay: &mut Buffer, shape: &Map) {
let (w, h, big, sb, anchor, patterns) = (self.w, self.h, self.big, self.sb, self.anchor, self.patterns);
let full = Map { w, h, v: shape.v.iter().map(|a| if inside(*a) { 1.0 } else { 0.0 }).collect() };
for (_, e) in self.rev() {
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e {
paint_pattern(lay, &full, patterns, name, id, Placement::anchored(anchor, *link, *phase, *scale, *angle), big, common.blend, common.opacity);
}
}
for (_, e) in self.rev() {
if let Effect::GradientOverlay { common, gradient, .. } = e {
paint_fx(lay, &full, &FxPaint::Gradient(gradient.clone()), sb, anchor, big, common.blend, common.opacity, patterns)
}
}
for (_, e) in self.rev() {
if let Effect::ColorOverlay { common, color } = e {
paint_color(lay, &full, rgb(color), common.blend, common.opacity);
}
}
for (i, e) in self.rev() {
if let Effect::Satin(s) = e {
let m = relative_to_shape(self.fx(i, 0), shape);
paint_color(lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in self.rev() {
if let Effect::InnerGlow(g) = e {
paint_glow(lay, &relative_to_shape(self.fx(i, 0), shape), g, sb, anchor, big, patterns);
}
}
if self.interior_group() {
for p in &mut lay.px {
p[3] *= self.layer.fill_opacity.max(0.0);
}
}
for (i, e) in self.rev() {
if let Effect::InnerShadow(s) = e {
let m = relative_to_shape(self.fx(i, 0), shape);
paint_color(lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
}
/// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
/// interior effects, below the stroke effect (psd-tools stroke-composite).
fn paint_vector_stroke(&self, lay: &mut Buffer, shape: &Map) {
let Some(vs) = self.vstroke else {
return;
};
let fill = self.layer.fill_opacity;
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
if s[3] > 0.0 && inside(*a) {
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (self.kmask(i) * s[3] / a).min(1.0)], 1.0);
}
}
}
/// The distance fields the strokes need, cropped to the region; empty unless `ready` (the
/// layer has a stroke).
///
/// A stroke's outer edge picks the chamfer or the exact field by its width
/// ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist.
fn stroke_fields(&self, ready: bool) -> StrokeFields {
if !ready {
return StrokeFields::default();
}
let (maps, big) = (self.maps, self.big);
let crop_out = |v: &Option<Vec<f32>>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR));
StrokeFields {
din: maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default(),
dout: crop_out(&maps.dout),
dout_exact: crop_out(&maps.dout_exact),
vdout: crop_out(&maps.vdout),
vdout_exact: crop_out(&maps.vdout_exact),
}
}
/// A gradient stroke aligned with the layer spans the stroke's own extent.
fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Rect {
self.maps.stroke_frame(st).unwrap_or(self.sb)
}
/// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
/// are slid beneath it, so the first listed (top) instance is processed first.
fn paint_inside_strokes(&self, lay: &mut Buffer, shape: &Map, strokes: &[&photocraft_doc::StrokeFx], fields: &StrokeFields) {
for st in strokes.iter().rev().copied() {
let (in_w, _) = stroke_widths(st);
if in_w <= 0.0 {
continue;
}
let mut m = Map::new(self.w, self.h, 0.0);
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&fields.din) {
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
}
paint_fx(lay, &m, &st.paint, self.stroke_frame(st), self.anchor, self.big, st.common.blend, st.common.opacity, self.patterns);
}
}
/// Bevel and emboss effects whose paint is `kind`. Inner ones paint into the layer relative
/// to its `shape`; the others paint the maps as they are.
fn paint_bevel(&self, target: &mut Buffer, kind: BevelPaint, shape: Option<&Map>) {
for (i, e) in self.rev() {
if let Effect::BevelEmboss(b) = e
&& self.maps.bevel_paint[i] == kind
{
let map = |k: usize| match shape {
Some(shape) => relative_to_shape(self.fx(i, k), shape),
None => self.fx(i, k),
};
paint_color(target, &map(0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(target, &map(1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
}
/// The outside parts of the strokes, blended onto the backdrop beneath the layer with their
/// own mode. A higher stroke covers the ones below it (Photoshop: a Multiply stroke listed
/// above a wider Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's
/// share is its coverage × opacity not yet taken by strokes above; for Normal strokes this
/// equals stacking them top over bottom.
fn paint_outside_strokes(&self, work: &mut Buffer, lay: &Buffer, shape: &Map, strokes: &[&photocraft_doc::StrokeFx], fields: &StrokeFields) {
if !strokes.iter().any(|st| stroke_widths(st).1 > 0.0) {
return;
}
let (w, h) = (self.w, self.h);
let base = work.clone();
let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended
let mut cover = vec![0.0f32; w * h];
for st in strokes.iter().copied() {
let (_, out_w) = widths(st);
let (_, out_w) = stroke_widths(st);
if out_w <= 0.0 {
continue;
}
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
// filled shape's outline it covers the part of each edge pixel outside the path.
let exact = stroke_outside_exact(out_w);
let d: &[f32] = if vector_outline {
(if exact { vdout_exact.as_deref() } else { vdout.as_deref() }).unwrap_or_default()
} else if exact {
dout_exact.as_deref().unwrap_or_default()
} else {
dout.as_deref().unwrap_or_default()
};
let mut share = vec![0.0f32; w * h];
for (i, sh) in share.iter_mut().enumerate() {
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
let k =
if inside(shape.v[i]) { if vector_shape && !maps.outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band };
let mut k = k * st.common.opacity;
if st.common.blend == BlendMode::Dissolve {
let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);
k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 };
}
*sh = k * (1.0 - cover[i]);
cover[i] += *sh;
}
let share = self.outside_share(st, out_w, shape, lay, fields, &mut cover);
// The stroke blended over the backdrop at full coverage.
let mut blended = base.clone();
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
// The share already carries the Dissolve choice, so the paint itself composites as Normal.
let paint_blend = if st.common.blend == BlendMode::Dissolve { BlendMode::Normal } else { st.common.blend };
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, paint_blend, 1.0, patterns);
paint_fx(&mut blended, &ones, &st.paint, self.stroke_frame(st), self.anchor, self.big, paint_blend, 1.0, self.patterns);
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
if *s > 0.0 {
for c in 0..3 {
@@ -420,48 +548,59 @@ pub(crate) fn composite_with_effects_prepared(
}
}
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Outer
{
let (hi, sh) = (fx(i, 0), fx(i, 1));
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
/// The share of each pixel an outside stroke `st` (outside width `out_w`) takes: its coverage
/// × opacity not yet taken by the strokes above, which `cover` accumulates.
fn outside_share(&self, st: &photocraft_doc::StrokeFx, out_w: f32, shape: &Map, lay: &Buffer, fields: &StrokeFields, cover: &mut [f32]) -> Vec<f32> {
let (w, big) = (self.w, self.big);
// Shape layers without a filled outline stroke their vector outline, estimated from local
// coverage; every other layer strokes its shape. The width then picks the field.
let vector_shape = matches!(self.layer.content, photocraft_doc::LayerContent::Shape(_));
let vector_outline = vector_shape && !self.maps.outline;
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
// filled shape's outline it covers the part of each edge pixel outside the path.
let exact = stroke_outside_exact(out_w);
let d: &[f32] = match (vector_outline, exact) {
(true, true) => fields.vdout_exact.as_deref(),
(true, false) => fields.vdout.as_deref(),
(false, true) => fields.dout_exact.as_deref(),
(false, false) => fields.dout.as_deref(),
}
.unwrap_or_default();
let mut share = vec![0.0f32; w * self.h];
for (i, sh) in share.iter_mut().enumerate() {
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
let k = if inside(shape.v[i]) { if vector_outline { 0.0 } else { band * outline_share(shape.v[i], lay.px[i][3]) } } else { band };
let mut k = k * st.common.opacity;
if st.common.blend == BlendMode::Dissolve {
let (x, y) = (i64::from(big.x0) + (i % w) as i64, i64::from(big.y0) + (i / w) as i64);
k = if k > 0.0 && dissolves_in(x, y, k) { 1.0 } else { 0.0 };
}
*sh = k * (1.0 - cover[i]);
cover[i] += *sh;
}
share
}
// Layer (with interior effects) onto the exterior result, in the layer's mode.
let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
for (wp, lp) in work.px.iter_mut().zip(&lay.px) {
if lp[3] > 0.0 {
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
}
}
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Both
{
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// Layer opacity applies to the whole stack.
let op = layer.opacity;
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
let a = before.px[i];
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
backdrop.px[i] = if layer.blend == BlendMode::Dissolve {
// Dissolve applies layer opacity to the completed interior/exterior effect
// stack as pixel coverage, not continuous gray interpolation (#3051).
if crate::dissolve_noise(x, y) < op { b } else { a }
} else {
mix_premul(a, b, op)
};
/// Writes the finished stack `work` into `backdrop` (over `before`) at layer opacity.
fn write_back(&self, backdrop: &mut Buffer, before: &Buffer, work: &Buffer) {
let (big, w) = (self.big, self.w);
let rect = backdrop.rect;
let op = self.layer.opacity;
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
let a = before.px[i];
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
backdrop.px[i] = if self.layer.blend == BlendMode::Dissolve {
// Dissolve applies layer opacity to the completed interior/exterior effect
// stack as pixel coverage, not continuous gray interpolation (#3051).
if crate::dissolve_noise(x, y) < op { b } else { a }
} else {
mix_premul(a, b, op)
};
}
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::Map;
/// 1D squared distance transform (Felzenszwalb & Huttenlocher).
fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32], near: &mut [usize]) {
+4 -1
View File
@@ -1,6 +1,9 @@
use super::blur::{blur, dilate};
use super::distance::{dist_inside, dist_outside};
use super::*;
use super::{
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
Shadow, psblend,
};
pub(super) fn contour_lut(c: &Contour) -> Option<Vec<f32>> {
match c {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::content::{render_content, surface_to_buffer};
use super::ctx::Ctx;
use super::*;
use super::{BlendMode, Document, Layer, LayerContent, Rect, advanced, bounds, effects, masks, shape_split};
/// Bounds of a layer's own pixels (union over group children; the canvas
/// for fill layers; an artboard's board).
+1 -1
View File
@@ -1,7 +1,7 @@
use super::composite::texture_ctx;
use super::ctx::{Ctx, PREPARE_DEPTH};
use super::extents::{effect_shape, effect_source_bounds};
use super::*;
use super::{Layer, LayerContent, Rect, Surface, advanced, effects};
// ---------------------------------------------------------------------------------------------
// Layer-effect map cache
+1 -1
View File
@@ -1,7 +1,7 @@
use super::content::RENDER_TILE;
use super::ctx::Ctx;
use super::render::{band_rows_for, render, render_bands, render_tiled_with};
use super::*;
use super::{Buffer, Document, Rect, Rgba8Image, pattern, proxy, uniform};
/// Documents above this many pixels get thumbnails from a proxy (see [`proxy`]) when that is
/// faithful; smaller ones are reduced from the exact composite.
+1 -1
View File
@@ -1,7 +1,7 @@
use super::composite::composite_stack_at;
use super::content::RENDER_TILE;
use super::ctx::{Ctx, RenderVectorMasks, prepare_effects, prepare_tones};
use super::*;
use super::{Buffer, Document, Layer, Rect, Surface, adjust, advanced, cmyk, multichannel, native, native_lab, pattern, psblend};
/// Every layer the compositor draws, groups before their children: hidden layers and everything
/// inside hidden groups are left out.
+8
View File
@@ -63,6 +63,11 @@ fn every_kind_round_trips_through_params() {
#[test]
fn params_reach_the_model() {
basic_params_reach_the_model();
color_space_params_reach_the_model();
}
fn basic_params_reach_the_model() {
let a = from_params("colorBalance", &json!({"midtones": [50, 0, -200]}), None, ColorMode::Rgb).unwrap();
assert_eq!(a, Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [50.0, 0.0, -100.0], highlights: [0.0; 3], preserve_luminosity: true });
let a = from_params("channelMixer", &json!({"monochrome": true}), None, ColorMode::Rgb).unwrap();
@@ -82,6 +87,9 @@ fn params_reach_the_model() {
assert!(matches!(a, Adjustment::HueSaturation { colorize: true, saturation: 0.0, hue: 120.0, .. }), "{a:?}");
let a = from_params("gradientMap", &json!({"stops": [[1, "#ffffff"], [0, "#000000"]], "dither": true}), None, ColorMode::Rgb).unwrap();
assert!(matches!(&a, Adjustment::GradientMap { stops, dither: true, .. } if stops[0].0 == 0.0));
}
fn color_space_params_reach_the_model() {
// CMYK curves: ink channels; Lab: lightness without a composite.
let a = from_params("curves", &json!({"black": [[0, 0], [255, 200]]}), None, ColorMode::Rgb).unwrap();
assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Cmyk, black, .. } if black.len() == 2));
+31 -21
View File
@@ -82,6 +82,7 @@ pub mod file_cmds;
pub mod fill_cmds;
pub mod fill_key_cmds;
pub mod float_cmds;
pub mod font_library_cmds;
pub(super) mod frame_cmds;
pub mod fx_view_cmds;
pub mod fx_visibility_cmds;
@@ -462,26 +463,40 @@ fn build() -> Vec<CommandSpec> {
"Layer…",
["Layer", "New"],
Some("Cmd+Shift+N"),
r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)}"##,
r##"{"name":str?,"clipToPrevious":bool=false (Use Previous Layer to Create Clipping Mask; inside a clip group the layer joins it anyway),"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":"Normal|Multiply|…"="Normal","opacity":0..1=1,"fillNeutral":bool=false (fill with the mode's neutral color: modes without one are an error),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)} (above the active layer, or the top layer inside it when it is an open group)"##,
has_doc,
crate::new_layer::run
),
cmd!("layer.new.group", "Group…", ["Layer", "New"], None, r##"{"name":str?}"##, has_doc, |s, p| {
let id = s.edit("New Group", |doc, active| {
let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| doc.next_layer_name("Group"));
let id = doc.insert_above(*active, Layer::group(name, vec![]));
*active = Some(id);
Ok(id)
})?;
crate::layer_multi_cmds::note_inert_insert(s, id);
Ok(json!({ "layer": id.0 }))
}),
cmd!(
"layer.new.group",
"Group…",
["Layer", "New"],
None,
r##"{"name":text?,"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":str="passThrough" (blend mode),"opacity":0..100=100 (percent),"position":"above|below"="above" (below: the Layers panel's Cmd/Ctrl-click; never under the Background)} (above the active layer, or inside it when it is an open group; clipped when it lands inside a clipping mask)"##,
has_doc,
|s, p| {
let opts = crate::group_options::GroupOptions::parse("layer.new.group", p)?;
let below = crate::group_options::below("layer.new.group", p)?;
let place = if below { crate::layer_place::Place::Below } else { crate::layer_place::Place::Auto };
let id = s.edit("New Group", |doc, active| {
let name = opts.name.clone().unwrap_or_else(|| doc.next_layer_name("Group"));
let mut group = Layer::group(name, vec![]);
opts.apply(&mut group);
let bg = crate::background_cmds::background_id(doc);
let id = crate::layer_place::insert_new(doc, *active, group, place);
crate::background_cmds::keep_background_at_bottom(bg, doc)?;
*active = Some(id);
Ok(id)
})?;
Ok(json!({ "layer": id.0 }))
}
),
cmd!(
"layer.groupLayers",
"Group Layers",
["Layer"],
Some("Cmd+G"),
r##"{"layer":id?,"name":str?} (no layer: every selected layer)"##,
r##"{"layer":id?,"name":text?,"color":"none|red|orange|yellow|green|seafoam|blue|indigo|magenta|fuchsia|violet|gray"="none","mode":str="passThrough" (blend mode),"opacity":0..100=100 (percent)} (no layer: every selected layer; the new group starts collapsed)"##,
has_layer,
crate::layer_multi_cmds::group_layers
),
@@ -614,9 +629,6 @@ fn build() -> Vec<CommandSpec> {
["name"] => "Rename Layer",
_ => "Layer Properties",
};
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
// Clipping and channel changes reach the layers around it: recomposite everything.
let local = p.get("clipped").is_none() && p.get("channels").is_none();
// The eye: a history state only with History Options › Make Layer Visibility
// Changes Undoable.
let only_visibility = changed.as_slice() == ["visible"];
@@ -672,9 +684,6 @@ fn build() -> Vec<CommandSpec> {
}
Ok(())
})?;
if local {
crate::layer_multi_cmds::note_damage(s, &before, &ids);
}
Ok(Value::Null)
}
),
@@ -802,7 +811,7 @@ fn build() -> Vec<CommandSpec> {
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
let id = doc.insert_above(*active, l);
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
*active = Some(id);
Ok(id)
})?;
@@ -834,7 +843,7 @@ fn build() -> Vec<CommandSpec> {
.in_mode(doc.mode);
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.gradient", default_mask)?;
let id = doc.insert_above(*active, l);
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
*active = Some(id);
Ok(id)
})?;
@@ -934,7 +943,7 @@ fn build() -> Vec<CommandSpec> {
"Brush Stroke",
[],
None,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…BrushSettings}?,"preset":name?,"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..1?,"flow":0..1?,"fill":bool=false (fill the region the closed `points` enclose with the brush colour, its edge anti-aliased and softened by hardness, instead of stamping the outline; a closed gesture paints a filled dab, not a ring),"spacing":0..10?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…|clear (erases with this brush)"="normal","erase":bool?,"smoothing":0..1?,"zoom":number=1,"seed":u64?,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target}"##,
has_paintable,
crate::brush_cmds::paint_stroke
),
@@ -1316,6 +1325,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::distort_cmds::specs());
v.extend(crate::analysis_cmds::specs());
v.extend(crate::notes_cmds::specs());
v.extend(crate::font_library_cmds::specs());
v.extend(crate::history_cmds::specs());
v.extend(crate::history_options_cmds::specs());
v.extend(crate::history_menu_cmds::specs());
+2 -2
View File
@@ -1,6 +1,6 @@
use super::enabled::{check_all_unlocked, is_mask_target, paint_surface};
use super::layer_params::{layer_param, mask_new_layer};
use super::*;
use super::{Adjustment, ColorMode, EngineError, Layer, LayerContent, Map, Result, Session, Value, json, pixels};
/// Add an adjustment layer above the active one; it takes the mask `mask_new_layer` builds from `p` (a path, the selection, or the default white mask).
pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &str) -> Result<Value> {
@@ -9,7 +9,7 @@ pub(super) fn new_adjustment(s: &mut Session, adj: Adjustment, p: &Value, cmd: &
let id = s.edit(&label, |doc, active| {
let mut l = Layer::new(doc.next_layer_name(&name), LayerContent::Adjustment(adj));
mask_new_layer(doc, &mut l, p, cmd, true)?;
let id = doc.insert_above(*active, l);
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
*active = Some(id);
Ok(id)
})?;
+2 -2
View File
@@ -313,7 +313,7 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
surf.write_region(area, px);
surf.prune();
}
below = doc.insert_above(Some(below), l);
below = crate::layer_place::insert_new(doc, Some(below), l, crate::layer_place::Place::Above);
below
});
let Some(px) = &blended else {
@@ -327,7 +327,7 @@ fn auto_blend(s: &mut Session, p: &Value) -> Result<Value> {
surf.write_region(area, px);
surf.prune();
}
let nid = doc.insert_above(Some(below), l);
let nid = crate::layer_place::insert_new(doc, Some(below), l, crate::layer_place::Place::Above);
*active = Some(nid);
Ok((Some(nid), depth_id))
})?;
+28 -4
View File
@@ -320,12 +320,35 @@ fn artboard_from_group(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
/// The layers' union bounds, or the canvas when they have none: the board Artboard from Layers
/// makes when no size is given.
fn union_bounds<'a>(layers: impl IntoIterator<Item = &'a Layer>, canvas: Rect) -> Rect {
layers.into_iter().filter_map(crate::layer_multi_cmds::layer_bounds).reduce(|a, b| a.union(&b)).unwrap_or(canvas)
}
/// What Artboard from Layers' dialog opens with, as in the reference application: the name the
/// board would get and the selected top-level layers' size (`None` without a document).
pub fn from_layers_defaults(s: &Session) -> Option<(String, [i64; 2])> {
let d = s.active()?;
let ids = crate::layer_multi_cmds::top_level(&d.doc, &crate::layer_multi_cmds::selected(s));
let [_, _, w, h] = xywh(union_bounds(ids.iter().filter_map(|id| d.doc.layer(*id)), d.doc.bounds()));
Some((next_artboard_name(&d.doc), [w, h]))
}
fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
let sel = crate::layer_multi_cmds::selected(s);
let d = s.active().ok_or(EngineError::NoDocument)?;
let canvas = d.doc.bounds();
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
let background = background_param(p, "layer.new.artboardFromLayers")?.unwrap_or_default();
let name = p.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).map(str::to_string);
let cmd = "layer.new.artboardFromLayers";
let background = background_param(p, cmd)?.unwrap_or_default();
// `int` rounds, so a small negative (-0.4) would pass as 0, "keep the bounds".
if ["width", "height"].iter().any(|k| p.get(*k).and_then(Value::as_f64).is_some_and(|v| v < 0.0)) {
return Err(bad(cmd, "the artboard width and height must not be negative"));
}
// A size of the user's own (a post, a story…) from the layers' top left; 0 or none keeps
// their bounds on that axis (#9087).
let size = [int(p, "width").filter(|v| *v != 0), int(p, "height").filter(|v| *v != 0)];
let (gid, rect) = s.edit("Artboard from Layers", |doc, active| {
let ids = crate::layer_multi_cmds::top_level(doc, &sel);
if ids.is_empty() || ids.iter().any(|id| doc.artboard_of(*id).is_some()) {
@@ -335,7 +358,8 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
children.push(doc.remove(*id).ok_or(EngineError::NoLayer(*id))?);
}
let rect = children.iter().filter_map(crate::layer_multi_cmds::layer_bounds).reduce(|a, b| a.union(&b)).unwrap_or(canvas);
let [x, y, w, h] = xywh(union_bounds(&children, canvas));
let rect = board_rect(cmd, [x, y, size[0].unwrap_or(w), size[1].unwrap_or(h)])?;
let mut g = Layer::group(name.unwrap_or_else(|| next_artboard_name(doc)), children);
set_artboard(&mut g, Artboard { rect, background, preset: String::new() });
let gid = doc.insert_above(None, g);
@@ -687,7 +711,7 @@ pub fn specs() -> Vec<CommandSpec> {
"layer.new.artboardFromLayers",
"Artboard from Layers…",
&["Layer", "New"],
r##"{"name":str?,"background":…?} (the selected layers) → {layer, rect}"##,
r##"{"name":text,"background":"white|black|transparent|custom"="white","color":color,"width":px=0,"height":px=0} (the selected layers; name: empty gives Artboard N; color: for custom; width, height: the board's size from the layers' top left, 0 keeps their bounds) → {layer, rect}"##,
has_free_layers,
artboard_from_layers
),
@@ -690,3 +690,43 @@ fn new_document_with_an_artboard() {
assert!(doc(&s).artboards().is_empty());
assert!(s.execute("file.new", json!({"artboard": "yes"})).is_err());
}
/// #9087: Artboard from Layers takes a size of the user's own, from the layers' top left; 0 or
/// none keeps their bounds on that axis, and a negative size is refused.
#[test]
fn artboard_from_layers_takes_a_custom_size() {
for (params, want) in [
(json!({"width": 108, "height": 135}), [10, 10, 108, 135]),
(json!({"width": 0, "height": 0}), [10, 10, 20, 20]),
(json!({"width": 60}), [10, 10, 60, 20]),
] {
let mut s = session(8);
add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
let r = s.execute("layer.new.artboardFromLayers", params.clone()).unwrap();
assert_eq!(r["rect"], json!(want), "{params}");
}
let mut s = session(8);
add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
assert!(s.execute("layer.new.artboardFromLayers", json!({"width": -5})).is_err());
// `int` rounds -0.4 to 0, which would otherwise keep the bounds.
assert!(s.execute("layer.new.artboardFromLayers", json!({"height": -0.4})).is_err());
assert!(doc(&s).artboards().is_empty(), "a refused size changes nothing");
assert_eq!(s.execute("layer.new.artboardFromLayers", json!({"name": ""})).unwrap()["rect"], json!([10, 10, 20, 20]));
assert_eq!(doc(&s).artboards()[0].1, "Artboard 1", "an empty name gives Artboard N");
}
/// The dialog's defaults (#9087): the selected layers' size and the board's name, as the command
/// makes it with no size; the canvas when the layers have no pixels.
#[test]
fn from_layers_defaults_are_the_selected_layers_size() {
let mut s = session(8);
add_square(&mut s, Rect::new(10, 10, 30, 40), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(from_layers_defaults(&s), Some(("Artboard 1".to_string(), [20, 30])));
s.execute("layer.new.artboardFromLayers", json!({})).unwrap();
add_square(&mut s, Rect::new(50, 5, 60, 10), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(from_layers_defaults(&s), Some(("Artboard 2".to_string(), [10, 5])));
let mut s = session(8);
s.execute("layer.new.layer", json!({})).unwrap();
assert_eq!(from_layers_defaults(&s), Some(("Artboard 1".to_string(), [100, 80])), "an empty layer: the canvas");
assert_eq!(from_layers_defaults(&Session::new()), None);
}
@@ -1,7 +1,7 @@
use super::descriptors::{color_desc, color_hex, desc, enm, get_bool, get_enum, get_list, get_num, get_obj, jbool, jnum, jstr, long, num, pct, text};
use super::fields::{F, Kind, Mapped, fields_from, fields_to, step};
use super::tables::{CHANNELS, channel_key, channel_ref, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Map, Value, json};
// ------------------------------------------------------------------ adjustments
+1 -1
View File
@@ -1,7 +1,7 @@
use super::descriptors::{desc, get_obj, jstr};
use super::events::{event_and_descriptor, event_for};
use super::fields::Mapped;
use super::*;
use super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, D, DEFAULT_SET, Descriptor, PHOTOSHOP_STEP, UnicodeString, Value, atn, json};
// ------------------------------------------------------------------ import
@@ -1,4 +1,4 @@
use super::*;
use super::{Class, D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json};
// ------------------------------------------------------------------ descriptor access
+1 -1
View File
@@ -16,7 +16,7 @@ use super::steps_layer::{
};
use super::steps_make::{make_from, make_to, set_from, set_to};
use super::tables::{NOISE, OFFSET_FILL, SMART_BLUR_MODE, SMART_BLUR_QUALITY, TRIM};
use super::*;
use super::{AtnEvent, D, Descriptor, STOP_MESSAGE, Value, json};
/// An event: code (empty when Photoshop only has a string name), string name, the panel
/// label, the command ids it exports (the first is the one simple events import to), and the
+1 -1
View File
@@ -1,6 +1,6 @@
use super::descriptors::{ang, color_desc, color_hex, enm, get_bool, get_enum, get_num, get_obj, long, pct, px};
use super::tables::{blend_label, blend_ps, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Map, Value, json};
// ------------------------------------------------------------------ field tables
+1 -1
View File
@@ -1,5 +1,5 @@
use super::convert::{UniqueNames, export, import};
use super::*;
use super::{Action, DEFAULT_SET, EngineError, Result, Session, Value, bad, json};
pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "actions.load";
+1 -1
View File
@@ -5,7 +5,7 @@ use super::descriptors::{
};
use super::fields::{Mapped, none, step, steps};
use super::tables::{ALIGN, EXTENSION, INTENT, INTERPOLATION, MODES, PROFILES, PURGE, SAVE_FORMATS, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Id, Map, ReferenceItem, STOP_MESSAGE, UnicodeString, Value, json};
/// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale.
pub(super) fn place_from(d: &Descriptor) -> Mapped {
@@ -5,7 +5,7 @@ use super::descriptors::{
use super::events::pixel_target;
use super::fields::{F, Kind, Mapped, fields_from, fields_to, step};
use super::tables::{CHANNELS, INTERPOLATION, RADIAL_METHOD, RADIAL_QUALITY, channel_of, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json};
/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre,
/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything
@@ -7,7 +7,7 @@ use super::events::pixel_target;
use super::fields::{F, Kind, Mapped, fields_to, none, step, steps};
use super::steps_make::is_tool_class;
use super::tables::{FILL_CONTENTS, INTERPOLATION, MENU_ITEMS, STROKE_LOCATION, blend_label, blend_ps, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Id, Map, ReferenceItem, UnicodeString, Value, json};
pub(super) fn select_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
+161 -144
View File
@@ -6,7 +6,7 @@ use super::descriptors::{
use super::fields::{Mapped, none, step, steps};
use super::steps_layer::quick_mask_ref;
use super::tables::{LABEL_COLORS, MODES, blend_label, blend_ps, lookup, lookup_back};
use super::*;
use super::{D, Descriptor, Id, Map, ReferenceItem, Value, json};
/// A `patternLayer` descriptor's pattern, scale, angle and link as the params of
/// `layer.newFillLayer.pattern` / `layer.fill.pattern.set`. The pattern goes by name: ids differ
@@ -59,82 +59,92 @@ const MASK_USING: &[(&str, &str)] = &[
("HdSl", "layer.layerMask.hideSelection"),
];
fn duplicate_layer_from_descriptor(d: &Descriptor) -> Option<Mapped> {
let r = get_ref(d, "null")?;
if ref_class(r).as_deref() != Some("Dcmn") || !matches!(target(r), Target::Class) {
return None;
}
let u = get_ref(d, "Usng")?;
if !matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection")) {
return None;
}
let mut params = layer_target(u)?;
params.insert("document".into(), "new".into());
if let Some(n) = get_text(d, "Nm ") {
params.insert("documentName".into(), n.into());
}
if let Some(n) = get_text(d, "LyrN") {
params.insert("name".into(), n.into());
}
Some(step("layer.duplicate", Value::Object(params)))
}
fn mask_from_descriptor(d: &Descriptor) -> Option<Mapped> {
let Some(D::Class(c)) = d.get("Nw ") else { return None };
if trimmed(&c.class_id) != "Chnl" {
return None;
}
let at_mask =
get_ref(d, "At ").is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk"));
Some(match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) {
Some(id) if at_mask => none(id),
_ => Mapped::Unsupported { harmless: false },
})
}
fn new_document_from_descriptor(d: &Descriptor) -> Option<Mapped> {
let nw = get_obj(d, "Nw ")?;
if get_enum(d, "Usng").as_deref() == Some("Slct") {
let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" };
let mut mapped_steps = vec![(id, json!({}))];
if let Some(n) = get_text(nw, "Nm ") {
mapped_steps.push(("layer.setProps", json!({"name": n})));
}
return Some(steps(mapped_steps));
}
if get_text(nw, "preset").is_some() {
return Some(none("file.newFromClipboard"));
}
let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) else {
return Some(Mapped::Unsupported { harmless: false });
};
let res = get_num(nw, "Rslt").unwrap_or(72.0);
let to_px = |key: &str, value: f64| match nw.get(key) {
Some(D::UnitFloat { unit: PX, .. }) => value,
_ => value * res / 72.0,
};
let mut params = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res});
if let Some(n) = get_text(nw, "Nm ") {
params["name"] = json!(n);
}
if let Some(D::Class(c)) = nw.get("Md ")
&& let Some(m) = lookup(MODES, &trimmed(&c.class_id))
{
params["mode"] = json!(m);
}
if let Some(depth) = get_num(nw, "Dpth") {
params["depth"] = json!(depth);
}
if let Some(fill) = get_enum(nw, "Fl ") {
params["background"] = json!(match fill.as_str() {
"Wht" => "white",
"BckC" => "backgroundColor",
"Trns" => "transparent",
_ => "white",
});
}
Some(step("file.new", params))
}
pub(super) fn make_from(d: &Descriptor) -> Mapped {
// Duplicate layer/group into a new document: `Mk` with a document class, a layer
// `Usng`, an optional `Nm` (the new document's name) and an optional `LyrN`
// (the copy's name). A `Dplc` with a `T` document reference (duplicate into another
// open document) stays a kept Photoshop step: document indices mean nothing at replay.
if let Some(r) = get_ref(d, "null")
&& ref_class(r).as_deref() == Some("Dcmn")
&& matches!(target(r), Target::Class)
&& let Some(u) = get_ref(d, "Usng")
&& matches!(ref_class(u).as_deref(), Some("Lyr") | Some("layerSection"))
&& let Some(mut p) = layer_target(u)
{
p.insert("document".into(), "new".into());
if let Some(n) = get_text(d, "Nm ") {
p.insert("documentName".into(), n.into());
}
if let Some(n) = get_text(d, "LyrN") {
p.insert("name".into(), n.into());
}
return step("layer.duplicate", Value::Object(p));
if let Some(mapped) = duplicate_layer_from_descriptor(d) {
return mapped;
}
// Add Layer Mask: a `Chnl` class made at the mask channel, `Usng` what it reveals.
if let Some(D::Class(c)) = d.get("Nw ")
&& trimmed(&c.class_id) == "Chnl"
{
// The target layer's mask only: a further item (a `Lyr` by name…) puts it on another layer.
let at_mask = get_ref(d, "At ")
.is_some_and(|r| r.len() == 1 && ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Msk"));
return match get_enum(d, "Usng").and_then(|u| lookup(MASK_USING, &u)) {
Some(id) if at_mask => none(id),
_ => Mapped::Unsupported { harmless: false },
};
if let Some(mapped) = mask_from_descriptor(d) {
return mapped;
}
if let Some(nw) = get_obj(d, "Nw ") {
// Layer via Copy / Cut (`Usng` = the selection), or a new document.
if get_enum(d, "Usng").as_deref() == Some("Slct") {
let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" };
let mut v = vec![(id, json!({}))];
if let Some(n) = get_text(nw, "Nm ") {
v.push(("layer.setProps", json!({"name": n})));
}
return steps(v);
}
if get_text(nw, "preset").is_some() {
return none("file.newFromClipboard");
}
if let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) {
let res = get_num(nw, "Rslt").unwrap_or(72.0);
// Dimensions are in points (`#Rlt`) unless given in pixels.
let to_px = |k: &str, v: f64| match nw.get(k) {
Some(D::UnitFloat { unit: PX, .. }) => v,
_ => v * res / 72.0,
};
let mut p = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res});
if let Some(n) = get_text(nw, "Nm ") {
p["name"] = json!(n);
}
if let Some(D::Class(c)) = nw.get("Md ")
&& let Some(m) = lookup(MODES, &trimmed(&c.class_id))
{
p["mode"] = json!(m);
}
if let Some(depth) = get_num(nw, "Dpth") {
p["depth"] = json!(depth);
}
if let Some(f) = get_enum(nw, "Fl ") {
p["background"] = json!(match f.as_str() {
"Wht" => "white",
"BckC" => "backgroundColor",
"Trns" => "transparent",
_ => "white",
});
}
return step("file.new", p);
}
return Mapped::Unsupported { harmless: false };
if let Some(mapped) = new_document_from_descriptor(d) {
return mapped;
}
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let using = get_obj(d, "Usng");
@@ -316,84 +326,91 @@ pub(super) fn make_to(id: &str, p: &Value) -> Option<Descriptor> {
})
}
fn selection_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
match d.get("T ") {
Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() {
"Al" => none("select.all"),
"None" => none("select.deselect"),
"Prvs" => none("select.reselect"),
_ => unsupported,
},
Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) {
(Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})),
// ⌘-click on the targeted channel (a layer mask after `select` of `Msk`).
(Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})),
(Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})),
_ => unsupported,
},
Some(D::Descriptor(t)) if t.class_id.is("Plgn") => {
let pts: Option<Vec<Value>> = get_list(t, "Pts ")
.unwrap_or(&[])
.iter()
.map(|v| match v {
D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])),
_ => None,
})
.collect();
match pts {
Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})),
_ => unsupported,
}
}
Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => {
let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else {
return unsupported;
};
let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)});
if t.class_id.is("Elps") {
p["ellipse"] = json!(true);
}
step("select.rect", p)
}
_ => unsupported,
}
}
fn background_from(d: &Descriptor) -> Mapped {
let unsupported = Mapped::Unsupported { harmless: false };
// On an image that opened as a normal layer there is no Background to convert;
// Photoshop stops there, but the step's purpose (an editable layer) is met.
// Photoshop's `set` of another layer doesn't target it: the selected layer stays.
let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))];
let mut props = Map::new();
props.insert("layer".into(), "Layer 0".into());
if let Some(t) = get_obj(d, "T ") {
if let Some(n) = get_text(t, "Nm ") {
props.insert("name".into(), n.into());
}
if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) {
props.insert("opacity".into(), (o / 100.0).into());
}
match get_enum(t, "Md ").as_deref() {
None | Some("Nrml") => {}
Some(m) => match blend_label(m) {
Some(label) => {
props.insert("blend".into(), label.into());
}
None => return unsupported,
},
}
}
if props.len() > 1 {
v.push(("layer.setProps", Value::Object(props)));
}
steps(v)
}
pub(super) fn set_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let unsupported = Mapped::Unsupported { harmless: false };
match (ref_class(r).as_deref(), target(r)) {
(Some("Chnl"), Target::Property(p)) if p == "fsel" => match d.get("T ") {
Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() {
"Al" => none("select.all"),
"None" => none("select.deselect"),
"Prvs" => none("select.reselect"),
_ => unsupported,
},
Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) {
(Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})),
// ⌘-click on the targeted channel (a layer mask after `select` of `Msk`).
(Some("Chnl"), Target::Ordinal(o)) if o == "Trgt" => step("select.loadSelection", json!({"channel": "targeted"})),
(Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})),
_ => unsupported,
},
Some(D::Descriptor(t)) if t.class_id.is("Plgn") => {
let pts: Option<Vec<Value>> = get_list(t, "Pts ")
.unwrap_or(&[])
.iter()
.map(|v| match v {
D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])),
_ => None,
})
.collect();
match pts {
Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})),
_ => unsupported,
}
}
Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => {
let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght"))
else {
return unsupported;
};
let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)});
if t.class_id.is("Elps") {
p["ellipse"] = json!(true);
}
step("select.rect", p)
}
_ => unsupported,
},
(Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d),
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
// Layer › New › Layer From Background (Photoshop records the New Layer dialog's values
// as a `set` of the Background); values other than the defaults then apply to the layer.
(Some("Lyr"), Target::Property(p)) if p == "Bckg" => {
// On an image that opened as a normal layer there is no Background to convert;
// Photoshop stops there, but the step's purpose (an editable layer) is met.
// Photoshop's `set` of another layer doesn't target it: the selected layer stays.
let mut v = vec![("layer.new.layerFromBackground", json!({"ifPresent": true, "select": false}))];
let mut props = Map::new();
props.insert("layer".into(), "Layer 0".into());
if let Some(t) = get_obj(d, "T ") {
if let Some(n) = get_text(t, "Nm ") {
props.insert("name".into(), n.into());
}
if let Some(o) = get_num(t, "Opct").filter(|o| (o - 100.0).abs() > 1e-9) {
props.insert("opacity".into(), (o / 100.0).into());
}
match get_enum(t, "Md ").as_deref() {
None | Some("Nrml") => {}
Some(m) => match blend_label(m) {
Some(label) => {
props.insert("blend".into(), label.into());
}
None => return unsupported,
},
}
}
if props.len() > 1 {
v.push(("layer.setProps", Value::Object(props)));
}
steps(v)
}
(Some("Lyr"), Target::Property(p)) if p == "Bckg" => background_from(d),
// The toolbox colours.
(Some("Clr"), Target::Property(p)) if p == "FrgC" || p == "BckC" => match get_obj(d, "T ").and_then(color_hex) {
Some(hex) => step("tools.setColors", json!({(if p == "FrgC" { "foreground" } else { "background" }): hex})),
+1 -1
View File
@@ -1,5 +1,5 @@
use super::descriptors::{Target, class, code, get_ref, target};
use super::*;
use super::{D, Descriptor, ReferenceItem};
/// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels.
const BLEND_MODES: &[(&str, &str)] = &[
+85 -3
View File
@@ -240,9 +240,7 @@ fn layer_id(s: &Session, p: &Value) -> Result<photocraft_doc::LayerId> {
}
fn damage_json(s: &mut Session, dmg: Rect) -> Value {
if let Some(st) = s.active_mut() {
st.last_damage = Some(dmg);
}
s.hint_damage(dmg);
json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })
}
@@ -419,9 +417,93 @@ pub fn paint_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let pts = parse_points(p, "paint.stroke")?;
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
let label = if brush.erase && !clear_mode(p) { "Eraser" } else { "Brush Tool" };
if flag(p, "fill", false) {
return fill_region(s, p, label, brush, pts);
}
stroke_with(s, "paint.stroke", p, label, brush, pts, false)
}
/// `paint.stroke {fill: true}`: fill the region enclosed by `points` (their polygon) with the
/// brush's colour and opacity, its edge anti-aliased and softened by the brush hardness, instead of
/// stamping the outline. A closed gesture then paints a filled dab, not a ring (#3659).
fn fill_region(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pts: Vec<StrokePoint>) -> Result<Value> {
if clear_mode(p) {
// `clear` maps onto the eraser; a clear fill would erase the region the outline encloses,
// which a selection + delete does deliberately (#3659). Reject before the eraser check so
// the message names the real conflict.
return Err(bad("paint.stroke", "\"fill\" cannot be combined with \"mode\":\"clear\"; select the enclosed region and delete it instead"));
}
if brush.erase {
return Err(bad("paint.stroke", "\"fill\" does not support the Eraser"));
}
if pts.len() < 3 {
return Err(bad("paint.stroke", "\"fill\" needs at least three points (a closed region)"));
}
let (id, brush, _zoom) = stroke_target(s, p, brush)?;
// A hard brush (hardness 1) keeps the anti-aliased polygon edge; a soft one feathers it by half
// the brush width, the way its tip fades.
let feather_px = ((1.0 - brush.hardness.clamp(0.0, 1.0)) * brush.size * 0.5).max(0.0);
let (color, opacity, mode) = (brush.color, brush.opacity.clamp(0.0, 1.0), brush.mode);
// A colour picked as CMYK fills with its inks in a CMYK target where it covers fully, as
// Edit › Fill does (blend_color_mask blends partial coverage through the colour's RGB).
let picked = brush.color_cmyk.filter(|(rgb, _)| *rgb == [color[0], color[1], color[2]]).map(|(_, inks)| inks);
let cmyk = doc_cmyk_space(s);
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
s.edit(label, |doc, _| {
let sel = doc.selection.clone();
let canvas = doc.bounds();
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
// The polygon's whole-pixel bounds, grown by the feather's reach (3σ) so a soft edge
// fades out instead of being clipped.
let (mut bx0, mut by0, mut bx1, mut by1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for sp in &pts {
if !(sp.x.is_finite() && sp.y.is_finite()) {
return Err(bad("paint.stroke", "non-finite point in a fill"));
}
bx0 = bx0.min(sp.x);
by0 = by0.min(sp.y);
bx1 = bx1.max(sp.x);
by1 = by1.max(sp.y);
}
let ix = |v: f64| v.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32;
let reach = photocraft_algo::selection::feather_reach(feather_px);
let pad = i32::try_from(reach).unwrap_or(i32::MAX).saturating_add(2);
let bounds = Rect::new(
ix(bx0.floor()).saturating_sub(pad),
ix(by0.floor()).saturating_sub(pad),
ix(bx1.ceil()).saturating_add(pad),
ix(by1.ceil()).saturating_add(pad),
)
.intersect(&canvas);
if bounds.is_empty() {
return Ok(Rect::new(0, 0, 0, 0));
}
let (w, h) = (bounds.width() as usize, bounds.height() as usize);
// The coverage, its feathered copy (and the blur's scratch), the selection read and
// the mask are each a buffer over the whole region, which can be the whole canvas:
// reserve that peak first, so a region too big for memory is an error, not an abort.
const FILL: &str = "filling a painted region";
let peak = w.checked_mul(h).and_then(|n| n.checked_mul(5)).ok_or_else(|| crate::allocation::error(FILL, "requested size overflowed"))?;
drop(crate::allocation::capacity::<f32>(peak, FILL)?);
let xy: Vec<(f32, f32)> = pts.iter().map(|sp| (sp.x as f32, sp.y as f32)).collect();
let mut cov = photocraft_algo::selection::polygon(&xy, bounds, true);
if feather_px > 0.01 {
cov = photocraft_algo::selection::feather(&cov, w, h, feather_px);
}
if let Some(sel) = sel.as_ref() {
let sv = crate::allocation::read_region(sel, bounds, FILL)?;
for (c, s) in cov.iter_mut().zip(&sv) {
*c *= s.clamp(0.0, 1.0);
}
}
let coverage = photocraft_algo::selection::mask_to_surface(&cov, bounds);
crate::fill_cmds::blend_color_mask(surf, bounds, color, picked, &coverage, mode, opacity, lock)?;
Ok(bounds)
})
})?;
Ok(damage_json(s, dmg))
}
/// A `paint.stroke` rendered while it is drawn, onto a copy of the active document, so the canvas
/// shows the real dabs before the stroke commits. Committing the same params and points with
/// `"seed": live.seed` gives the same pixels.
@@ -1,6 +1,9 @@
//! Incremental Mixer Brush transaction. Source pixels are composed before each dab;
//! only the selected target changes. Dropping the transaction cancels it.
use super::*;
use super::{
EngineError, LiveCommit, MixerSettings, Rect, Result, Session, Stroke, StrokePoint, Surface, Value, bad, check_coords, color, damage_json, doc_cmyk_space,
flag, has_paintable, json, layer_id, num, parse_points, pct, resolve_brush,
};
use photocraft_color::{PixelFormat, SampleType};
use photocraft_doc::{Document, LayerId};
use photocraft_paint::dynamics::DabGenerator;
+104
View File
@@ -31,6 +31,93 @@ fn stroke_backwards_compatible() {
assert!(rgba(&s, 30, 5)[3] > 0.9 && rgba(&s, 30, 8)[3] < 0.01);
}
#[test]
fn fill_fills_the_enclosed_region_not_the_outline() {
// #3659: a closed gesture with `fill` paints the interior, not a ring.
let mut s = session(60, 60);
s.execute(
"paint.stroke",
json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "size": 4, "color": "#ff0000", "brush": {"hardness": 1.0}}),
)
.unwrap();
assert!(rgba(&s, 30, 30)[0] > 0.9 && rgba(&s, 30, 30)[3] > 0.9, "interior filled: {:?}", rgba(&s, 30, 30));
assert_eq!(rgba(&s, 5, 30)[3], 0.0, "outside untouched");
assert_eq!(rgba(&s, 30, 5)[3], 0.0, "outside untouched");
// A soft brush feathers the edge outside the polygon; a hard one keeps a crisp edge.
let mut soft = session(60, 60);
soft.execute(
"paint.stroke",
json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "size": 40, "color": "#ff0000", "brush": {"hardness": 0.0}}),
)
.unwrap();
assert!(rgba(&soft, 8, 30)[3] > 0.0, "a soft edge paints outside the polygon: {:?}", rgba(&soft, 8, 30));
// Graceful failures: fewer than three points, and the Eraser.
assert!(s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10]], "fill": true})).is_err());
assert!(s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10], [50, 50]], "fill": true, "erase": true})).is_err());
// One history step, like a normal stroke.
let before = s.active().unwrap().history.past_len();
s.execute("paint.stroke", json!({"points": [[12, 12], [48, 12], [48, 48], [12, 48]], "fill": true, "color": "#0000ff"})).unwrap();
assert_eq!(s.active().unwrap().history.past_len(), before + 1, "fill is one history step");
}
#[test]
fn fill_is_rejected_with_clear_mode() {
// #3659 review: `"mode":"clear"` maps onto the eraser, so a clear fill would erase the region
// the outline encloses. Reject it; a selection + delete does that deliberately.
let mut s = session(60, 60);
let poly = json!([[10, 10], [50, 10], [50, 50], [10, 50]]);
assert!(s.execute("paint.stroke", json!({"points": poly.clone(), "fill": true, "mode": "clear"})).is_err());
// `clear` without `fill` still erases along the outline (main's behaviour), unchanged.
s.execute("paint.stroke", json!({"points": poly, "fill": true, "size": 4, "color": "#ff0000"})).unwrap();
assert!(rgba(&s, 30, 30)[3] > 0.9, "the region is filled first");
s.execute("paint.stroke", json!({"points": [[30, 2], [30, 58]], "size": 10, "mode": "clear"})).unwrap();
assert!(rgba(&s, 30, 30)[3] < 0.01, "a clear stroke erased what it crossed");
assert!(rgba(&s, 12, 30)[3] > 0.9, "pixels the clear stroke missed remain");
}
#[test]
fn fill_soft_edge_has_no_step() {
// #3779: the feather (σ = 20 here) reaches 3σ = 60 px past the square; the fill's working
// bounds must reach as far, or the soft edge ends in a hard step where they clip it.
let mut s = session(200, 200);
s.execute(
"paint.stroke",
json!({"points": [[80, 80], [120, 80], [120, 120], [80, 120]], "fill": true, "size": 40, "color": "#ff0000", "brush": {"hardness": 0.0}}),
)
.unwrap();
assert!(rgba(&s, 57, 100)[3] > 0.0, "the fade continues past 1σ: {:?}", rgba(&s, 57, 100));
for x in 0..199 {
let (a, b) = (rgba(&s, x, 100)[3], rgba(&s, x + 1, 100)[3]);
assert!((a - b).abs() < 0.03, "step between x={x} ({a}) and x={} ({b})", x + 1);
}
}
#[test]
fn fill_paints_a_cmyk_pick_with_the_picked_inks() {
// #3779: like a plain stroke (#4344), a fill paints a colour picked as CMYK as its inks.
let cyan = [1.0f32, 0.0, 0.0, 0.0];
let mut s = session(16, 16);
s.execute("image.mode.cmyk", json!({})).unwrap();
let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0));
s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [14, 2], [14, 14], [2, 14]], "fill": true, "hardness": 1.0})).unwrap();
let st = s.active().unwrap();
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
let got: Vec<f32> = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect();
assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{got:?}");
}
#[test]
fn fill_works_on_cmyk_and_16_and_32_bit() {
for (mode, depth) in [("cmyk", 8), ("rgb", 16), ("grayscale", 32)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 60, "height": 30, "mode": mode, "depth": depth})).unwrap();
s.execute("paint.stroke", json!({"points": [[10, 5], [50, 5], [50, 25], [10, 25]], "fill": true, "color": "#000000", "hardness": 1.0})).unwrap();
assert!(rgba(&s, 30, 15)[0] < 0.2, "{mode}/{depth}: {:?}", rgba(&s, 30, 15));
assert!(rgba(&s, 5, 15)[0] > 0.8, "{mode}/{depth} outside: {:?}", rgba(&s, 5, 15));
}
}
#[test]
fn stroke_accepts_full_points_brush_and_preset() {
let mut s = session(200, 100);
@@ -493,6 +580,23 @@ fn define_brush_from_selection() {
assert!(rgba(&s, 30, 32)[3] > 0.9);
}
#[test]
fn fill_survives_allocation_failure() {
// CodeRabbit on #9775: a fill's buffers span the region, up to the whole canvas. Their peak is
// reserved through the fallible allocator before any of them is built, so running out of
// memory is the fill's own error, leaving the pixels and history alone, never an abort.
let mut s = session(60, 60);
let pixels_before = surface(&s);
let history_before = s.active().unwrap().history.past_len();
crate::allocation::fail_next_for_test();
let result = s.execute("paint.stroke", json!({"points": [[10, 10], [50, 10], [50, 50], [10, 50]], "fill": true, "color": "#ff0000"}));
let _ = crate::allocation::capacity::<u8>(0, "reset failpoint");
let err = result.expect_err("an allocation failure must be reported, not panic").to_string();
assert!(err.contains("filling a painted region"), "the fill reserves its buffers first: {err}");
assert_eq!(surface(&s), pixels_before, "no pixels changed");
assert_eq!(s.active().unwrap().history.past_len(), history_before, "no history step was recorded");
}
#[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
+1 -1
View File
@@ -2,7 +2,7 @@ use super::manage::gray_document;
use super::planes::{channel_format, edit_mode, next_alpha_name, plane_surface, read_plane, tone};
use super::refs::{Blending, bad, doc_index, mask_plane, source, source_spec};
use super::targeting::{Target, is_channel_target, is_spot_target, target_of, target_surface};
use super::*;
use super::{AlphaChannel, ColorMode, EngineError, Result, SelectionMode, Session, Value, json, sel};
// ---------------------------------------------------------------------------------------------
// Apply Image / Calculations
+3 -1
View File
@@ -3,7 +3,9 @@ use super::refs::{ChanRef, alpha_index, bad, doc_index, gray_plane, parse_ref};
use super::selection::{uniform, unselected_surface};
use super::targeting::target;
use super::view::{ChannelTarget, QuickMaskOptions, color_names, composite_name, fix_view, touch};
use super::*;
use super::{
AlphaChannel, Color, ColorIndicates, ColorMode, DocState, Document, EngineError, Layer, PixelFormat, Result, Session, Surface, Value, json, list, sel,
};
// ---------------------------------------------------------------------------------------------
// Channel management
+1 -1
View File
@@ -1,5 +1,5 @@
use super::view::{ChannelTarget, color_names};
use super::*;
use super::{ColorMode, DocState, Document, PixelFormat, Rect, Surface};
/// The Curves dialog's Channel popup lists only this channel when the Channels panel has a
/// single non-composite target (a colour component, layer mask, saved channel or Quick Mask).
+1 -1
View File
@@ -1,6 +1,6 @@
use super::planes::{color_count, edit_mode, luminosity, read_plane, tone};
use super::view::{color_names, composite_name};
use super::*;
use super::{BlendMode, Document, EngineError, LayerContent, LayerId, Result, Session, Value, blend_channel, from_rgba, sel, to_rgba};
// ---------------------------------------------------------------------------------------------
// Channel references
@@ -1,7 +1,7 @@
use super::planes::{channel_format, next_alpha_name, plane_surface, read_plane};
use super::refs::{ChanRef, bad, doc_index, parse_ref, source};
use super::view::{ChannelTarget, QuickMaskOptions};
use super::*;
use super::{AlphaChannel, ColorIndicates, Document, EngineError, PixelFormat, Rect, Result, SelectionMode, Session, Surface, Value, json, operation, sel};
pub(super) fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "select.saveSelection";
+1 -1
View File
@@ -2,7 +2,7 @@ use super::manage::{fix_view_now, gray_document};
use super::planes::{channel_format, edit_mode, plane_surface, read_plane, tone};
use super::refs::{ChanRef, alpha_index, bad, gray_plane, native_pixels};
use super::view::{ChannelTarget, ChannelView, color_names};
use super::*;
use super::{AlphaChannel, ColorMode, DocState, Document, EngineError, Layer, Result, Session, Value, from_rgba, json};
/// Merge Spot Channel: flattens the image and prints the spot ink into the colour channels.
pub(super) fn merge_spot(s: &mut Session, p: &Value) -> Result<Value> {
@@ -2,7 +2,7 @@ use super::manage::fix_view_now;
use super::planes::{color_count, materialize};
use super::refs::{ChanRef, bad, parse_ref};
use super::view::{ChannelTarget, touch};
use super::*;
use super::{ColorMode, Document, EngineError, Layer, LayerId, Rect, Result, Session, Surface, Value, from_rgba, json, list};
// ---------------------------------------------------------------------------------------------
// Targets for pixel commands
+1 -1
View File
@@ -1,5 +1,5 @@
use super::planes::color_count;
use super::*;
use super::{AlphaChannel, Color, ColorIndicates, ColorMode, DocState, Document, Serialize};
// ---------------------------------------------------------------------------------------------
// View state
+7 -3
View File
@@ -417,7 +417,7 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
let id = s.edit("Paste", |doc, active| {
let mut l = Layer::raster(doc.next_layer_name("Layer"), target);
*crate::pixels_mut(&mut l)? = surf;
let id = doc.insert_above(*active, l);
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::AutoEditable);
*active = Some(id);
doc.selection = None;
Ok(id)
@@ -459,9 +459,13 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<
let id = s.edit("Paste", |doc, active| {
doc.patterns.extend(patterns);
doc.metadata.psd_global_blocks.extend(linked);
// The first pasted layer goes where a new layer would; the rest stack above it.
let mut place = crate::layer_place::Place::AutoEditable;
for l in copies.layers {
*active = Some(doc.insert_above(*active, l));
*active = Some(crate::layer_place::insert_new(doc, *active, l, place));
place = crate::layer_place::Place::Above;
}
crate::cmd::layer_multi_cmds::check_group_depth(doc, "Paste")?;
doc.selection = None;
active.ok_or(EngineError::Other("nothing to paste".into()))
})?;
@@ -601,7 +605,7 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
}
let mut l = Layer::raster(doc.next_layer_name("Layer"), clip.surface.format());
*crate::pixels_mut(&mut l)? = clip.surface;
let nid = doc.insert_above(Some(id), l);
let nid = crate::layer_place::insert_new(doc, Some(id), l, crate::layer_place::Place::Above);
*active = Some(nid);
// Photoshop deselects: a following Free Transform moves the whole new layer (#1096).
doc.selection = None;
@@ -1,4 +1,7 @@
use super::*;
use super::{
Document, EngineError, Layer, LayerId, PixelFormat, Rect, Result, Session, Surface, Value, bad, bool_or, from_rgba_into, int, json, pixel_layer,
pixel_layer_id, str_or, to_rgba, writable_surface,
};
// ------------------------------------------------------------------ Content-Aware Fill
@@ -503,7 +506,7 @@ fn apply_content_aware_fill(s: &mut Session, label: &str, id: LayerId, output: &
s.write_region(window, &px);
s.prune();
}
let nid = doc.insert_above(Some(id), l);
let nid = crate::layer_place::insert_new(doc, Some(id), l, crate::layer_place::Place::Above);
*active = Some(nid);
return Ok(nid);
}
@@ -1,5 +1,7 @@
use super::content_aware::channel_mask;
use super::*;
use super::{
Document, EngineError, LayerId, PixelFormat, Rect, Result, Session, Surface, Value, bad, bool_or, f32_or, json, pixel_layer, to_rgba, writable_surface,
};
// ------------------------------------------------------------------ Content-Aware Scale
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of,
surface_of_mut, to_rgba,
};
// ------------------------------------------------------------------ Fade
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, EngineError, LayerContent, LayerId, Result, Session, Value, bad, bool_or, json, layer_param, str_or};
// ------------------------------------------------------------------ Find and Replace Text
@@ -1,4 +1,4 @@
use super::*;
use super::{CustomShape, Deserialize, Document, EngineError, LayerContent, LayerId, Path, Result, Serialize, Session, Value, bad, json, str_or};
// ------------------------------------------------------------------ Define Brush / Custom Shape
@@ -1,4 +1,4 @@
use super::*;
use super::{Result, Session, Value, json};
// ------------------------------------------------------------------ Purge
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Document, EngineError, LayerContent, LayerId, Result, Session, Value};
pub(crate) fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())

Some files were not shown because too many files have changed in this diff Show More