mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 12:46:57 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16dd4b19ad | ||
|
|
32552a61a1 | ||
|
|
f86517a029 | ||
|
|
62346f7fb6 | ||
|
|
3d0c819d46 | ||
|
|
dd708f4ce4 | ||
|
|
e79d6571ba | ||
|
|
a1a7b17ee0 | ||
|
|
79cfa2b65a | ||
|
|
bade115202 | ||
|
|
6d03075ed7 | ||
|
|
5bdb12ec51 | ||
|
|
ae0e5eb731 | ||
|
|
ad5fb53f68 | ||
|
|
a2cdc6c2d7 | ||
|
|
9f49cae093 | ||
|
|
6d21a69968 | ||
|
|
4736a4bcbe | ||
|
|
8b21621671 | ||
|
|
dbf9d0d998 | ||
|
|
62f4b9d453 | ||
|
|
986f977a01 | ||
|
|
986f68f969 | ||
|
|
403e321034 | ||
|
|
4cf8a5fd05 | ||
|
|
97c6769d6b | ||
|
|
78c611007b | ||
|
|
dac995ee6c | ||
|
|
8c6e4a3abc | ||
|
|
84aa8df05a | ||
|
|
b623db1ed7 | ||
|
|
55b3301384 | ||
|
|
6ca04e5afa | ||
|
|
c7c6031254 | ||
|
|
d2b5b144aa | ||
|
|
c5a9e7f9b2 | ||
|
|
857a4f7d8a | ||
|
|
da3da0942c | ||
|
|
dbca08cd20 | ||
|
|
649b1d5d92 | ||
|
|
35b5b8ceb0 | ||
|
|
20cb95bc27 |
No files matched your search
@@ -257,6 +257,14 @@ jobs:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: psd, name: grd }
|
||||
- { crate: psd, name: ase }
|
||||
- { crate: psd, name: aco }
|
||||
- { crate: psd, name: patterns }
|
||||
- { crate: psd, name: filter_effects }
|
||||
- { crate: psd, name: metadata }
|
||||
- { crate: psd, name: tagged }
|
||||
- { crate: psd, name: resources }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
|
||||
@@ -19,6 +19,7 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
||||
Generated
+30
-29
@@ -4182,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
@@ -4224,7 +4224,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-affinity"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -4234,7 +4234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-algo"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4247,7 +4247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-automation"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cap-std",
|
||||
@@ -4269,7 +4269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-automation",
|
||||
"photocraft-codecs",
|
||||
@@ -4283,7 +4283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-cms"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"moxcms",
|
||||
"rayon",
|
||||
@@ -4291,7 +4291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-codecs"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"exr",
|
||||
"flate2",
|
||||
@@ -4312,7 +4312,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-cms",
|
||||
"serde",
|
||||
@@ -4320,7 +4320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-compose"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
@@ -4335,7 +4335,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-doc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4346,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"exr",
|
||||
@@ -4380,9 +4380,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
@@ -4399,14 +4400,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-gpu"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"half",
|
||||
"log",
|
||||
@@ -4423,14 +4424,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-heif"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-io"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
@@ -4459,14 +4460,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ops"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-doc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-paint"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4477,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-plugins"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4492,7 +4493,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-psd"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"photocraft-psd",
|
||||
@@ -4503,7 +4504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raster"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
@@ -4513,7 +4514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-raw"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4528,7 +4529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
@@ -4540,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
@@ -4551,7 +4552,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"parley",
|
||||
@@ -4565,7 +4566,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"craft-ui",
|
||||
@@ -4604,7 +4605,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-vector"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"photocraft-color",
|
||||
"photocraft-doc",
|
||||
@@ -4615,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7448,7 +7449,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -331,6 +331,8 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -311,6 +311,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
|
||||
let mut reported_fonts = std::collections::HashSet::<String>::new();
|
||||
for (id, params) in cmds {
|
||||
let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?;
|
||||
if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
return Err(format!("`{id}`: {e}"));
|
||||
}
|
||||
let new_warnings: Vec<String> = h
|
||||
.session
|
||||
.active()
|
||||
|
||||
@@ -203,6 +203,50 @@ fn run_commands_and_save() {
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stops_after_failed_action_without_exporting() {
|
||||
let d = tmp("run-failed-action");
|
||||
let out = d.join("result.png");
|
||||
let result = bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "layer.new.layer"])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#])
|
||||
.args(["--cmd", "layer.delete", "--cmd", "actions.stop"])
|
||||
// The recorded deletion succeeded with two layers; replay cannot delete the last one.
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out)
|
||||
.output()
|
||||
.unwrap();
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout));
|
||||
assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}");
|
||||
let rows: Vec<Value> = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run");
|
||||
assert!(!out.exists(), "a failed action must not export a partial result");
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_continues_after_successful_action_and_exports() {
|
||||
let d = tmp("run-successful-action");
|
||||
let out = d.join("result.png");
|
||||
let (stdout, _) = ok(bin()
|
||||
.args(["run", "--new", r#"{"width":8,"height":8}"#])
|
||||
.args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#])
|
||||
.args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"])
|
||||
.args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#])
|
||||
.args(["--cmd", "document.inspect", "--out"])
|
||||
.arg(&out));
|
||||
let rows: Vec<Value> = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap();
|
||||
assert_eq!(playback["result"]["ran"], 1);
|
||||
assert!(playback["result"].get("failed").is_none());
|
||||
assert!(rows.iter().any(|row| row["command"] == "document.inspect"));
|
||||
assert!(out.is_file());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_errors() {
|
||||
let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap();
|
||||
|
||||
@@ -305,27 +305,22 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let error = self.presets.error();
|
||||
if error.is_some() || self.presets.unsaved() {
|
||||
if let Some(error) = self.presets.error() {
|
||||
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
|
||||
if let Some(error) = error {
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
|
||||
self.presets.retry();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
|
||||
});
|
||||
ui.label(if self.database.is_some() {
|
||||
i18n::t("Keep this tab open to retain your brushes.")
|
||||
} else {
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme);
|
||||
// Put retry first so floating brush windows cannot cover the control.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() {
|
||||
self.presets.retry();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)]));
|
||||
});
|
||||
ui.label(if self.database.is_some() {
|
||||
i18n::t("Keep this tab open to retain your brushes.")
|
||||
} else {
|
||||
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
|
||||
}
|
||||
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
|
||||
});
|
||||
});
|
||||
}
|
||||
self.app.ui(ui, frame);
|
||||
@@ -382,6 +377,8 @@ fn services(inbox: Inbox) -> Services {
|
||||
};
|
||||
reply.send(answer);
|
||||
}),
|
||||
// Browser folder handles cannot be represented as filesystem paths for engine commands.
|
||||
FileDialogRequest::Folder { .. } => reply.send(None),
|
||||
// No save dialog on the web: the suggested name becomes the download name.
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
|
||||
|
||||
@@ -644,4 +644,13 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
fn the_desktop_entry_names_the_window_class() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
|
||||
@@ -104,8 +104,16 @@ sync_github_name = true
|
||||
[people.timothydaly1]
|
||||
display_name = "SyntaxError"
|
||||
|
||||
[people.atoslins]
|
||||
real_name = "Atos Lins"
|
||||
display_name = "Atos Lins"
|
||||
sync_github_name = false
|
||||
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FredApps]
|
||||
sync_github_name = true
|
||||
@@ -59,6 +59,22 @@ fn main() {
|
||||
prev = p;
|
||||
}
|
||||
println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst);
|
||||
// One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow
|
||||
// (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole
|
||||
// stroke, and how far the two results differ.
|
||||
let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0);
|
||||
let mut fields = Vec::new();
|
||||
let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] };
|
||||
for step in [2.0, 60.0] {
|
||||
drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect();
|
||||
let t = Instant::now();
|
||||
let mut f = LiquifyField::new(b, cell);
|
||||
f.apply_stroke(&drag);
|
||||
println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
fields.push(f);
|
||||
}
|
||||
let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement());
|
||||
st.tool = LiquifyTool::Bloat;
|
||||
let t = Instant::now();
|
||||
let mut d = Rect::EMPTY;
|
||||
|
||||
+196
-56
@@ -154,7 +154,7 @@ impl LiquifyStroke {
|
||||
}
|
||||
|
||||
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiquifyField {
|
||||
pub bounds: Rect,
|
||||
/// Document pixels per field node.
|
||||
@@ -165,6 +165,38 @@ pub struct LiquifyField {
|
||||
pub d: Vec<[f32; 2]>,
|
||||
/// Freeze mask per node (0 = free, 1 = frozen).
|
||||
pub freeze: Vec<f32>,
|
||||
/// Where the brush stroke in progress stands, so dab spacing runs on across its segments.
|
||||
cursor: Option<StrokeCursor>,
|
||||
}
|
||||
|
||||
/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is
|
||||
/// transient brush state, not part of the result.
|
||||
impl PartialEq for LiquifyField {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze
|
||||
}
|
||||
}
|
||||
|
||||
/// The brush stroke in progress: the point its last segment ended at, its last dab and how far
|
||||
/// the brush has travelled along the path since that dab.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct StrokeCursor {
|
||||
end: [f64; 2],
|
||||
last_dab: [f64; 2],
|
||||
carry: f64,
|
||||
}
|
||||
|
||||
/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a
|
||||
/// drag is many small pushes and its edge stays smooth.
|
||||
fn dab_spacing(r: f64) -> f64 {
|
||||
(r * 0.05).max(0.5)
|
||||
}
|
||||
|
||||
/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze,
|
||||
/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it
|
||||
/// was with dabs a fifth of the radius apart.
|
||||
fn moving_weight(r: f64) -> f64 {
|
||||
dab_spacing(r) / (r * 0.2).max(0.5)
|
||||
}
|
||||
|
||||
/// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond.
|
||||
@@ -177,7 +209,7 @@ impl LiquifyField {
|
||||
let cell = cell.clamp(1.0, 64.0);
|
||||
let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2;
|
||||
let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2;
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
|
||||
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None }
|
||||
}
|
||||
|
||||
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
|
||||
@@ -220,6 +252,7 @@ impl LiquifyField {
|
||||
|
||||
/// Applies one stroke. Returns the document rectangle whose output changed.
|
||||
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
|
||||
self.cursor = None;
|
||||
match s.tool {
|
||||
LiquifyTool::LassoMask => return self.apply_lasso(s),
|
||||
LiquifyTool::ReconstructAll => {
|
||||
@@ -306,77 +339,120 @@ impl LiquifyField {
|
||||
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
|
||||
/// [`Self::apply_stroke`] does with the whole point list.
|
||||
pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect {
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY }
|
||||
self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 });
|
||||
if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY }
|
||||
}
|
||||
|
||||
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
|
||||
/// tool held still dabs once at `b`).
|
||||
/// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled
|
||||
/// along the whole stroke, carried from one segment to the next, so the result is the same
|
||||
/// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A
|
||||
/// stationary tool held still dabs once at `b`. When `a` is not where the previous segment
|
||||
/// ended, the stroke starts afresh at `a`.
|
||||
pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
self.walk_segment(s, a, b, true)
|
||||
}
|
||||
|
||||
/// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they
|
||||
/// would change nothing), which tests compare with the full walk.
|
||||
fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let stationary = s.tool.is_stationary();
|
||||
let cur = match self.cursor {
|
||||
Some(c) if c.end == [a[0], a[1]] => c,
|
||||
_ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 },
|
||||
};
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = dx.hypot(dy);
|
||||
if len == 0.0 {
|
||||
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
|
||||
self.cursor = Some(cur);
|
||||
return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty };
|
||||
}
|
||||
// NaN or a length that overflowed: nothing sensible to walk; the next segment restarts.
|
||||
if !len.is_finite() {
|
||||
self.cursor = None;
|
||||
return dirty;
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let spacing = (r * 0.2).max(0.5);
|
||||
let steps = (len / spacing).ceil().max(1.0) as usize;
|
||||
// Walk only the steps whose dab can reach a field node: the rest return from `dab` before
|
||||
// touching anything, and a segment to a far-off point has trillions of them (#937).
|
||||
let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty };
|
||||
for k in k0..=k1 {
|
||||
let t0 = (k - 1) as f64 / steps as f64;
|
||||
let t1 = k as f64 / steps as f64;
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let pr = pa + (pb - pa) * t1;
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pr));
|
||||
let spacing = dab_spacing(r);
|
||||
let weight = if stationary { moving_weight(r) } else { 1.0 };
|
||||
// Distance along this segment of its first dab, then one every `spacing`.
|
||||
let first = (spacing - cur.carry).clamp(0.0, spacing);
|
||||
// Distances are compared with a little slack: a dab that rounding puts a hair past the
|
||||
// segment's end still falls on it, so cutting one segment in two never adds or drops a dab.
|
||||
let slack = spacing * 1e-6;
|
||||
if first > len + slack {
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len });
|
||||
return dirty;
|
||||
}
|
||||
let last_k = ((len - first + slack) / spacing).floor().max(0.0);
|
||||
let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len];
|
||||
let (mut k0, mut k1) = (0.0f64, last_k);
|
||||
if skip {
|
||||
match self.reachable_span(a, [dx, dy], len, r) {
|
||||
// A dragging dab is centred one spacing behind its position: pad by that and a step.
|
||||
Some((d0, d1)) => {
|
||||
k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0);
|
||||
k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0);
|
||||
}
|
||||
None => k1 = -1.0,
|
||||
}
|
||||
}
|
||||
if k0 <= k1 {
|
||||
let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab };
|
||||
for k in (k0 as u64)..=(k1 as u64) {
|
||||
let dist = (first + k as f64 * spacing).min(len);
|
||||
let c = at(dist);
|
||||
let delta = [c[0] - prev[0], c[1] - prev[1]];
|
||||
// Forward Warp centres the dab where the brush was (it drags what is under it).
|
||||
let centre = if stationary { c } else { prev };
|
||||
dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight));
|
||||
prev = c;
|
||||
}
|
||||
}
|
||||
let last = (first + last_k * spacing).min(len);
|
||||
self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) });
|
||||
dirty
|
||||
}
|
||||
|
||||
/// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or
|
||||
/// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1
|
||||
/// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a
|
||||
/// node only when its centre is within `r` plus a node of the node grid, so the window below
|
||||
/// pads that by another node and a step: the dabs it leaves out would each change nothing.
|
||||
fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> {
|
||||
/// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie
|
||||
/// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs
|
||||
/// near that span can touch a node; a segment to a far-off point has trillions of others
|
||||
/// (#937).
|
||||
fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> {
|
||||
if self.w == 0 || self.h == 0 {
|
||||
return None;
|
||||
}
|
||||
let pad = r + 2.0 * self.cell;
|
||||
let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad];
|
||||
let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad];
|
||||
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
|
||||
let (mut k0, mut k1) = (1.0f64, n);
|
||||
let (mut u0, mut u1) = (0.0f64, 1.0f64);
|
||||
for ax in 0..2 {
|
||||
let d = b[ax] - a[ax];
|
||||
let d = dir[ax];
|
||||
if d == 0.0 {
|
||||
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0);
|
||||
k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0);
|
||||
let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d);
|
||||
u0 = u0.max(v0.min(v1));
|
||||
u1 = u1.min(v0.max(v1));
|
||||
}
|
||||
// NaN: a segment whose length overflowed.
|
||||
if k0.is_nan() || k1.is_nan() || k0 > k1 {
|
||||
if u0.is_nan() || u1.is_nan() || u0 > u1 {
|
||||
return None;
|
||||
}
|
||||
Some((k0 as usize, k1 as usize))
|
||||
Some((u0 * len, u1 * len))
|
||||
}
|
||||
|
||||
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
|
||||
/// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the
|
||||
/// document rect it affected.
|
||||
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect {
|
||||
if s.tool == LiquifyTool::LassoMask {
|
||||
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
|
||||
}
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
|
||||
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight;
|
||||
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
|
||||
let density = (s.density / 100.0).clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
@@ -921,21 +997,9 @@ mod tests {
|
||||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
|
||||
/// The segment walk before #937: every step, whether or not its dab can reach the field.
|
||||
/// The segment walk before #937: every dab, whether or not it can reach the field.
|
||||
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
|
||||
let mut dirty = Rect::EMPTY;
|
||||
let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0));
|
||||
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
|
||||
let r = (s.size / 2.0).max(0.5);
|
||||
let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize;
|
||||
for k in 1..=steps {
|
||||
let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64);
|
||||
let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1];
|
||||
let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)];
|
||||
let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] };
|
||||
dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1));
|
||||
}
|
||||
dirty
|
||||
f.walk_segment(s, a, b, false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -945,7 +1009,7 @@ mod tests {
|
||||
// every result bit-identical to the full walk.
|
||||
use LiquifyTool::*;
|
||||
let bounds = Rect::new(-7, 3, 89, 67);
|
||||
let segments: [([f64; 3], [f64; 3]); 9] = [
|
||||
let segments: [([f64; 3], [f64; 3]); 10] = [
|
||||
([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside
|
||||
([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left
|
||||
([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards
|
||||
@@ -955,6 +1019,7 @@ mod tests {
|
||||
([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge
|
||||
([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside
|
||||
([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step
|
||||
([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left
|
||||
];
|
||||
for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] {
|
||||
for size in [6.0, 40.0] {
|
||||
@@ -973,6 +1038,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly.
|
||||
fn sampled(path: &[[f64; 3]], parts: usize) -> Vec<Vec<f64>> {
|
||||
let mut out = vec![path[0].to_vec()];
|
||||
for w in path.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
// Uneven steps, as a real pointer gives: t grows quadratically.
|
||||
for k in 1..=parts {
|
||||
let t = (k * k) as f64 / (parts * parts) as f64;
|
||||
out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a
|
||||
/// slow one (many close ones) warped the same path differently. Spacing now runs on along the
|
||||
/// whole stroke: any sampling of a path gives the same field.
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() {
|
||||
use LiquifyTool::*;
|
||||
let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]];
|
||||
for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] {
|
||||
for size in [12.0, 40.0] {
|
||||
let field = |parts: usize| {
|
||||
let mut warp = LiquifyStroke::new(ForwardWarp, 30.0);
|
||||
warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]];
|
||||
let mut s = LiquifyStroke::new(tool, size);
|
||||
s.points = sampled(&path, parts);
|
||||
// Some displacement first, so Reconstruct and Smooth have something to change.
|
||||
LiquifyField::from_strokes(bounds(), 2.0, &[warp, s])
|
||||
};
|
||||
let coarse = field(1);
|
||||
for parts in [3, 17, 60] {
|
||||
let fine = field(parts);
|
||||
let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max);
|
||||
assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not
|
||||
/// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds
|
||||
/// up differs by a whole dab between a slow and a fast pointer.
|
||||
#[test]
|
||||
fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() {
|
||||
let field = |step: f64| {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0);
|
||||
s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect();
|
||||
LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s])
|
||||
};
|
||||
let (slow, fast) = (field(2.0), field(60.0));
|
||||
let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max);
|
||||
assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement");
|
||||
assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() {
|
||||
// A segment that does not start where the last one ended begins a new run of dabs there.
|
||||
let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0);
|
||||
let mut f = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_begin(&s, [10.0, 10.0, 1.0]);
|
||||
f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]);
|
||||
let mut g = LiquifyField::new(bounds(), 2.0);
|
||||
f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
g.stroke_begin(&s, [40.0, 30.0, 1.0]);
|
||||
g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]);
|
||||
assert_eq!(f, g);
|
||||
// Hostile points: nothing panics and the next segment starts afresh.
|
||||
f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]);
|
||||
f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_segment_to_a_far_off_point_returns() {
|
||||
// The issue's request: about 3.3e14 steps before the fix. Also a segment whose length
|
||||
@@ -1110,8 +1250,8 @@ mod tests {
|
||||
/// dab's circle (a hard-edged block at Density 0, half strength at the default 50).
|
||||
#[test]
|
||||
fn forward_warp_leaves_the_brush_rim_in_place() {
|
||||
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 4.0);
|
||||
// A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started.
|
||||
let (r, drag) = (20.0, 1.0);
|
||||
for density in [0.0, 50.0, 100.0] {
|
||||
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
|
||||
s.density = density;
|
||||
|
||||
@@ -343,3 +343,102 @@ async fn agent_play_refuses_a_recorded_file_open() {
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
|
||||
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
|
||||
/// trip — all through MCP tools and checked from outside the engine.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_completes_ten_more_scripted_tasks() {
|
||||
let dir = tmp("tasks2");
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 11. Stroke a selection: a red square with a black outside stroke.
|
||||
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
|
||||
let inside = pixel(&c, 40, 40).await;
|
||||
let edge = pixel(&c, 18, 40).await;
|
||||
let away = pixel(&c, 5, 5).await;
|
||||
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
|
||||
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
|
||||
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 12. Inverse the selection: a blue frame around the red square.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "select.inverse", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
|
||||
let centre = pixel(&c, 40, 40).await;
|
||||
let frame = pixel(&c, 4, 4).await;
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
|
||||
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
|
||||
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
|
||||
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "layer.duplicate", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
|
||||
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
|
||||
assert_eq!(patches, 2, "task 13: {doc}");
|
||||
|
||||
// 14. Merge the copy down; the two patches become one layer.
|
||||
run(&c, "layer.mergeDown", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
|
||||
|
||||
// 15. Flatten the image; the patch survives.
|
||||
run(&c, "layer.flattenImage", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
|
||||
let green = pixel(&c, 20, 20).await;
|
||||
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
|
||||
|
||||
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
|
||||
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
|
||||
let shifted = pixel(&c, 40, 40).await;
|
||||
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
|
||||
// 17. Feather the selection and reveal a mask through it: the edge blends.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "select.modify.feather", json!({"radius": 4})).await;
|
||||
run(&c, "layer.layerMask.revealSelection", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
|
||||
let boundary = pixel(&c, 20, 40).await;
|
||||
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 18. Expand a selection and read its bounds back.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
|
||||
run(&c, "select.modify.expand", json!({"radius": 5})).await;
|
||||
let bounds = inspect(&c).await["selectionBounds"].clone();
|
||||
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
|
||||
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
|
||||
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 19. Rename a layer and delete it again.
|
||||
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
|
||||
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
|
||||
|
||||
// 20. Export the final image and reopen the PNG as a flat document.
|
||||
tool(&c, "doc_export", json!({"path": "final.png"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": "final.png"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
|
||||
//!
|
||||
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
|
||||
//! through `doc_inspect`, `document.pixel` and exported/saved files.
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{ClientHandler, RoleClient, ServiceExt};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Client;
|
||||
impl ClientHandler for Client {
|
||||
fn get_info(&self) -> ClientConfig {
|
||||
ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
type Server = tokio::task::JoinHandle<()>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
let server = tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
(Client.serve(c).await.expect("client init"), server)
|
||||
}
|
||||
|
||||
async fn shutdown(c: Conn, server: Server) {
|
||||
c.cancel().await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
|
||||
let mut p = CallToolRequestParams::new(name.to_owned());
|
||||
if let Value::Object(m) = args {
|
||||
p = p.with_arguments(m);
|
||||
}
|
||||
let r = c.call_tool(p).await.expect("call_tool transport");
|
||||
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
|
||||
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
|
||||
}
|
||||
|
||||
async fn run(c: &Conn, id: &str, params: Value) -> Value {
|
||||
tool(c, "command_run", json!({"id": id, "params": params})).await
|
||||
}
|
||||
|
||||
async fn inspect(c: &Conn) -> Value {
|
||||
tool(c, "doc_inspect", json!({})).await
|
||||
}
|
||||
|
||||
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
|
||||
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
|
||||
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
|
||||
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn top_layer(doc: &Value) -> &Value {
|
||||
&doc["layers"][0]
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
|
||||
/// conversion with an export check, a native save round trip that preserves type, a layer-style
|
||||
/// stroke and inverse fills.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_completes_six_rounding_out_tasks() {
|
||||
let dir = tmp("tasks4");
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 31. Undo/redo sweep: four fills go away and come back.
|
||||
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
|
||||
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let x = 10 * i as i32;
|
||||
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
|
||||
run(&c, "edit.fill", json!({"color": color})).await;
|
||||
}
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
|
||||
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
|
||||
for _ in 0..steps {
|
||||
run(&c, "edit.undo", json!({})).await;
|
||||
}
|
||||
for i in 0..4 {
|
||||
let x = 10 * i + 5;
|
||||
let p = pixel(&c, x, 5).await;
|
||||
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
|
||||
}
|
||||
for _ in 0..steps {
|
||||
run(&c, "edit.redo", json!({})).await;
|
||||
}
|
||||
let red = pixel(&c, 5, 5).await;
|
||||
let black = pixel(&c, 35, 5).await;
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
|
||||
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
|
||||
|
||||
// 32. Paint a barcode: five black bars on white.
|
||||
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
|
||||
for i in 0..5 {
|
||||
let x = 4 + 8 * i;
|
||||
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#000000"})).await;
|
||||
}
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
for i in 0..5 {
|
||||
let bar = pixel(&c, 5 + 8 * i, 12).await;
|
||||
let gap = pixel(&c, 9 + 8 * i, 12).await;
|
||||
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
|
||||
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
|
||||
}
|
||||
|
||||
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
|
||||
run(&c, "image.mode.grayscale", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
|
||||
let g = pixel(&c, 5, 12).await;
|
||||
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
|
||||
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
|
||||
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
|
||||
let px = img.to_rgba8();
|
||||
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
|
||||
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
// 34. A native save round trip preserves a styled type layer.
|
||||
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
|
||||
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
|
||||
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
|
||||
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
// 35. A layer-style stroke frames a swatch.
|
||||
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
|
||||
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
|
||||
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
|
||||
let border = pixel(&c, 11, 24).await;
|
||||
let centre = pixel(&c, 24, 24).await;
|
||||
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
|
||||
|
||||
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
|
||||
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
|
||||
run(&c, "select.inverse", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#808080"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let corner = pixel(&c, 2, 2).await;
|
||||
let centre = pixel(&c, 24, 24).await;
|
||||
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
|
||||
run(&c, "select.all", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let white = pixel(&c, 2, 2).await;
|
||||
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
|
||||
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
|
||||
}
|
||||
|
||||
/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer
|
||||
/// named "Background" (how PSD import and File › New create it).
|
||||
/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind,
|
||||
/// never by its (localized) name ([`photocraft_doc::Document::background`]).
|
||||
pub fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
|
||||
l.has_background_traits()
|
||||
}
|
||||
|
||||
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
|
||||
|
||||
@@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() {
|
||||
let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0];
|
||||
assert!(got[3] <= 1.0);
|
||||
}
|
||||
|
||||
/// The Background is found by its structure, not its English name: a file from a German or
|
||||
/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A
|
||||
/// bottom layer merely called "Background", without the Background's locks, is not one.
|
||||
#[test]
|
||||
fn deep_knockout_reveals_a_localized_background() {
|
||||
for depth in DEPTHS {
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].name = name.into();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, WHITE, name);
|
||||
}
|
||||
let mut d = doc(depth, true);
|
||||
d.layers[0].locks = Default::default();
|
||||
let green = solid(&d, "green", full(&d), GREEN);
|
||||
d.layers.push(green);
|
||||
d.layers.push(knocker(&d, Knockout::Deep));
|
||||
check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background");
|
||||
}
|
||||
}
|
||||
+141
-32
@@ -1114,27 +1114,24 @@ fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin:
|
||||
}
|
||||
|
||||
fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
|
||||
let src = if inner {
|
||||
match g.source {
|
||||
GlowSource::Edge => shape.clone().map(|a| 1.0 - a),
|
||||
GlowSource::Center => shape.clone(),
|
||||
}
|
||||
} else {
|
||||
shape.clone()
|
||||
};
|
||||
// An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken
|
||||
// after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly
|
||||
// 1 − the edge glow, for both techniques).
|
||||
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
|
||||
let mut m = match g.technique {
|
||||
GlowTechnique::Precise => {
|
||||
// An inner glow, from either source, measures from the edge inwards; a centre glow is
|
||||
// the inverse below (the distance outside is ≤ 0 everywhere inside, #966).
|
||||
// Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to
|
||||
// centre), solid within the spread / choke: after the default 50 % Range that is
|
||||
// Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner,
|
||||
// sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range
|
||||
// values are unmeasured). The distances here are 0.5 px short of centre to centre.
|
||||
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let soft = (g.size + 1.0 - solid).max(1e-3);
|
||||
let mut m = Map::new(shape.w, shape.h, 0.0);
|
||||
for (o, d) in m.v.iter_mut().zip(d) {
|
||||
*o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
|
||||
}
|
||||
if inner && g.source == GlowSource::Center {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
let d = d + 0.5;
|
||||
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
|
||||
}
|
||||
m
|
||||
}
|
||||
@@ -1142,16 +1139,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
|
||||
let (r, bw) = spread_split(g.size, g.spread);
|
||||
let mut m = dilate(&src, r);
|
||||
blur(&mut m, bw);
|
||||
if inner && g.source == GlowSource::Center {
|
||||
// Brightest in the middle: coverage from the distance to the edge.
|
||||
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r);
|
||||
blur(&mut e, bw);
|
||||
m = e.map(|v| 1.0 - v);
|
||||
}
|
||||
m
|
||||
}
|
||||
};
|
||||
m = apply_lut(m, glow_lut(g));
|
||||
if inner && g.source == GlowSource::Center {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
}
|
||||
noise(&mut m, g.noise, origin.0, origin.1);
|
||||
if inner {
|
||||
for (v, a) in m.v.iter_mut().zip(&shape.v) {
|
||||
@@ -1461,7 +1455,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
||||
BevelGeom {
|
||||
paint,
|
||||
width,
|
||||
depth: b.depth * if smooth { width } else { size } * sign,
|
||||
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
|
||||
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
|
||||
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
|
||||
pillow: b.style == BevelStyle::PillowEmboss,
|
||||
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
|
||||
}
|
||||
@@ -1482,10 +1478,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
||||
let sharp = sharp_edges(shape);
|
||||
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
|
||||
let mut h = Map::new(shape.w, shape.h, 0.0);
|
||||
let soft = g.chisel_soft > 0.0;
|
||||
for i in 0..h.v.len() {
|
||||
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
|
||||
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
|
||||
}
|
||||
if g.chisel_soft > 0.0 {
|
||||
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
|
||||
// Hard's across a straight edge); emboss styles keep the blur until measured.
|
||||
if soft && g.paint == BevelPaint::Both {
|
||||
tent(&mut h, g.chisel_soft);
|
||||
}
|
||||
h
|
||||
@@ -1527,17 +1526,46 @@ pub struct TextureCtx<'a> {
|
||||
}
|
||||
|
||||
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
|
||||
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
|
||||
///
|
||||
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
|
||||
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
|
||||
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
|
||||
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
|
||||
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
|
||||
/// pixel further out. All within 1 level of Photoshop on those profiles.
|
||||
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
|
||||
let ramp = size + 1.0;
|
||||
match paint {
|
||||
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
|
||||
BevelPaint::Both => {
|
||||
if din > 0.0 {
|
||||
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
|
||||
BevelPaint::Outer if !soft => {
|
||||
let top = (size + 2.0) / ramp;
|
||||
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
|
||||
}
|
||||
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
|
||||
BevelPaint::Outer => {
|
||||
if dout > 0.0 {
|
||||
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
|
||||
(size + 2.0) / ramp
|
||||
}
|
||||
}
|
||||
BevelPaint::Both => {
|
||||
let half = size / 2.0 + 0.5;
|
||||
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
|
||||
}
|
||||
BevelPaint::Inner if soft => {
|
||||
if din > 0.0 {
|
||||
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
BevelPaint::Inner => {
|
||||
if din > 0.0 {
|
||||
((din + 0.5) / ramp).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1555,7 +1583,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
|
||||
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
|
||||
// bevel-outer-smooth).
|
||||
let reach_past = g.paint == BevelPaint::Both;
|
||||
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
|
||||
// 27.11 shades it, half strength, at `size + 1½` px out).
|
||||
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
|
||||
let on_bevel = |x: i64, y: i64| {
|
||||
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
|
||||
};
|
||||
@@ -2340,6 +2370,40 @@ mod tests {
|
||||
assert!(at(&maps[0], 20, 15) < 0.05);
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
|
||||
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
|
||||
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
|
||||
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
|
||||
#[test]
|
||||
fn chisel_facets_match_photoshop() {
|
||||
let amount = |g: f32| {
|
||||
let (se, ce) = 30f32.to_radians().sin_cos();
|
||||
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
|
||||
};
|
||||
let n = 80;
|
||||
let shape = square(n, 20, 60);
|
||||
let maps = |style, technique| {
|
||||
let mut b = bevel_of(style, technique);
|
||||
b.size = 10.0;
|
||||
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
|
||||
};
|
||||
let at = |m: &Map, y: usize| m.v[y * n + 40];
|
||||
let facet = amount(10.0 / 22.0);
|
||||
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
|
||||
for y in 20..30 {
|
||||
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
|
||||
}
|
||||
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
|
||||
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
|
||||
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
|
||||
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
|
||||
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
|
||||
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
|
||||
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
|
||||
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
|
||||
assert_eq!(at(&outer[1], 72), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
|
||||
let shape = square(40, 10, 30);
|
||||
@@ -2883,6 +2947,51 @@ mod tests {
|
||||
assert!(glow_lut(&g).is_none());
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise
|
||||
/// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow.
|
||||
#[test]
|
||||
fn glows_match_photoshop() {
|
||||
let glow = |technique, size: f32, spread: f32, source| Glow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||||
paint: FxPaint::Color(photocraft_color::Color::BLACK),
|
||||
technique,
|
||||
spread,
|
||||
size,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source,
|
||||
};
|
||||
let n = 60;
|
||||
let mut shape = Map::new(n, n, 0.0);
|
||||
for y in 20..40 {
|
||||
for x in 20..40 {
|
||||
shape.v[y * n + x] = 1.0;
|
||||
}
|
||||
}
|
||||
let at = |m: &Map, x: usize| m.v[30 * n + x];
|
||||
// Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232
|
||||
// at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11).
|
||||
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0));
|
||||
for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] {
|
||||
let got = 255.0 * (1.0 - at(&m, 20 - d));
|
||||
assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}");
|
||||
}
|
||||
// Spread 40 %: solid for the first 4 px.
|
||||
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0));
|
||||
assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0);
|
||||
// Centre source = 1 − edge, for both techniques.
|
||||
for t in [GlowTechnique::Precise, GlowTechnique::Softer] {
|
||||
let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0));
|
||||
let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0));
|
||||
for x in 20..40 {
|
||||
assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_follow_light() {
|
||||
let (dx, dy) = offset(120.0, 10.0);
|
||||
|
||||
@@ -636,6 +636,13 @@ pub struct Layer {
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
/// Whether the layer has the Background's own properties: an unclipped pixel layer with
|
||||
/// transparency and position locked. Only the bottom layer of a document with them is the
|
||||
/// Background: use [`Document::background`] / [`Document::is_background`] to identify it.
|
||||
pub fn has_background_traits(&self) -> bool {
|
||||
self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
pub fn new(name: impl Into<String>, content: LayerContent) -> Self {
|
||||
Layer {
|
||||
id: LayerId::fresh(),
|
||||
@@ -1040,6 +1047,25 @@ impl Document {
|
||||
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
|
||||
}
|
||||
|
||||
/// The Background layer, identified structurally as Photoshop does, never by its name (a file
|
||||
/// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of
|
||||
/// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD
|
||||
/// stores it without a transparency channel and transparency-protected; File › New and opaque
|
||||
/// flat files create it the same way).
|
||||
pub fn background(&self) -> Option<&Layer> {
|
||||
self.layers.first().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// [`Self::background`], mutably.
|
||||
pub fn background_mut(&mut self) -> Option<&mut Layer> {
|
||||
self.layers.first_mut().filter(|l| Layer::has_background_traits(l))
|
||||
}
|
||||
|
||||
/// Whether `id` is the document's Background layer ([`Self::background`]).
|
||||
pub fn is_background(&self, id: LayerId) -> bool {
|
||||
self.background().is_some_and(|b| b.id == id)
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
|
||||
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
|
||||
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
|
||||
@@ -1205,6 +1231,30 @@ mod tests {
|
||||
assert_eq!(s.pixel(100, 0), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
/// The Background is the locked bottom pixel layer, whatever it is called.
|
||||
#[test]
|
||||
fn background_is_structural_not_named() {
|
||||
let mut d = doc();
|
||||
let id = d.layers[0].id;
|
||||
assert!(d.is_background(id));
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
d.layers[0].name = name.into();
|
||||
assert_eq!(d.background().map(|l| l.id), Some(id), "{name}");
|
||||
}
|
||||
// A layer called "Background" without the locks, or above the bottom, is not it.
|
||||
let mut top = Layer::raster("Background", d.pixel_format());
|
||||
top.locks.transparency = true;
|
||||
top.locks.position = true;
|
||||
let top_id = top.id;
|
||||
d.layers.push(top);
|
||||
assert!(!d.is_background(top_id));
|
||||
d.layers[0].locks.position = false;
|
||||
assert!(d.background().is_none());
|
||||
d.layers[0].locks.position = true;
|
||||
d.layers[0].clipped = true;
|
||||
assert!(d.background_mut().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_group_depth_counts_nesting_without_recursing() {
|
||||
let mut d = doc();
|
||||
|
||||
@@ -1224,4 +1224,44 @@ mod tests {
|
||||
assert!(bad.actions.list.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter,
|
||||
/// a selection fill and styled text all act on the document the replay runs in.
|
||||
#[test]
|
||||
fn a_recorded_edit_run_replays_on_a_second_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Studio"})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap();
|
||||
s.execute("type.setStyle", json!({"fauxBold": true})).unwrap();
|
||||
let stopped = s.execute("actions.stop", json!({})).unwrap();
|
||||
let steps = stopped["steps"].as_u64().unwrap();
|
||||
assert!(steps >= 7, "the recording keeps every family: {stopped}");
|
||||
|
||||
// The second document: a different size, background and name. The replay must act here.
|
||||
s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "replay: {played}");
|
||||
assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}");
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document");
|
||||
assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed");
|
||||
assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black");
|
||||
let last = d.layers.last().unwrap();
|
||||
match &last.content {
|
||||
photocraft_doc::LayerContent::Text(t) => {
|
||||
assert_eq!(t.text, "Hi", "the styled text replayed");
|
||||
assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed");
|
||||
}
|
||||
other => panic!("expected the recorded type layer, got {other:?}"),
|
||||
}
|
||||
// The first document is untouched.
|
||||
let first = &s.documents()[0].doc;
|
||||
assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop");
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let data: Vec<f32> = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect();
|
||||
let mut bg = Layer::raster("Background", fmt);
|
||||
bg.locks.transparency = true;
|
||||
bg.locks.position = true;
|
||||
crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data);
|
||||
*active = Some(bg.id);
|
||||
doc.layers = vec![bg];
|
||||
|
||||
@@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"angle": 0.0}));
|
||||
}
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
|
||||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
|
||||
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
|
||||
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
|
||||
compound(s, "Straighten", |s| {
|
||||
|
||||
@@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::extra_cmds::is_background;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "layer.new.backgroundFromLayer";
|
||||
|
||||
/// The document's Background layer, if it has one (always the bottom layer).
|
||||
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
|
||||
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
|
||||
doc.background().map(|l| l.id)
|
||||
}
|
||||
|
||||
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
|
||||
@@ -155,7 +154,7 @@ mod tests {
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(is_background(l) && l.name == "Background", "{depth}");
|
||||
assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// Its pixels stay; transparency takes the background colour (blue).
|
||||
let red = pixel(&s, 3, 3);
|
||||
@@ -170,10 +169,10 @@ mod tests {
|
||||
assert!(s.undo());
|
||||
assert_eq!(order(&s), [l0, a, b], "{depth}");
|
||||
let l = doc(&s).layer(a).unwrap();
|
||||
assert!(!is_background(l) && l.name == "A");
|
||||
assert!(!doc(&s).is_background(l.id) && l.name == "A");
|
||||
assert!(s.redo());
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}");
|
||||
assert!(is_background(&doc(&s).layers[0]));
|
||||
assert!(doc(&s).is_background(doc(&s).layers[0].id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +190,7 @@ mod tests {
|
||||
assert_eq!(d.layers[1].id, l0);
|
||||
assert_eq!(d.layers[2].id, a);
|
||||
let bg = &d.layers[0];
|
||||
assert!(is_background(bg) && !bg.visible, "visibility is kept");
|
||||
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
|
||||
assert_eq!(bg.opacity, 1.0);
|
||||
}
|
||||
|
||||
@@ -211,7 +210,7 @@ mod tests {
|
||||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
// A group or an adjustment layer can't become the Background.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
@@ -220,7 +219,7 @@ mod tests {
|
||||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
assert!(doc(&s).background().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -415,8 +415,9 @@ pub struct LiveStroke {
|
||||
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
|
||||
mirrors: Vec<StrokeRenderer>,
|
||||
mirror_distinct: Vec<bool>,
|
||||
/// Restore passes separately; their union may span an otherwise untouched document.
|
||||
symmetry_regions: Vec<Rect>,
|
||||
/// What the symmetric preview drew last, once a mirror differs from the stroke, and how many
|
||||
/// passes it merged (a mirror joining redraws every tile).
|
||||
union_live: Option<(photocraft_paint::UnionLive, usize)>,
|
||||
pre: Surface,
|
||||
sel: Option<Surface>,
|
||||
lock: bool,
|
||||
@@ -508,7 +509,7 @@ impl LiveStroke {
|
||||
symmetry,
|
||||
mirrors,
|
||||
mirror_distinct,
|
||||
symmetry_regions: Vec::new(),
|
||||
union_live: None,
|
||||
pre,
|
||||
sel,
|
||||
lock,
|
||||
@@ -567,33 +568,27 @@ impl LiveStroke {
|
||||
}
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
if self.mirror_distinct.iter().any(|d| *d) {
|
||||
// Merge every finished pass once, both here and on commit, including shared axes.
|
||||
// The stroke and its distinct mirrors composite as one stroke, as the commit
|
||||
// merges them; only the tiles that changed are redrawn.
|
||||
let old = self.tail;
|
||||
let mut original_preview = self.renderer.clone();
|
||||
original_preview.finish();
|
||||
let copies: Vec<_> = self
|
||||
.mirrors
|
||||
.iter()
|
||||
.zip(&self.mirror_distinct)
|
||||
.filter(|(_, d)| **d)
|
||||
.map(|(r, _)| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count();
|
||||
let (mut live, full, mut dmg) = match self.union_live.take() {
|
||||
Some((live, merged)) => (live, merged != passes, Rect::EMPTY),
|
||||
None => {
|
||||
// Leaving the single-pass preview: take its smoothing tail back first.
|
||||
if !old.is_empty() {
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
}
|
||||
(photocraft_paint::UnionLive::default(), true, old)
|
||||
}
|
||||
};
|
||||
let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer)
|
||||
.chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r))
|
||||
.collect();
|
||||
let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds()));
|
||||
if self.symmetry_regions.is_empty() && !old.is_empty() {
|
||||
surf.write_region(old, &self.pre.read_region(old));
|
||||
}
|
||||
for region in &self.symmetry_regions {
|
||||
surf.write_region(*region, &self.pre.read_region(*region));
|
||||
}
|
||||
self.symmetry_regions =
|
||||
std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect();
|
||||
let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock);
|
||||
self.tail = bounds;
|
||||
return Ok(damage.union(&bounds));
|
||||
dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock));
|
||||
self.tail = old.union(&live.bounds());
|
||||
self.union_live = Some((live, passes));
|
||||
return Ok(dmg);
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||||
|
||||
@@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
|
||||
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
|
||||
pub fn follows_target(id: &str) -> bool {
|
||||
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
|
||||
routed(id) || id == "select.float"
|
||||
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
|
||||
routed(id) || id == "select.float" || id == "layer.translate"
|
||||
}
|
||||
|
||||
/// Commands whose target follows the Channels panel.
|
||||
|
||||
@@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
if locks.pixels || locks.all {
|
||||
return Err(format!("the layer \"{}\" is locked", l.name));
|
||||
}
|
||||
if locks.transparency && !crate::extra_cmds::is_background(l) {
|
||||
if locks.transparency && !d.doc.is_background(l.id) {
|
||||
return Err(format!("the transparency of layer \"{}\" is locked", l.name));
|
||||
}
|
||||
Ok(())
|
||||
@@ -217,7 +217,7 @@ mod tests {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(name(&s), "Background");
|
||||
let d = s.active().unwrap();
|
||||
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
|
||||
assert!(d.doc.is_background(d.active_layer.unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -878,7 +878,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Move Layer",
|
||||
[],
|
||||
None,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
|
||||
r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##,
|
||||
has_layer,
|
||||
crate::layer_multi_cmds::translate
|
||||
),
|
||||
@@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result<Layer> {
|
||||
let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
// A copy of the Background layer is an ordinary, unlocked layer (Photoshop).
|
||||
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
|
||||
let from_background = doc.is_background(id);
|
||||
let mut dup = src.duplicate();
|
||||
dup.name = doc.copy_name(&dup.name);
|
||||
if from_background {
|
||||
|
||||
@@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does).
|
||||
fn float_background(l: &mut Layer) {
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
}
|
||||
|
||||
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
|
||||
let locks = l.locks.union(group);
|
||||
if locks.all || locks.pixels || (position && locks.position) {
|
||||
@@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool,
|
||||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
// A Background gains transparency when the distortion moves pixels (as in Photoshop).
|
||||
if moves {
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
}
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
|
||||
}
|
||||
if moves {
|
||||
float_background(l);
|
||||
}
|
||||
check_locks(l, group, moves)?;
|
||||
let LayerContent::Raster(surf) = &mut l.content else {
|
||||
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
|
||||
|
||||
@@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option<Box<Document>> {
|
||||
let mut copies = scratch_doc(&d.doc);
|
||||
for id in ids {
|
||||
let mut l = d.doc.layer(*id)?.clone();
|
||||
if crate::extra_cmds::is_background(&l) {
|
||||
if d.doc.is_background(*id) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks = Default::default();
|
||||
}
|
||||
@@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu
|
||||
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
|
||||
/// transparency, so there the area is filled with the background colour instead (Photoshop).
|
||||
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
|
||||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let bg = doc.is_background(id);
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
if bg {
|
||||
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
|
||||
@@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
let lowest = visible[0];
|
||||
let base = doc.layers[lowest].clone();
|
||||
let is_background = base.name == "Background" && base.locks.transparency;
|
||||
let is_background = lowest == 0 && doc.is_background(base.id);
|
||||
// Composite of visible layers only (hidden ones stay where they are).
|
||||
let mut solo = doc.clone();
|
||||
solo.layers.retain(|l| l.visible);
|
||||
@@ -1363,7 +1364,7 @@ mod tests {
|
||||
let mut s = fresh();
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
|
||||
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
|
||||
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
|
||||
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
|
||||
}
|
||||
@@ -1609,9 +1610,9 @@ mod tests {
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layers.len(), 4);
|
||||
let pasted = &st.doc.layers[st.doc.layers.len() - 2];
|
||||
assert!(!crate::extra_cmds::is_background(pasted));
|
||||
assert!(!st.doc.is_background(pasted.id));
|
||||
assert_eq!(pasted.locks, Default::default());
|
||||
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
|
||||
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
|
||||
}
|
||||
|
||||
/// #2428: a smart object copied in one document pastes into another as a smart object, in
|
||||
|
||||
@@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result<LayerId> {
|
||||
s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer"))
|
||||
}
|
||||
|
||||
fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
/// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
if is_background(l) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
|
||||
@@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bg = s.tools.background;
|
||||
let dmg = s.edit("Magic Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
|
||||
// Photoshop converts the Background to a normal layer before erasing to transparency.
|
||||
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let lock = doc.effective_locks(id).transparency;
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
@@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = pixels_locked(&d.doc, l) {
|
||||
return Err(e);
|
||||
}
|
||||
if d.doc.effective_locks(id).transparency && !is_background(l) {
|
||||
if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) {
|
||||
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
}
|
||||
let stroke = Stroke { brush, points: pts };
|
||||
let dmg = s.edit("Background Eraser", |doc, _| {
|
||||
let selection = doc.selection.clone();
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
unlock_background(l);
|
||||
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
|
||||
Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref()))
|
||||
})?;
|
||||
|
||||
@@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
|
||||
}
|
||||
|
||||
pub(crate) fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn has_background(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
|
||||
}
|
||||
|
||||
/// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with
|
||||
@@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
|
||||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
|
||||
}
|
||||
@@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect {
|
||||
|
||||
fn layer_from_background(s: &mut Session) -> Result<Value> {
|
||||
let id = s.edit("Layer From Background", |doc, active| {
|
||||
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
unlock_background(l);
|
||||
*active = Some(l.id);
|
||||
Ok(l.id)
|
||||
@@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) {
|
||||
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
|
||||
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
|
||||
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
|
||||
background_to_layer(doc, id);
|
||||
}
|
||||
|
||||
/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns
|
||||
/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't
|
||||
/// take (moving, transforming, exposing transparency, a style). Other layers are left alone.
|
||||
pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.background_mut().filter(|l| l.id == id) {
|
||||
unlock_background(l);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ impl CutParts {
|
||||
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
|
||||
let mut rest = if mask { surf.clone() } else { full.clone() };
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
|
||||
let fill = !copy && (mask || doc.is_background(layer));
|
||||
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
|
||||
if !b.is_empty() {
|
||||
@@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
|
||||
/// floating? The Background is position-locked, but its selected pixels move (Photoshop).
|
||||
pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
|
||||
locks.all || (locks.position && !doc.is_background(l.id))
|
||||
}
|
||||
|
||||
fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let gid = match content {
|
||||
Some(id) => {
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
|
||||
if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) {
|
||||
return Err(bad("only a pixel layer (not the Background) can go in a frame".into()));
|
||||
}
|
||||
let gid = doc.insert_above(Some(id), Layer::group(name, vec![]));
|
||||
|
||||
@@ -298,7 +298,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
||||
// A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area
|
||||
// an extending Crop adds is filled with the extension (background) colour.
|
||||
if extends
|
||||
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let old = old_canvas.translate(dx, dy);
|
||||
@@ -315,22 +315,28 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn anchor_factors(a: &str) -> (f64, f64) {
|
||||
let x = if a.contains("Left") || a == "left" {
|
||||
0.0
|
||||
} else if a.contains("Right") || a == "right" {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let y = if a.starts_with("top") {
|
||||
0.0
|
||||
} else if a.starts_with("bottom") {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
(x, y)
|
||||
/// The nine documented canvas-size anchors, in the order the command lists them, each mapped to
|
||||
/// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge).
|
||||
const ANCHORS: [(&str, (f64, f64)); 9] = [
|
||||
("topLeft", (0.0, 0.0)),
|
||||
("top", (0.5, 0.0)),
|
||||
("topRight", (1.0, 0.0)),
|
||||
("left", (0.0, 0.5)),
|
||||
("center", (0.5, 0.5)),
|
||||
("right", (1.0, 0.5)),
|
||||
("bottomLeft", (0.0, 1.0)),
|
||||
("bottom", (0.5, 1.0)),
|
||||
("bottomRight", (1.0, 1.0)),
|
||||
];
|
||||
|
||||
/// Resolves an anchor name to its (x, y) factors, matching the nine documented values exactly.
|
||||
/// Anything else is an error naming `anchor` and listing them, rather than resolving the two axes
|
||||
/// independently (which let a misspelling like `"topleft"` land between two valid anchors, #2946).
|
||||
fn anchor_factors(cmd: &str, a: &str) -> Result<(f64, f64)> {
|
||||
ANCHORS.iter().find(|(name, _)| *name == a).map(|&(_, f)| f).ok_or_else(|| {
|
||||
let names = ANCHORS.iter().map(|&(n, _)| n).collect::<Vec<_>>().join("|");
|
||||
bad(cmd, format!("`anchor` must be one of {names}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn extension_color(s: &Session, p: &Value) -> [f32; 4] {
|
||||
@@ -357,7 +363,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh });
|
||||
let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) };
|
||||
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
|
||||
let (ax, ay) = anchor_factors(p.get("anchor").and_then(Value::as_str).unwrap_or("center"));
|
||||
let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?;
|
||||
let dx = ((nw as f64 - ow) * ax).round() as i32;
|
||||
let dy = ((nh as f64 - oh) * ay).round() as i32;
|
||||
let ext = extension_color(s, p);
|
||||
@@ -373,16 +379,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A shrink or no-op resize must not silently convert the Background.
|
||||
if ext[3] == 0.0
|
||||
&& (nw > ow as u32 || nh > oh as u32)
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& crate::extra_cmds::is_background(bg)
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
{
|
||||
crate::extra_cmds::unlock_background(bg);
|
||||
}
|
||||
// The locked Background layer is extended with the extension colour.
|
||||
if ext[3] > 0.0
|
||||
&& let Some(bg) = doc.layers.first_mut()
|
||||
&& bg.name == "Background"
|
||||
&& bg.locks.transparency
|
||||
&& let Some(bg) = doc.background_mut()
|
||||
&& let LayerContent::Raster(surf) = &mut bg.content
|
||||
{
|
||||
let fmt = surf.format();
|
||||
@@ -980,6 +983,42 @@ mod tests {
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_size_anchor_offsets_and_rejects_unlisted() {
|
||||
// 64x48 -> 100x80: dx spans 36 and dy spans 32, so each anchor factor lands on a known
|
||||
// offset. All nine documented values must anchor exactly where they do today (#2946).
|
||||
let expected = [
|
||||
("topLeft", [0, 0]),
|
||||
("top", [18, 0]),
|
||||
("topRight", [36, 0]),
|
||||
("left", [0, 16]),
|
||||
("center", [18, 16]),
|
||||
("right", [36, 16]),
|
||||
("bottomLeft", [0, 32]),
|
||||
("bottom", [18, 32]),
|
||||
("bottomRight", [36, 32]),
|
||||
];
|
||||
for (anchor, off) in expected {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
let r = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap();
|
||||
assert_eq!(r["offset"], json!(off), "anchor {anchor}");
|
||||
}
|
||||
// An omitted anchor still defaults to center.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
assert_eq!(s.execute("image.canvasSize", json!({"width": 100, "height": 80})).unwrap()["offset"], json!([18, 16]));
|
||||
// A misspelling or unknown value is a BadParams error naming `anchor`, never an anchor
|
||||
// between two of the nine (the old independent-axis matching produced top-centre etc.).
|
||||
for anchor in ["topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
let err = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "anchor {anchor:?} must be rejected, got {err:?}");
|
||||
assert!(err.to_string().contains("anchor"), "the error for {anchor:?} must name `anchor`: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_to_selection_and_trim() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
||||
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
|
||||
if sdoc.is_background(*id) {
|
||||
l.name = ddoc.next_layer_name("Layer");
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result<Vec<Lay
|
||||
return Ok(ids);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect();
|
||||
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
|
||||
}
|
||||
@@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub(crate) fn is_locked_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
fn select_all_layers(s: &mut Session) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
|
||||
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("there are no layers to select".into()));
|
||||
}
|
||||
@@ -283,7 +279,14 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does a move aimed at the layer mask (`"target":"mask"`) move `l`'s mask alone? Only an
|
||||
/// unlinked mask does (the chain off); a linked one moves with its layer, as in Photoshop.
|
||||
pub fn moves_mask_alone(l: &Layer) -> bool {
|
||||
l.mask.as_ref().is_some_and(|m| !m.linked)
|
||||
}
|
||||
|
||||
/// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers.
|
||||
/// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone.
|
||||
pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0);
|
||||
let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0);
|
||||
@@ -297,14 +300,27 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("no active layer".into()));
|
||||
}
|
||||
let mask_target = p.get("target").and_then(Value::as_str) == Some("mask");
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let ids = s.edit("Move", |doc, _| {
|
||||
let ids = move_targets(doc, &roots);
|
||||
if ids.is_empty() {
|
||||
let (alone, rest): (Vec<LayerId>, Vec<LayerId>) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone));
|
||||
let mut ids = move_targets(doc, &rest);
|
||||
if ids.is_empty() && alone.is_empty() {
|
||||
return Err(EngineError::NoLayer(roots[0]));
|
||||
}
|
||||
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)?;
|
||||
for id in alone {
|
||||
let locks = doc.effective_locks(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
// A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070).
|
||||
if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) {
|
||||
crate::artboard_cmds::fit_canvas(doc);
|
||||
@@ -326,6 +342,11 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
|
||||
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
|
||||
/// copying the moving pixels only.
|
||||
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
|
||||
moved_target(doc, ids, dx, dy, false)
|
||||
}
|
||||
|
||||
/// [`moved`] for a drag aimed at the layer mask (`mask_target`): unlinked masks move alone.
|
||||
pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_target: bool) -> Result<Document> {
|
||||
let mut out = doc.clone();
|
||||
for &id in ids {
|
||||
let locks = doc.effective_locks(id);
|
||||
@@ -333,6 +354,12 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Docume
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
if mask_target && moves_mask_alone(l) {
|
||||
if let Some(m) = &mut l.mask {
|
||||
m.surface = shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
shift_shown(doc, l, dx, dy);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -1444,6 +1471,59 @@ mod tests {
|
||||
s.active().unwrap().last_damage
|
||||
}
|
||||
|
||||
/// A mask with its chain off stays put while the layer moves; a linked one moves along.
|
||||
#[test]
|
||||
fn translate_leaves_an_unlinked_mask_in_place() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = session(depth);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
|
||||
assert!(mval(&s, 14, 14) > 0.9, "{depth}-bit: unlinked mask stays");
|
||||
assert!(mval(&s, 44, 14) < 0.1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.linked", json!({"linked": true})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap();
|
||||
assert!(mval(&s, 44, 14) > 0.9, "{depth}-bit: linked mask moves");
|
||||
}
|
||||
}
|
||||
|
||||
/// Aimed at the mask, a move shifts an unlinked mask alone and leaves a linked one with its layer.
|
||||
#[test]
|
||||
fn translate_with_the_mask_targeted_moves_an_unlinked_mask_alone() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = session(depth);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.layerMask.hideAll", json!({})).unwrap();
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
// Linked: the layer and its mask move together.
|
||||
s.execute("layer.translate", json!({"dx": 30, "dy": 0, "target": "mask"})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit");
|
||||
assert!(mval(&s, 44, 14) > 0.9);
|
||||
s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": -30, "dy": 0, "target": "mask"})).unwrap();
|
||||
assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit: the layer stays");
|
||||
assert!(mval(&s, 14, 14) > 0.9, "the mask moved");
|
||||
assert!(mval(&s, 44, 14) < 0.1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(mval(&s, 44, 14) > 0.9, "undo puts the mask back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_layer_props_report_only_their_area() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
||||
let canvas = doc.bounds();
|
||||
let selection = doc.selection.clone();
|
||||
let locks = doc.effective_locks(id);
|
||||
let background = doc.is_background(id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let LayerContent::Smart(_) = l.content {
|
||||
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
|
||||
@@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).
|
||||
if float_bg && l.locks.position && l.name == "Background" {
|
||||
if float_bg && background {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
|
||||
@@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let canvas = doc.bounds();
|
||||
let fmt = doc.pixel_format();
|
||||
let dpi = doc.resolution_dpi;
|
||||
let background_id = doc.background().map(|l| l.id);
|
||||
for l in doc.layers.iter_mut() {
|
||||
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
|
||||
let background = Some(l.id) == background_id;
|
||||
if background && let Some(surf) = l.surface_mut() {
|
||||
// The Background stays a Background: rotated pixels over the background colour.
|
||||
let src = surf.content_bounds();
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size};
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
@@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
|
||||
}
|
||||
|
||||
/// Maps every layer's pixels and masks through `h` into `clip`.
|
||||
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
|
||||
/// `background_id` is the id of the document's Background, if it has one.
|
||||
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option<LayerId>) -> Result<()> {
|
||||
for l in layers {
|
||||
crate::allocation::checkpoint("a perspective crop")?;
|
||||
let background = crate::extra_cmds::is_background(l);
|
||||
let background = background_id == Some(l.id);
|
||||
match &mut l.content {
|
||||
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
|
||||
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
|
||||
LayerContent::Raster(surf) => {
|
||||
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
|
||||
*surf = if background {
|
||||
@@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32)
|
||||
let (w, ht) = size;
|
||||
let clip = Rect::from_xywh(0, 0, w, ht);
|
||||
let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg);
|
||||
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
|
||||
let background_id = doc.background().map(|l| l.id);
|
||||
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
|
||||
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
|
||||
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
|
||||
let canvas = doc.bounds();
|
||||
for (path, _, l) in doc.walk().iter().rev() {
|
||||
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|
||||
|| crate::layer_multi_cmds::is_locked_background(l)
|
||||
|| doc.is_background(l.id)
|
||||
|| doc.effective_locks(l.id).all
|
||||
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
|
||||
{
|
||||
|
||||
@@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = doc.mode;
|
||||
let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect();
|
||||
for id in &ids {
|
||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
|
||||
if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
crate::extra_cmds::background_to_layer(doc, *id);
|
||||
let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
if add {
|
||||
l.effects.items.extend(effects.iter().cloned());
|
||||
} else {
|
||||
|
||||
@@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> {
|
||||
if matches!(l.content, LayerContent::Adjustment(_)) {
|
||||
return Err("adjustment layers have no bounds to slice".into());
|
||||
}
|
||||
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
|
||||
if d.doc.is_background(id) {
|
||||
return Err("the Background layer can't have a layer-based slice".into());
|
||||
}
|
||||
if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) {
|
||||
|
||||
@@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool {
|
||||
/// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so
|
||||
/// the document looks the same before and after.
|
||||
pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result<Layer> {
|
||||
let background = l.name == "Background" && l.locks.position;
|
||||
let background = doc.is_background(l.id);
|
||||
let mut inner = l.clone();
|
||||
inner.visible = true;
|
||||
inner.opacity = 1.0;
|
||||
|
||||
@@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc:
|
||||
if src.depth != doc.depth {
|
||||
crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth);
|
||||
}
|
||||
let background_id = src.background().map(|l| l.id);
|
||||
let mut layers = std::mem::take(&mut src.layers);
|
||||
for l in &mut layers {
|
||||
if crate::extra_cmds::is_background(l) {
|
||||
if Some(l.id) == background_id {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks = Default::default();
|
||||
}
|
||||
|
||||
@@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
|
||||
}
|
||||
|
||||
/// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's
|
||||
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
|
||||
fn transform_locks(l: &Layer, group: Locks) -> Locks {
|
||||
/// position lock doesn't count: Photoshop turns it into a normal layer first.
|
||||
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
|
||||
let mut own = l.locks;
|
||||
if own.position && l.name == "Background" {
|
||||
if background {
|
||||
own.position = false;
|
||||
own.transparency = false;
|
||||
}
|
||||
@@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks {
|
||||
pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
|
||||
let Some(l) = doc.layer(id) else { return false };
|
||||
// An explicit stack rather than recursion: documents can nest deeply.
|
||||
let mut stack = vec![(l, group_locks(doc, id))];
|
||||
while let Some((l, group)) = stack.pop() {
|
||||
let locks = transform_locks(l, group);
|
||||
let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))];
|
||||
while let Some((l, group, background)) = stack.pop() {
|
||||
let locks = transform_locks(l, group, background);
|
||||
if locks.position || locks.all {
|
||||
return true;
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content {
|
||||
stack.extend(g.children.iter().map(|c| (c, locks)));
|
||||
stack.extend(g.children.iter().map(|c| (c, locks, false)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
|
||||
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background
|
||||
/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]).
|
||||
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
crate::allocation::checkpoint("transforming pixels")?;
|
||||
let locks = transform_locks(l, group);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
let locks = transform_locks(l, group, false);
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
@@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
|
||||
if !h.is_identity() {
|
||||
|
||||
@@ -121,10 +121,23 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The PostScript name of the installed face of `family` at exactly `weight`/`italic`. A PSD names
|
||||
/// a type layer by PostScript name, and Photoshop treats a name no font has as missing (#3556).
|
||||
/// Only an exact face is named: layout picks a named face before weight and italic, so naming a
|
||||
/// nearby face would override the requested weight (a variable font's axis, a weight between two
|
||||
/// faces) or italic. Without an exact face the style keeps no name and layout matches as before.
|
||||
fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option<String> {
|
||||
let mut shared = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let faces = shared.fonts.faces(family);
|
||||
let face = faces.iter().find(|f| f.italic == italic && f.weight.round() as u16 == weight)?;
|
||||
face.postscript_name.clone()
|
||||
}
|
||||
|
||||
/// Applies character-style keys from JSON. Returns true if any key was present.
|
||||
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
let mut any = false;
|
||||
let mut hit = |b: bool| any |= b;
|
||||
let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some());
|
||||
if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) {
|
||||
s.font_family = v.to_string();
|
||||
s.postscript_name = None;
|
||||
@@ -169,6 +182,11 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
s.postscript_name = Some(v.to_string());
|
||||
hit(true);
|
||||
}
|
||||
// A font named by family (or a style that matched no face) has no PostScript name yet: fill in
|
||||
// the installed face's real name, so the PSD does not invent "<Family>-Regular" (#3556).
|
||||
if font_key && s.postscript_name.is_none() {
|
||||
s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic);
|
||||
}
|
||||
if let Some(v) = f32p(p, "size") {
|
||||
s.size_pt = v.max(0.01);
|
||||
hit(true);
|
||||
@@ -989,14 +1007,36 @@ mod tests {
|
||||
assert_eq!(style.weight, 600);
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
|
||||
apply_char_props(&mut style, &json!({"weight": 400}));
|
||||
assert!(style.postscript_name.is_none());
|
||||
// #3556: a style change re-resolves to the installed face instead of leaving it nameless.
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
|
||||
assert!(style.font_style.is_empty());
|
||||
apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"}));
|
||||
assert!(style.postscript_name.is_none());
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
|
||||
apply_char_props(&mut style, &json!({"fontStyle": "Regular", "postscriptName": "ExplicitFace"}));
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("ExplicitFace"));
|
||||
}
|
||||
|
||||
/// #3556: a font named by family stores the installed face's PostScript name, so PSD export
|
||||
/// names a real font instead of inventing "<Family>-Regular" that Photoshop calls missing.
|
||||
#[test]
|
||||
fn a_family_font_gets_the_installed_postscript_name() {
|
||||
let mut style = CharStyle { font_family: "Inter".into(), ..Default::default() };
|
||||
apply_char_props(&mut style, &json!({"font": "Inter"}));
|
||||
assert_eq!(style.font_family, "Inter");
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"), "resolved from the font database");
|
||||
// A weight picks that face's name.
|
||||
apply_char_props(&mut style, &json!({"font": "Inter", "weight": 600}));
|
||||
assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold"));
|
||||
// A weight no face has (a variable font's axis, or between two faces) names no face, so
|
||||
// layout still matches the requested weight instead of a nearby face's.
|
||||
apply_char_props(&mut style, &json!({"weight": 650}));
|
||||
assert!(style.postscript_name.is_none(), "{:?}", style.postscript_name);
|
||||
// A family PhotoCraft does not have keeps no name; the export falls back as before.
|
||||
let mut absent = CharStyle { font_family: "No Such Family 123".into(), ..Default::default() };
|
||||
apply_char_props(&mut absent, &json!({"font": "No Such Family 123"}));
|
||||
assert!(absent.postscript_name.is_none());
|
||||
}
|
||||
|
||||
/// Picking a style or weight resets the weight axis to it; other axes keep their values (#2985).
|
||||
#[test]
|
||||
fn a_style_or_weight_resets_the_weight_axis() {
|
||||
|
||||
@@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// A Background is turned into a normal layer by the caller first
|
||||
/// ([`crate::extra_cmds::background_to_layer`]).
|
||||
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
|
||||
if l.locks.position && l.name == "Background" {
|
||||
l.locks.position = false;
|
||||
l.locks.transparency = false;
|
||||
l.name = "Layer 0".into();
|
||||
}
|
||||
let locks = l.locks.union(group);
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
@@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
let sel = doc.selection.clone();
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = crate::transform_cmds::group_locks(doc, id);
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
|
||||
let snapshot = doc.clone();
|
||||
|
||||
@@ -19,6 +19,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["unbounded_depth"] }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
|
||||
crc32fast = "1.5"
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
ruzstd = "0.9"
|
||||
|
||||
|
||||
+68
-20
@@ -3,6 +3,7 @@
|
||||
//! unzipped and re-zipped by another tool still loads). No ZIP64, no
|
||||
//! encryption, no multi-disk archives.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::FormatError;
|
||||
@@ -11,25 +12,9 @@ const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const EOCD_SIG: u32 = 0x0605_4b50;
|
||||
|
||||
/// CRC-32 (IEEE), table-driven.
|
||||
/// CRC-32 (IEEE), as stored in ZIP headers.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
|
||||
let t = TABLE.get_or_init(|| {
|
||||
let mut t = [0u32; 256];
|
||||
for (i, e) in t.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*e = c;
|
||||
}
|
||||
t
|
||||
});
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
|
||||
}
|
||||
!c
|
||||
crc32fast::hash(data)
|
||||
}
|
||||
|
||||
/// Streaming writer for STORE entries.
|
||||
@@ -122,7 +107,12 @@ pub struct Entry {
|
||||
/// Read-only view of an archive in memory.
|
||||
pub struct ZipReader<'a> {
|
||||
bytes: &'a [u8],
|
||||
/// All entries, in central-directory order. Treat as read-only: `find`
|
||||
/// looks names up through an index built from this list in `new`.
|
||||
pub entries: Vec<Entry>,
|
||||
/// Name -> index of the first entry with that name, so `find` is O(1)
|
||||
/// instead of a scan per lookup (a bundle reads one entry per tile).
|
||||
by_name: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], o: usize) -> Result<u16, FormatError> {
|
||||
@@ -194,11 +184,16 @@ impl<'a> ZipReader<'a> {
|
||||
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
|
||||
p = entry_end;
|
||||
}
|
||||
Ok(ZipReader { bytes, entries })
|
||||
let mut by_name = HashMap::with_capacity(entries.len());
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
// Duplicate names: the first entry wins, as with a front-to-back scan.
|
||||
by_name.entry(e.name.clone()).or_insert(i);
|
||||
}
|
||||
Ok(ZipReader { bytes, entries, by_name })
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.name == name)
|
||||
self.by_name.get(name).and_then(|&i| self.entries.get(i))
|
||||
}
|
||||
|
||||
/// Entry payload (decompressed), at most `max` bytes, CRC-checked.
|
||||
@@ -263,6 +258,59 @@ mod tests {
|
||||
assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_matches_bitwise_reference() {
|
||||
// Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross
|
||||
// any SIMD / slicing block size.
|
||||
fn reference(data: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for len in [0, 1, 7, 15, 16, 17, 63, 64, 65, 255, 256, 1000] {
|
||||
assert_eq!(crc32(&data[..len]), reference(&data[..len]), "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_locates_every_entry_in_a_large_archive() {
|
||||
let mut w = ZipWriter::new();
|
||||
let n = 3000u32;
|
||||
for i in 0..n {
|
||||
w.add(&format!("tiles/{i:05}.zst"), &i.to_le_bytes()).unwrap();
|
||||
}
|
||||
let bytes = w.finish().unwrap();
|
||||
let r = ZipReader::new(&bytes).unwrap();
|
||||
assert_eq!(r.entries.len(), n as usize);
|
||||
for i in (0..n).rev() {
|
||||
let name = format!("tiles/{i:05}.zst");
|
||||
let e = r.find(&name).unwrap();
|
||||
assert_eq!(e.name, name);
|
||||
assert_eq!(r.read(e, 4).unwrap(), i.to_le_bytes());
|
||||
}
|
||||
assert!(r.find("tiles/missing.zst").is_none());
|
||||
assert!(r.find("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_resolve_to_the_first_entry() {
|
||||
let mut w = ZipWriter::new();
|
||||
w.add("a", b"first").unwrap();
|
||||
w.add("b", b"other").unwrap();
|
||||
w.add("a", b"second").unwrap();
|
||||
let bytes = w.finish().unwrap();
|
||||
let r = ZipReader::new(&bytes).unwrap();
|
||||
assert_eq!(r.entries.len(), 3);
|
||||
assert_eq!(r.read_by_name("a", 100).unwrap(), b"first");
|
||||
assert_eq!(r.read_by_name("b", 100).unwrap(), b"other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_mismatch_detected() {
|
||||
let mut w = ZipWriter::new();
|
||||
|
||||
+35
-15
@@ -1152,31 +1152,33 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
|
||||
return mout(acc);
|
||||
}
|
||||
|
||||
// Precise glow from a distance map: p0 = (solid, soft, invert).
|
||||
// Precise glow from a distance map (`effects::glow_map`): p0 = (solid, soft). Coverage is 0.5 at
|
||||
// the edge falling to 0 at `size + 1` px centre to centre (the field is 0.5 px short of that),
|
||||
// solid within the spread / choke.
|
||||
@fragment
|
||||
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
|
||||
let d = textureLoad(tex_a, local(in.pos), 0).r;
|
||||
var m = clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
|
||||
let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5;
|
||||
var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
|
||||
if (d <= op.p0.x) { m = 1.0; }
|
||||
if (op.p0.z > 0.5) { m = 1.0 - m; }
|
||||
return mout(m);
|
||||
}
|
||||
|
||||
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v, contour (LUT row 0), × shape
|
||||
// (`layer_tex`)).
|
||||
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v (1: before the contour, 2: after it,
|
||||
// as a centre-source inner glow), contour (LUT row 0), × shape (`layer_tex`)).
|
||||
@fragment
|
||||
fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
var v = textureLoad(tex_a, p, 0).r;
|
||||
if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); }
|
||||
if (op.p0.y > 0.5) { v = 1.0 - v; }
|
||||
if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; }
|
||||
if (op.p0.z > 0.5) { v = lut(0, v); }
|
||||
if (op.p0.y > 1.5) { v = 1.0 - v; }
|
||||
if (op.p0.w > 0.5) { v = v * textureLoad(layer_tex, p, 0).r; }
|
||||
return mout(v);
|
||||
}
|
||||
|
||||
// Chiselled bevel height from A = dist_inside, B = dist_outside (`effects::bevel_chisel_h`):
|
||||
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size).
|
||||
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size, Chisel Soft).
|
||||
@fragment
|
||||
fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
@@ -1184,16 +1186,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
|
||||
let dout = textureLoad(tex_b, p, 0).r;
|
||||
let size = op.p0.y;
|
||||
var h: f32;
|
||||
// The ramp spans size + 1 px, from half a pixel beyond the edge (`effects::bevel_chisel_h`).
|
||||
let ramp = size + 1.0;
|
||||
let soft = op.p0.z > 0.5;
|
||||
switch i32(op.p0.x) {
|
||||
case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.0); }
|
||||
case 1: {
|
||||
if (din > 0.0) {
|
||||
h = 0.5 + 0.5 * clamp(din / (size / 2.0), 0.0, 1.0);
|
||||
case 0: {
|
||||
if (soft) {
|
||||
h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0);
|
||||
} else {
|
||||
h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0);
|
||||
let top = (size + 2.0) / ramp;
|
||||
h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0);
|
||||
}
|
||||
}
|
||||
case 1: {
|
||||
let half = size / 2.0 + 0.5;
|
||||
if (din > 0.0) {
|
||||
h = 0.5 + 0.5 * clamp(din / half, 0.0, 1.0);
|
||||
} else {
|
||||
h = 0.5 - 0.5 * clamp(dout / half, 0.0, 1.0);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
if (soft) {
|
||||
h = select(0.0, clamp((din + 1.5) / ramp, 0.0, (size + 2.0) / ramp), din > 0.0);
|
||||
} else {
|
||||
h = select(0.0, clamp((din + 0.5) / ramp, 0.0, 1.0), din > 0.0);
|
||||
}
|
||||
}
|
||||
default: { h = clamp(din / size, 0.0, 1.0); }
|
||||
}
|
||||
return mout(h);
|
||||
}
|
||||
@@ -1207,7 +1226,8 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
||||
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
|
||||
// 4-neighbour's).
|
||||
var hmax = ra(p);
|
||||
if (op.p1.y > 1.5) {
|
||||
// p3.x: an outer chisel bevel, which also reaches one pixel past its ramp.
|
||||
if (op.p1.y > 1.5 || op.p3.x > 0.5) {
|
||||
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
|
||||
}
|
||||
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
|
||||
|
||||
+22
-6
@@ -236,6 +236,19 @@ impl B {
|
||||
fn finish(&mut self, a: In, b: Option<In>, invert: bool, contour: &Contour, times_shape: bool, out: usize) {
|
||||
self.finish_lut(a, b, invert, contour_lut(contour), times_shape, out);
|
||||
}
|
||||
/// A glow's last step: its contour over the Range, then (centre-source inner glows) the
|
||||
/// inverse, then × the shape when inner.
|
||||
fn finish_glow(&mut self, a: In, lut: Option<Vec<f32>>, inner: bool, center: bool) {
|
||||
let mut s =
|
||||
stage(Kernel::MFinish, Some(a), None, [0.0, if center { 2.0 } else { 0.0 }, f32::from(u8::from(lut.is_some())), f32::from(u8::from(inner))], 0);
|
||||
s.lut = lut.map(Arc::new);
|
||||
if inner {
|
||||
s.s = Some(In::Shape);
|
||||
}
|
||||
s.out = Some(0);
|
||||
self.stages.push(s);
|
||||
}
|
||||
|
||||
fn finish_lut(&mut self, a: In, b: Option<In>, invert: bool, lut: Option<Vec<f32>>, times_shape: bool, out: usize) {
|
||||
let flag = |v: bool| f32::from(u8::from(v));
|
||||
let mut s = stage(Kernel::MFinish, Some(a), b, [flag(b.is_some()), flag(invert), flag(lut.is_some()), flag(times_shape)], 0);
|
||||
@@ -319,9 +332,9 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
GlowTechnique::Precise => {
|
||||
let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size);
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0));
|
||||
b.finish_lut(m, None, false, photocraft_compose::effects::glow_lut(g), inner, 0);
|
||||
let soft = (g.size + 1.0 - solid).max(1e-3);
|
||||
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0));
|
||||
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
|
||||
}
|
||||
GlowTechnique::Softer => {
|
||||
// Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha.
|
||||
@@ -333,7 +346,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
m = b.dilate(src, d, r);
|
||||
}
|
||||
let m = b.blur(m, bw);
|
||||
b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0);
|
||||
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
|
||||
}
|
||||
}
|
||||
1
|
||||
@@ -363,8 +376,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
} else {
|
||||
let din = b.field(FieldKind::BevelInside, size);
|
||||
let dout = b.field(FieldKind::BevelOutside, size);
|
||||
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0));
|
||||
if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
|
||||
let soft = g.chisel_soft > 0.0;
|
||||
let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0));
|
||||
// As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp.
|
||||
if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h }
|
||||
};
|
||||
if let Some(c) = &bv.contour {
|
||||
// Contour element: the height through the contour (`bevel_contour_lut`).
|
||||
@@ -405,6 +420,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
for which in [0.0, 1.0] {
|
||||
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
|
||||
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
|
||||
s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0];
|
||||
s.lut = lut.clone();
|
||||
s.s = Some(In::Shape);
|
||||
s.out = Some(out);
|
||||
|
||||
+107
-6
@@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
|
||||
/// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour
|
||||
/// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among
|
||||
/// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like
|
||||
/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported.
|
||||
/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab,
|
||||
/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB
|
||||
/// through the colour-managed conversions (the document's CMYK profile when one is in scope).
|
||||
/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported.
|
||||
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
let version = be16(d, 0)?;
|
||||
let reverse = *d.get(2)? != 0;
|
||||
@@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
for _ in 0..n {
|
||||
let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0);
|
||||
let mid = be32(d, at + 4)? as f32 / 100.0;
|
||||
if be16(d, at + 8)? != 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0);
|
||||
stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?)));
|
||||
let space = be16(d, at + 8)?;
|
||||
let c = |k: usize| be16(d, at + 10 + 2 * k);
|
||||
stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?));
|
||||
mids.push(mid);
|
||||
at += 20;
|
||||
}
|
||||
@@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
|
||||
Some(Adjustment::GradientMap { stops, reverse, dither })
|
||||
}
|
||||
|
||||
/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the
|
||||
/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops
|
||||
/// (in whatever colour space it stored them), smoothness and method survive the save. `false`
|
||||
/// leaves `data` untouched.
|
||||
pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool {
|
||||
let Adjustment::GradientMap { stops, reverse, dither } = target else { return false };
|
||||
let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops);
|
||||
match (same_stops, data.get_mut(2..4)) {
|
||||
(true, Some(flags)) => {
|
||||
flags[0] = u8::from(*reverse);
|
||||
flags[1] = u8::from(*dither);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so
|
||||
/// the stops are reproduced exactly).
|
||||
fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec<u8> {
|
||||
@@ -824,6 +842,89 @@ mod tests {
|
||||
assert_eq!(write(&a)[0].1, block);
|
||||
}
|
||||
|
||||
/// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`.
|
||||
fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
|
||||
let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false);
|
||||
// Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop.
|
||||
for (i, (space, c)) in [a, b].into_iter().enumerate() {
|
||||
let at = 24 + 20 * i + 8;
|
||||
v[at..at + 2].copy_from_slice(&space.to_be_bytes());
|
||||
for (k, x) in c.iter().enumerate() {
|
||||
v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) {
|
||||
match a {
|
||||
Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_stops_in_every_colour_space() {
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
let white_rgb = (0, [65535, 65535, 65535, 0]);
|
||||
// CMYK, stored inverted: 65535 = no ink. Full K only.
|
||||
let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb);
|
||||
let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb();
|
||||
for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] {
|
||||
let a = parse(b"grdm", &k_only, None, channels);
|
||||
let (first, last) = ends(&a);
|
||||
assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}");
|
||||
assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}");
|
||||
assert_eq!(last, [1.0; 3]);
|
||||
}
|
||||
// Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white.
|
||||
let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab);
|
||||
let (first, last) = ends(&lab);
|
||||
assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}");
|
||||
assert!(last.iter().all(|v| *v > 0.99), "{last:?}");
|
||||
// Lab with chroma: a = +60 is reddish (a and b are signed hundredths).
|
||||
let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab);
|
||||
let (first, _) = ends(&red);
|
||||
assert!(first[0] > first[1] + 0.2, "{first:?}");
|
||||
// Grayscale: 0..10000 is ink coverage (10000 = black).
|
||||
let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray);
|
||||
assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3]));
|
||||
// HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue.
|
||||
let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb);
|
||||
let (first, _) = ends(&hsb);
|
||||
assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}");
|
||||
// Book colours and unknown ids are not guessed: the block stays raw.
|
||||
for space in [3u16, 9, 0xFFFF] {
|
||||
let d = grdm_with((space, [1, 2, 3, 4]), white_rgb);
|
||||
assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}");
|
||||
}
|
||||
// Truncated stops never panic.
|
||||
for n in 0..k_only.len() {
|
||||
let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_map_reverse_and_dither_keep_the_stops_block() {
|
||||
let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0]));
|
||||
let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() };
|
||||
let mut data = original.clone();
|
||||
let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true };
|
||||
assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk));
|
||||
assert_eq!(&data[2..4], &[1, 1]);
|
||||
assert_eq!(data[4..], original[4..], "the stops keep their colour spaces");
|
||||
assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target);
|
||||
// Edited stops are not patched in.
|
||||
let mut edited = stops;
|
||||
edited[0].1 = [0.5; 3];
|
||||
let mut data2 = original.clone();
|
||||
let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false };
|
||||
assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk));
|
||||
assert_eq!(data2, original);
|
||||
assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk));
|
||||
assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk));
|
||||
}
|
||||
|
||||
fn rt(a: Adjustment) {
|
||||
let blocks = write(&a);
|
||||
assert!(!blocks.is_empty(), "{a:?}");
|
||||
|
||||
@@ -151,6 +151,7 @@ impl Builder {
|
||||
bg.prune();
|
||||
let mut l = Layer::new("Background", LayerContent::Raster(bg));
|
||||
l.locks.transparency = true;
|
||||
l.locks.position = true;
|
||||
layers.insert(0, l);
|
||||
}
|
||||
self.doc.layers = layers;
|
||||
|
||||
@@ -15,13 +15,21 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007;
|
||||
pub(crate) const QUICK_MASK_INFO: u16 = 1022;
|
||||
|
||||
const SPACE_RGB: u16 = 0;
|
||||
const SPACE_HSB: u16 = 1;
|
||||
const SPACE_CMYK: u16 = 2;
|
||||
const SPACE_LAB: u16 = 7;
|
||||
const SPACE_GRAY: u16 = 8;
|
||||
|
||||
/// Resource 1077 for `channels` (in file order).
|
||||
pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
|
||||
/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour
|
||||
/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13;
|
||||
/// 14-byte records (the older 1007 layout) shift every channel after the first.
|
||||
/// `transparency`: the merged image's transparency channel comes first and gets a record too.
|
||||
pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Vec<u8> {
|
||||
let mut out = 1u32.to_be_bytes().to_vec();
|
||||
if transparency {
|
||||
// As Photoshop writes it: red, 100 %, masked areas.
|
||||
out.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 100, 1]);
|
||||
}
|
||||
for c in channels {
|
||||
let (color, opacity, kind) = match c.spot {
|
||||
Some((ink, solidity)) => (ink, solidity, 2u8),
|
||||
@@ -34,15 +42,16 @@ pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
|
||||
}
|
||||
out.extend_from_slice(&((opacity.clamp(0.0, 1.0) * 100.0).round() as u16).to_be_bytes());
|
||||
out.push(kind);
|
||||
out.push(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Applies a DisplayInfo resource (`versioned` = 1077) to the imported channels in order.
|
||||
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [AlphaChannel]) {
|
||||
let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data };
|
||||
for (c, rec) in channels.iter_mut().zip(body.as_chunks::<14>().0) {
|
||||
/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the
|
||||
/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip`
|
||||
/// records (the merged transparency channel's).
|
||||
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) {
|
||||
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
|
||||
for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) {
|
||||
let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]);
|
||||
let comps = [u(2), u(4), u(6), u(8)];
|
||||
let color = decode_color(u(0), comps);
|
||||
@@ -58,9 +67,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [A
|
||||
}
|
||||
}
|
||||
|
||||
/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces
|
||||
/// (book colours…) read the first three components as RGB. See [`decode_known_color`].
|
||||
pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
|
||||
let n = |v: u16| f32::from(v) / 65535.0;
|
||||
match space {
|
||||
decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2])))
|
||||
}
|
||||
|
||||
/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB,
|
||||
/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id.
|
||||
/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see
|
||||
/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion.
|
||||
pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option<Color> {
|
||||
let n = |v: u16| f32::from(v) / 65535.0;
|
||||
Some(match space {
|
||||
SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
|
||||
SPACE_HSB => {
|
||||
let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2]));
|
||||
Color::rgb(rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
// Photoshop colour structures store CMYK inverted (65535 = no ink).
|
||||
SPACE_CMYK => {
|
||||
let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 };
|
||||
@@ -79,7 +104,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color {
|
||||
let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0;
|
||||
Color::rgb(v, v, v)
|
||||
}
|
||||
_ => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// HSB (hue in degrees, saturation and brightness 0..1) → RGB.
|
||||
fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
|
||||
let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 };
|
||||
let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
|
||||
let f = h - h.floor();
|
||||
let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f)));
|
||||
match h.floor() as u8 {
|
||||
0 => [v, t, p],
|
||||
1 => [q, v, p],
|
||||
2 => [p, v, t],
|
||||
3 => [p, q, v],
|
||||
4 => [t, p, v],
|
||||
_ => [v, p, q],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,17 +138,25 @@ mod tests {
|
||||
a.indicates = ColorIndicates::SelectedAreas;
|
||||
let mut s = AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8));
|
||||
s.spot = Some((Color::rgb(1.0, 0.0, 1.0), 0.8));
|
||||
let bytes = display_info(&[&a, &s]);
|
||||
assert_eq!(bytes.len(), 4 + 28);
|
||||
let bytes = display_info(&[&a, &s], false);
|
||||
// 13 bytes per channel, as every corpus file (Photoshop's included) lays out 1077.
|
||||
assert_eq!(bytes.len(), 4 + 2 * 13);
|
||||
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&bytes, true, &mut back);
|
||||
apply_display_info(&bytes, true, 0, &mut back);
|
||||
assert_eq!(back[0].color, a.color);
|
||||
assert!((back[0].opacity - 0.3).abs() < 1e-6);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
// With the merged transparency's record first, the channels still get their own.
|
||||
let with_t = display_info(&[&a, &s], true);
|
||||
assert_eq!(with_t.len(), 4 + 3 * 13);
|
||||
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&with_t, true, 1, &mut back);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
// Truncated data is ignored rather than misread.
|
||||
let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&bytes[..10], true, &mut c);
|
||||
apply_display_info(&bytes[..10], true, 0, &mut c);
|
||||
assert_eq!(c[0].opacity, 0.5);
|
||||
}
|
||||
|
||||
@@ -117,5 +166,13 @@ mod tests {
|
||||
assert_eq!(g.to_rgb(), [0.0; 3]);
|
||||
let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb();
|
||||
assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}");
|
||||
// HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green.
|
||||
let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb();
|
||||
assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}");
|
||||
let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb();
|
||||
assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}");
|
||||
// Unknown spaces (book colours) are not guessed.
|
||||
assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None);
|
||||
assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -376,6 +376,9 @@ mod tests {
|
||||
});
|
||||
d.last_applied_comp = Some(7);
|
||||
let res = write_comps_resource(&d).unwrap();
|
||||
// `list` is a length-4 string id, as Photoshop writes it, never a code.
|
||||
assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list"));
|
||||
assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list"));
|
||||
let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap();
|
||||
// Attach as Photoshop would: shmd with cmls plus another item.
|
||||
let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))];
|
||||
|
||||
@@ -48,9 +48,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St
|
||||
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) });
|
||||
}
|
||||
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, true, 0, &mut doc.channels);
|
||||
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect();
|
||||
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names)));
|
||||
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false)));
|
||||
}
|
||||
if let Some(x) = &doc.metadata.xmp {
|
||||
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
|
||||
|
||||
@@ -529,6 +529,12 @@ impl Ex {
|
||||
let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0;
|
||||
!legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a
|
||||
});
|
||||
// A Gradient Map whose stops are unchanged keeps the file's own block (stops in
|
||||
// the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither
|
||||
// updated in place.
|
||||
let keep = keep
|
||||
|| (matches!(a, photocraft_doc::Adjustment::GradientMap { .. })
|
||||
&& raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels)));
|
||||
if !keep {
|
||||
raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd");
|
||||
let w = adjust_map::write(a);
|
||||
@@ -1435,10 +1441,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
resources.push(ImageResource::new(1032, guides_resource(doc)));
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect();
|
||||
// One entry per extra channel, the merged transparency included (as in every file
|
||||
// Photoshop opens; one short, Photoshop refuses the file with "unexpected end-of-file").
|
||||
let names: Vec<&str> = has_alpha.then_some("Transparency").into_iter().chain(extra.iter().map(|a| a.name.as_str())).collect();
|
||||
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
|
||||
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha)));
|
||||
}
|
||||
if let Some(id) = quick_mask_id {
|
||||
let mut data = id.to_be_bytes().to_vec();
|
||||
@@ -1717,4 +1725,35 @@ mod tests {
|
||||
assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd);
|
||||
assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb);
|
||||
}
|
||||
|
||||
/// A layered document with transparency in the merged image and an alpha channel: the name
|
||||
/// and DisplayInfo resources hold one entry per extra channel, the merged transparency first,
|
||||
/// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could
|
||||
/// not import the clipboard because an unexpected end-of-file was encountered"). The alpha
|
||||
/// channel keeps its own name and options on the way back.
|
||||
#[test]
|
||||
fn alpha_names_count_the_merged_transparency() {
|
||||
let mut doc = document(8, 6);
|
||||
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
|
||||
layer.surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]);
|
||||
doc.layers.push(layer);
|
||||
let mut a = photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(PixelFormat::GRAY8));
|
||||
a.opacity = 0.5;
|
||||
doc.channels.push(a);
|
||||
let file = document_to_psd(&doc);
|
||||
let header_extra = usize::from(file.header.channels) - 3;
|
||||
let names = |id| file.resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap();
|
||||
let pascal = names(1006);
|
||||
let (mut count, mut i) = (0, 0);
|
||||
while i < pascal.len() {
|
||||
count += 1;
|
||||
i += 1 + usize::from(pascal[i]);
|
||||
}
|
||||
assert_eq!((header_extra, count), (2, 2), "transparency + Alpha 1");
|
||||
assert_eq!(names(crate::channel_map::DISPLAY_INFO).len(), 4 + 2 * 13);
|
||||
let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
assert_eq!(back.channels.len(), 1);
|
||||
assert_eq!(back.channels[0].name, "Alpha 1");
|
||||
assert!((back.channels[0].opacity - 0.5).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -702,7 +702,20 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
// Capped like `build` (#2940): a group past the document's nesting cap keeps no children,
|
||||
// so no file can make this tree, or anything walking or dropping it, recurse deeper.
|
||||
let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH);
|
||||
doc.layers = cx.build(&tree, 0);
|
||||
// Colours stored in the document's CMYK space (Gradient Map stops…) convert through
|
||||
// its own profile, as the compositor and the PSD writer read them.
|
||||
let space = photocraft_compose::cmyk_space(&doc);
|
||||
doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0));
|
||||
// Photoshop's Background: the bottom record, a transparency-protected pixel layer stored
|
||||
// without a transparency channel (-1). It is also position-locked, whatever its
|
||||
// (localized) name and whether or not the file carries an `lspf` block.
|
||||
if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut())
|
||||
&& rec.flags.transparency_protected()
|
||||
&& !rec.channels.iter().any(|c| c.id == -1)
|
||||
&& matches!(bottom.content, LayerContent::Raster(_))
|
||||
{
|
||||
bottom.locks.position = true;
|
||||
}
|
||||
// Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked).
|
||||
if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) =
|
||||
file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed)
|
||||
@@ -808,6 +821,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
let first = cc + usize::from(file.merged_has_alpha());
|
||||
let plane = h.row_bytes() * hh;
|
||||
let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default();
|
||||
// Photoshop lists every extra channel, the merged transparency first when there is one.
|
||||
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
|
||||
let names = names.get(skip..).unwrap_or_default();
|
||||
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
|
||||
let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break };
|
||||
let mut s = Surface::new(cx.mask_fmt);
|
||||
@@ -818,9 +834,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
}
|
||||
// Channel options (overlay colour, opacity, colour indicates) and spot inks.
|
||||
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels);
|
||||
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels);
|
||||
}
|
||||
// Saved in Quick Mask mode: that channel becomes the document's Quick Mask again.
|
||||
if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO)
|
||||
|
||||
@@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn count(hay: &[u8], needle: &[u8]) -> usize {
|
||||
hay.windows(needle.len()).filter(|w| *w == needle).count()
|
||||
}
|
||||
|
||||
/// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as
|
||||
/// Photoshop does; the code form is a different key to Photoshop.
|
||||
#[test]
|
||||
fn fresh_placed_warp_is_a_string_id() {
|
||||
let spec =
|
||||
PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None };
|
||||
let mut warnings = Vec::new();
|
||||
let sold = sold_bytes(None, &spec, &mut warnings);
|
||||
let plld = plld_bytes(&spec, &mut warnings);
|
||||
// `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class.
|
||||
for (bytes, n) in [(&sold, 2), (&plld, 1)] {
|
||||
assert_eq!(count(bytes, b"\0\0\0\x04warp"), n);
|
||||
assert_eq!(count(bytes, b"\0\0\0\0warp"), 0);
|
||||
}
|
||||
let d = sold_descriptor(&sold).unwrap();
|
||||
assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec())));
|
||||
}
|
||||
|
||||
fn sf(command: &str, params: J) -> SmartFilter {
|
||||
SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true }
|
||||
}
|
||||
|
||||
@@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`.
|
||||
fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec<u8> {
|
||||
let mut v = vec![0, 1, 0, 0];
|
||||
v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
v.extend_from_slice(&2u16.to_be_bytes());
|
||||
for (t, (space, c)) in [(0u32, a), (4096, b)] {
|
||||
v.extend_from_slice(&t.to_be_bytes());
|
||||
v.extend_from_slice(&50u32.to_be_bytes());
|
||||
v.extend_from_slice(&space.to_be_bytes());
|
||||
for x in c {
|
||||
v.extend_from_slice(&x.to_be_bytes());
|
||||
}
|
||||
v.extend_from_slice(&[0, 0]);
|
||||
}
|
||||
v.extend_from_slice(&2u16.to_be_bytes());
|
||||
for t in [0u32, 4096] {
|
||||
v.extend_from_slice(&t.to_be_bytes());
|
||||
v.extend_from_slice(&50u32.to_be_bytes());
|
||||
v.extend_from_slice(&255u16.to_be_bytes());
|
||||
}
|
||||
v.extend_from_slice(&2u16.to_be_bytes()); // expansion count
|
||||
v.extend_from_slice(&0u16.to_be_bytes()); // smoothness
|
||||
v
|
||||
}
|
||||
|
||||
fn saved_grdm(bytes: &[u8]) -> Vec<u8> {
|
||||
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
|
||||
f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() {
|
||||
use photocraft_cms::{Intent, Transform, synth};
|
||||
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
|
||||
let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
|
||||
let profile = Profile::parse(&profile.to_bytes()).unwrap();
|
||||
let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap();
|
||||
// First stop 60 % cyan + 40 % black (stored inverted), second paper white.
|
||||
let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]);
|
||||
let paper = (2u16, [65535; 4]);
|
||||
let lab_dark = (7u16, [2000, 0, 0, 0]);
|
||||
let lab_red = (7u16, [5000, 6000u16, 2000, 0]);
|
||||
let gray_black = (8u16, [10000, 0, 0, 0]);
|
||||
let gray_white = (8u16, [0, 0, 0, 0]);
|
||||
for depth in SampleType::ALL {
|
||||
for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] {
|
||||
let block = grdm_block(stops.0, stops.1);
|
||||
let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth);
|
||||
if mode == ColorMode::Cmyk {
|
||||
doc.icc_profile = Some(profile.to_bytes());
|
||||
}
|
||||
let fmt = doc.pixel_format();
|
||||
doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false));
|
||||
doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() })));
|
||||
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let mut back = import("x.psd", &bytes).unwrap().document;
|
||||
let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else {
|
||||
panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content);
|
||||
};
|
||||
let (first, last) = (got[0].1, got[got.len() - 1].1);
|
||||
match mode {
|
||||
ColorMode::Cmyk => {
|
||||
// Through the document's own profile, not the built-in coated CMYK.
|
||||
let mut expected = [0.0f32; 3];
|
||||
to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected);
|
||||
assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}");
|
||||
let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]);
|
||||
assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used");
|
||||
}
|
||||
ColorMode::Lab => {
|
||||
assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}");
|
||||
assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}");
|
||||
}
|
||||
// An inverting map, white to black.
|
||||
_ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])),
|
||||
}
|
||||
// The layer now renders: the composite differs from the background alone.
|
||||
let with_map = photocraft_compose::flatten(&back).px;
|
||||
let mut hidden = back.clone();
|
||||
hidden.layers[1].visible = false;
|
||||
assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}");
|
||||
// Saving again keeps the block (and so the stops' colour space) byte for byte...
|
||||
let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}");
|
||||
// ...and so does toggling Reverse and Dither, which only flip their flags.
|
||||
if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content {
|
||||
*reverse = true;
|
||||
*dither = true;
|
||||
}
|
||||
let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes;
|
||||
let flipped = saved_grdm(&flipped_file);
|
||||
assert_eq!(&flipped[2..4], &[1, 1]);
|
||||
assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space");
|
||||
let reopened = import("x.psd", &flipped_file).unwrap().document;
|
||||
assert_eq!(reopened.layers[1].content, back.layers[1].content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected,
|
||||
//! stored without a transparency channel), whatever its name in the Photoshop language that
|
||||
//! saved the file.
|
||||
|
||||
use photocraft_io::psd_to_document;
|
||||
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
|
||||
|
||||
/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`.
|
||||
fn file(name: &str, transparency_channel: bool) -> PsdFile {
|
||||
let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
|
||||
let bottom = f.layers_mut().first_mut().unwrap();
|
||||
bottom.name = name.bytes().filter(u8::is_ascii).collect();
|
||||
bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf");
|
||||
bottom.blocks.push(TaggedBlock::unicode_name(name));
|
||||
bottom.flags.set_transparency_protected(true);
|
||||
if !transparency_channel {
|
||||
bottom.channels.retain(|c| c.id != -1);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_background_is_the_background() {
|
||||
for name in ["Hintergrund", "背景", "Arrière-plan"] {
|
||||
let (d, _) = psd_to_document(&file(name, false));
|
||||
let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background"));
|
||||
assert_eq!(bg.name, name);
|
||||
assert!(bg.locks.transparency && bg.locks.position, "{name}");
|
||||
assert_eq!(bg.id, d.layers[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protected_layer_with_transparency_is_not_the_background() {
|
||||
// Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel.
|
||||
let (d, _) = psd_to_document(&file("Background", true));
|
||||
assert!(d.layers[0].locks.transparency);
|
||||
assert!(d.background().is_none());
|
||||
}
|
||||
@@ -857,6 +857,91 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
assert!(r.tail_preview().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() {
|
||||
// Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which
|
||||
// redraws only changed tiles; after every chunk the target must be exactly what finishing
|
||||
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
|
||||
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
|
||||
let mut brushes = vec![base.clone()];
|
||||
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
|
||||
let mut b = base.clone();
|
||||
b.dual_brush.enabled = true;
|
||||
b.dual_brush.size = 6.0;
|
||||
b.dual_brush.scatter = 1.5;
|
||||
brushes.push(b);
|
||||
let mut b = base.clone();
|
||||
b.color_dynamics.enabled = true;
|
||||
b.color_dynamics.hue_jitter = 0.5;
|
||||
brushes.push(b);
|
||||
brushes.push(BrushSettings { wet_edges: true, ..base.clone() });
|
||||
brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() });
|
||||
brushes.push(BrushSettings { erase: true, ..base.clone() });
|
||||
let mut b = base.clone();
|
||||
b.texture.enabled = true;
|
||||
b.texture.each_tip = false;
|
||||
brushes.push(b);
|
||||
let stroke: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(30.0 + i as f64 * 3.7, 40.0 + 25.0 * (i as f64 / 5.0).sin(), 1.0)).collect();
|
||||
// Mirrors about x = 100 and y = 75; the first point sits near the vertical axis so passes overlap.
|
||||
let mirror = |fx: bool, fy: bool| -> Vec<StrokePoint> {
|
||||
stroke.iter().map(|p| StrokePoint::new(if fx { 200.0 - p.x } else { p.x }, if fy { 150.0 - p.y } else { p.y }, p.pressure)).collect()
|
||||
};
|
||||
let paths = [stroke.clone(), mirror(true, false), mirror(false, true), mirror(true, true)];
|
||||
let area = Rect::new(-20, -20, 240, 190);
|
||||
let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false));
|
||||
let sel_rect = Rect::new(0, 0, 160, 120);
|
||||
sel.write_region(sel_rect, &(0..160 * 120).map(|i| ((i % 160) as f32 / 120.0).min(1.0)).collect::<Vec<_>>());
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
|
||||
let mut pre = Surface::new(fmt);
|
||||
let fill = Rect::new(10, 10, 190, 140);
|
||||
let n = fmt.channels();
|
||||
let px: Vec<f32> =
|
||||
(0..fill.width() * fill.height()).flat_map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.8].into_iter().take(n)).collect();
|
||||
pre.write_region(fill, &px);
|
||||
for (bi, b) in brushes.iter().enumerate() {
|
||||
// (passes included at each chunk, selection): two passes from the start; four, of
|
||||
// which two join part-way.
|
||||
for (four, selection) in [(false, None), (true, None), (true, Some(&sel))] {
|
||||
let mut renderers: Vec<StrokeRenderer> = (0..4).map(|_| StrokeRenderer::new(b, Some(fmt), 1.0)).collect();
|
||||
let mut live = crate::render::UnionLive::default();
|
||||
let mut target = pre.clone();
|
||||
let (mut at, mut chunk, mut merged) = (0, 1, 0);
|
||||
while at < stroke.len() {
|
||||
let end = (at + chunk).min(stroke.len());
|
||||
for (r, path) in renderers.iter_mut().zip(&paths) {
|
||||
r.push(&path[at..end]);
|
||||
}
|
||||
let count = if four && at >= 12 { 4 } else { 2 };
|
||||
let full = merged != count;
|
||||
merged = count;
|
||||
let mut passes: Vec<&mut StrokeRenderer> = renderers.iter_mut().take(count).collect();
|
||||
StrokeRenderer::composite_union_live(&mut passes, &mut live, full, &pre, &mut target, selection, false);
|
||||
let finished: Vec<StrokeRenderer> = renderers
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|r| {
|
||||
let mut r = r.clone();
|
||||
r.finish();
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
let mut expected = pre.clone();
|
||||
finished[0].composite_union_many(&finished[1..], &pre, &mut expected, selection, false);
|
||||
assert!(
|
||||
target.read_region(area) == expected.read_region(area),
|
||||
"{sample:?}, brush {bi}, four {four}, selection {}, points ..{end}",
|
||||
selection.is_some()
|
||||
);
|
||||
assert_eq!(live.bounds(), finished.iter().fold(Rect::EMPTY, |r, f| r.union(&f.bounds())));
|
||||
at = end;
|
||||
chunk = chunk % 3 + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
|
||||
|
||||
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use brush::{
|
||||
SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
|
||||
};
|
||||
pub use mixer::MixerSettings;
|
||||
pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke};
|
||||
pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
|
||||
pub use tile::{GrayTile, StoredTile};
|
||||
|
||||
/// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation.
|
||||
|
||||
@@ -491,6 +491,13 @@ impl CoverageMap {
|
||||
v
|
||||
}
|
||||
|
||||
/// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the
|
||||
/// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own.
|
||||
fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self {
|
||||
let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone()));
|
||||
Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Union two passes of the same brush stroke without applying opacity twice where they
|
||||
/// overlap. The stronger coverage wins, including its per-dab colour when present.
|
||||
fn union_max(&mut self, other: &Self) {
|
||||
@@ -527,6 +534,21 @@ impl CoverageMap {
|
||||
// Stroke renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// What [`StrokeRenderer::composite_union_live`] drew last: the coverage tiles of the passes'
|
||||
/// smoothing tails (redrawn without them next time) and the merged bounds.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct UnionLive {
|
||||
tail_keys: HashSet<(i32, i32)>,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
impl UnionLive {
|
||||
/// The merged bounds of the finished passes, as last drawn.
|
||||
pub fn bounds(&self) -> Rect {
|
||||
self.bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StrokeRenderer {
|
||||
@@ -570,6 +592,105 @@ impl StrokeRenderer {
|
||||
}
|
||||
merged.composite(pre, target, selection, lock_transparency, true)
|
||||
}
|
||||
|
||||
/// Coverage tiles (stroke and Dual Brush) changed since the last call or composite, into
|
||||
/// `keys`; forgets them.
|
||||
fn drain_dirty_into(&mut self, keys: &mut HashSet<(i32, i32)>) {
|
||||
keys.extend(self.cov.dirty.drain());
|
||||
if let Some(d) = self.dual.as_mut() {
|
||||
keys.extend(d.dirty.drain());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every coverage tile (stroke and Dual Brush), into `keys`.
|
||||
fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) {
|
||||
keys.extend(self.cov.tiles.keys());
|
||||
if let Some(d) = &self.dual {
|
||||
keys.extend(d.tiles.keys());
|
||||
}
|
||||
}
|
||||
|
||||
/// Live preview of a stroke painted as several passes (the stroke, then its symmetry
|
||||
/// mirrors): leaves `target` as finishing every pass now and compositing them with
|
||||
/// [`composite_union_many`](Self::composite_union_many) over `pre` would, but redraws only the
|
||||
/// coverage tiles that changed since the previous call (new dabs, and the smoothing tail now
|
||||
/// and before). `full` redraws every tile: on the first call, or when a pass joins. Returns
|
||||
/// the damaged rectangle.
|
||||
pub fn composite_union_live(
|
||||
passes: &mut [&mut Self],
|
||||
live: &mut UnionLive,
|
||||
full: bool,
|
||||
pre: &Surface,
|
||||
target: &mut Surface,
|
||||
selection: Option<&Surface>,
|
||||
lock_transparency: bool,
|
||||
) -> Rect {
|
||||
let mut keys = std::mem::take(&mut live.tail_keys);
|
||||
for p in passes.iter_mut() {
|
||||
p.drain_dirty_into(&mut keys);
|
||||
if full {
|
||||
p.keys_into(&mut keys);
|
||||
}
|
||||
}
|
||||
let tails: Vec<_> = passes.iter().map(|p| p.tail_preview()).collect();
|
||||
let mut tail_keys = HashSet::new();
|
||||
for t in tails.iter().flatten() {
|
||||
t.keys_into(&mut tail_keys);
|
||||
}
|
||||
keys.extend(&tail_keys);
|
||||
let mut merged: Option<Self> = None;
|
||||
for (p, t) in passes.iter().zip(&tails) {
|
||||
let cov = p.cov.finished_subset(t.as_ref().map(|t| &t.cov), &keys);
|
||||
let dual = p.dual.as_ref().map(|d| d.finished_subset(t.as_ref().and_then(|t| t.dual.as_ref()), &keys));
|
||||
match merged.as_mut() {
|
||||
None => {
|
||||
merged = Some(Self {
|
||||
ctx: p.ctx.clone(),
|
||||
generator: p.generator.clone(),
|
||||
cov,
|
||||
dual,
|
||||
fmt: p.fmt,
|
||||
per_dab_color: p.per_dab_color,
|
||||
dabs_done: p.dabs_done,
|
||||
scratch: Vec::new(),
|
||||
depth_scratch: Vec::new(),
|
||||
dab_buf: Vec::new(),
|
||||
dual_buf: Vec::new(),
|
||||
all_dabs: None,
|
||||
})
|
||||
}
|
||||
Some(m) => {
|
||||
m.cov.union_max(&cov);
|
||||
if let (Some(to), Some(from)) = (&mut m.dual, &dual) {
|
||||
to.union_max(from);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(mut merged) = merged else { return Rect::EMPTY };
|
||||
let (before, after) = (live.bounds, merged.bounds());
|
||||
// Back to the pre-stroke pixels where the previous call drew and nothing covers now.
|
||||
let mut keys: Vec<_> = keys.into_iter().collect();
|
||||
keys.sort_unstable();
|
||||
let mut dmg = Rect::EMPTY;
|
||||
let mut region = Vec::new();
|
||||
for &(tx, ty) in &keys {
|
||||
let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE);
|
||||
let old = tile.intersect(&before);
|
||||
if !old.is_empty() {
|
||||
pre.read_region_into(old, &mut region);
|
||||
target.write_region(old, ®ion);
|
||||
dmg = dmg.union(&old);
|
||||
}
|
||||
if merged.cov.tiles.contains_key(&(tx, ty)) {
|
||||
dmg = dmg.union(&tile.intersect(&after));
|
||||
}
|
||||
}
|
||||
merged.composite(pre, target, selection, lock_transparency, true);
|
||||
live.tail_keys = tail_keys;
|
||||
live.bounds = after;
|
||||
dmg
|
||||
}
|
||||
/// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing.
|
||||
pub fn new(brush: &BrushSettings, fmt: Option<PixelFormat>, zoom: f32) -> Self {
|
||||
let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some();
|
||||
|
||||
@@ -42,3 +42,59 @@ path = "fuzz_targets/image_source_data.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "grd"
|
||||
path = "fuzz_targets/grd.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "ase"
|
||||
path = "fuzz_targets/ase.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "aco"
|
||||
path = "fuzz_targets/aco.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "patterns"
|
||||
path = "fuzz_targets/patterns.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "filter_effects"
|
||||
path = "fuzz_targets/filter_effects.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "metadata"
|
||||
path = "fuzz_targets/metadata.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "tagged"
|
||||
path = "fuzz_targets/tagged.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "resources"
|
||||
path = "fuzz_targets/resources.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Fuzz the `.aco` Photoshop Color Swatches parser: any input must return `Ok` or `Err`, never
|
||||
//! panic; anything that parses must round-trip through `write` and re-parse with the same swatch
|
||||
//! count, and no name may exceed the name-unit cap.
|
||||
//!
|
||||
//! `MAX_SWATCHES` is `u16::MAX` and the on-disk count is a `u16`, so the count is unfalsifiable by
|
||||
//! construction; the assertion is kept as documentation of that invariant, not as a check that can
|
||||
//! fail. The name cap, which is reachable, is the one that bites.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::aco;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(f) = aco::parse(data) else { return };
|
||||
assert!(f.swatches.len() <= aco::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatches.len());
|
||||
for s in &f.swatches {
|
||||
assert!(s.name.encode_utf16().count() <= aco::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
|
||||
}
|
||||
let out = aco::write(&f.swatches).expect("a parsed .aco must be writable");
|
||||
let back = aco::parse(&out).expect("a written .aco must re-parse");
|
||||
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
|
||||
assert_eq!(aco::write(&back.swatches).expect("rewritable"), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Fuzz the `.ase` Adobe Swatch Exchange parser: any input must return `Ok` or `Err`, never panic;
|
||||
//! anything that parses must hold only finite colour components, stay within the swatch cap and
|
||||
//! round-trip through `write` and re-parse with the same number of entries.
|
||||
//!
|
||||
//! A colour outside any group is a legitimate `AseEntry::Swatch`, and a group left open at the
|
||||
//! end is closed by the parser, so entry shape is checked only through the round trip below.
|
||||
//! The cap assertion is an invariant, not a fast check: no 4 KB input can carry 100 001 blocks.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::ase::{self, AseColor, AseEntry};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(f) = ase::parse(data) else { return };
|
||||
assert!(f.swatch_count() <= ase::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatch_count());
|
||||
for e in &f.entries {
|
||||
let swatches = match e {
|
||||
AseEntry::Swatch(s) => std::slice::from_ref(s),
|
||||
AseEntry::Group { swatches, .. } => swatches.as_slice(),
|
||||
};
|
||||
for s in swatches {
|
||||
assert!(s.name.encode_utf16().count() <= ase::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
|
||||
// Rgb/Lab carry three components, Cmyk four, Gray one.
|
||||
let check = |c: &[f32]| {
|
||||
for v in c {
|
||||
assert!(v.is_finite(), "colour component {v} is not finite");
|
||||
}
|
||||
};
|
||||
match &s.color {
|
||||
AseColor::Rgb(v) | AseColor::Lab(v) => check(v),
|
||||
AseColor::Cmyk(v) => check(v),
|
||||
AseColor::Gray(v) => check(std::slice::from_ref(v)),
|
||||
AseColor::Other { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = ase::write(&f).expect("a parsed .ase must be writable");
|
||||
let back = ase::parse(&out).expect("a written .ase must re-parse");
|
||||
assert_eq!(back.swatch_count(), f.swatch_count(), "swatch count changed across a round trip");
|
||||
assert_eq!(ase::write(&back).expect("rewritable"), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Fuzz the `FEid` / `FXid` smart-filter cache parser: any input must return `Ok` or `Err`, never
|
||||
//! panic. Decoding every cached plane and the filter mask must also stay panic-free (a size the
|
||||
//! data cannot hold is refused before allocation, for RLE and zlib alike), and a parsed block must
|
||||
//! re-parse to the same value and re-serialise byte for byte.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::filter_effects::FilterEffects;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(fx) = FilterEffects::parse(data) else { return };
|
||||
for it in &fx.items {
|
||||
let _ = it.decoded_mask();
|
||||
if let (Ok(depth), Ok((w, h))) = (u16::try_from(it.depth), it.rect.size()) {
|
||||
for p in it.slots.iter().flatten() {
|
||||
let _ = p.decode(w, h, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = fx.to_bytes();
|
||||
let back = FilterEffects::parse(&out).expect("a written FEid block must re-parse");
|
||||
assert_eq!(back, fx, "a written FEid block does not re-parse to the same value");
|
||||
assert_eq!(back.to_bytes(), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Fuzz the `.grd` gradient parser: any input must return `Ok` or `Err`, never panic, and every
|
||||
//! number it hands back must be finite and in range.
|
||||
//!
|
||||
//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len`
|
||||
//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with
|
||||
//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::grd::{self, GrdColor};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(g) = grd::parse(data) else { return };
|
||||
assert!(g.len() <= grd::MAX_GRADIENTS, "{} gradients exceeds MAX_GRADIENTS", g.len());
|
||||
for grad in &g {
|
||||
assert!(grad.stops.len() <= grd::MAX_STOPS, "{} stops exceeds MAX_STOPS", grad.stops.len());
|
||||
assert!(grad.opacity.len() <= grd::MAX_STOPS, "{} transparency stops exceeds MAX_STOPS", grad.opacity.len());
|
||||
for &(l, o, m) in &grad.opacity {
|
||||
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
|
||||
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
|
||||
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
|
||||
}
|
||||
for s in &grad.stops {
|
||||
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
|
||||
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
|
||||
// Each model has its own documented range, not just "finite": the parser clamps RGB,
|
||||
// CMYK and Gray to 0..=1, Hue to 0..=360, L* to 0..=100 and a*/b* to the CIELAB gamut.
|
||||
let unit = |c: &[f32]| {
|
||||
for v in c {
|
||||
assert!(v.is_finite() && (0.0..=1.0).contains(v), "colour component {v} outside 0..=1");
|
||||
}
|
||||
};
|
||||
match s.color {
|
||||
GrdColor::Rgb(v) => unit(&v),
|
||||
GrdColor::Cmyk(v) => unit(&v),
|
||||
GrdColor::Hsb([h, sat, br]) => {
|
||||
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} outside 0..=360");
|
||||
unit(&[sat, br]);
|
||||
}
|
||||
GrdColor::Lab([l, a, b]) => {
|
||||
assert!(l.is_finite() && (0.0..=100.0).contains(&l), "L* {l} outside 0..=100");
|
||||
assert!(a.is_finite() && (-128.0..=127.0).contains(&a), "a* {a} outside the CIELAB gamut");
|
||||
assert!(b.is_finite() && (-128.0..=127.0).contains(&b), "b* {b} outside the CIELAB gamut");
|
||||
}
|
||||
GrdColor::Gray(v) => unit(&[v]),
|
||||
GrdColor::Foreground | GrdColor::Background => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = grd::write(&g);
|
||||
let back = grd::parse(&out).expect("a written .grd must re-parse");
|
||||
assert_eq!(back.len(), g.len(), "gradient count changed across a round trip");
|
||||
assert_eq!(grd::write(&back), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Fuzz the `shmd` (metadata setting) parser: any input must return `Ok` or `Err`, never panic.
|
||||
//!
|
||||
//! The `copy` field is a bool, so a non-canonical copy byte (anything but 0 or 1) normalises to 1
|
||||
//! on write; exact byte equality therefore cannot hold for arbitrary input, only for canonical
|
||||
//! files. The invariant that does hold, and that catches reader/writer drift, is that a
|
||||
//! parsed-then-written block re-parses to the *same items* (semantic equality, not just byte
|
||||
//! stability -- byte stability alone would also pass if the writer silently dropped a field).
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::metadata::{parse_shmd, write_shmd};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(items) = parse_shmd(data) else { return };
|
||||
let out = write_shmd(&items);
|
||||
let back = parse_shmd(&out).expect("a written shmd block must re-parse");
|
||||
assert_eq!(back, items, "a written shmd block does not re-parse to the same items");
|
||||
assert_eq!(write_shmd(&back), out, "shmd is not byte-stable across a round trip");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Fuzz the pattern parsers: the `Patt` / `Pat2` / `Pat3` tagged blocks and standalone `.pat`
|
||||
//! files. Any input must return `Ok` or `Err`, never panic. The channel offsets and padding a
|
||||
//! hostile file can carry are wire artifacts, not fields of `PsdPattern`, so a parsed pattern must
|
||||
//! survive a write/parse round trip unchanged -- a stronger invariant than byte stability, which
|
||||
//! would also hold if the writer silently normalised a field away.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(ps) = parse_pattern_block(data) {
|
||||
let out = write_pattern_block(&ps).expect("a parsed pattern block must be writable");
|
||||
let back = parse_pattern_block(&out).expect("a written pattern block must re-parse");
|
||||
assert_eq!(back, ps, "a pattern block does not survive a write/parse round trip");
|
||||
}
|
||||
if let Ok(ps) = parse_pat_file(data) {
|
||||
let out = write_pat_file(&ps).expect("a parsed .pat must be writable");
|
||||
let back = parse_pat_file(&out).expect("a written .pat must re-parse");
|
||||
assert_eq!(back, ps, "a .pat file does not survive a write/parse round trip");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Fuzz the typed image-resource views: `ImageResource::parsed` for an arbitrary resource id, plus
|
||||
//! the standalone `ResolutionInfo` and `VersionInfo` parsers. All must return `Ok` / `Err` / `None`
|
||||
//! (and `xmp_str` an `Option`), never panic. A parsed `ResolutionInfo` (16 fixed bytes) must be
|
||||
//! stable across a byte round trip. The first two bytes pick the id so every typed branch is
|
||||
//! reachable; the rest is the resource data.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::resources::{ImageResource, ResolutionInfo, VersionInfo};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (id, body) = match data.split_first_chunk::<2>() {
|
||||
Some((p, rest)) => (u16::from_be_bytes(*p), rest.to_vec()),
|
||||
None => (0u16, data.to_vec()),
|
||||
};
|
||||
let r = ImageResource::new(id, body.clone());
|
||||
let _ = r.parsed();
|
||||
let _ = r.xmp_str();
|
||||
if let Ok(ri) = ResolutionInfo::from_bytes(&body) {
|
||||
let round = ResolutionInfo::from_bytes(&ri.to_bytes()).expect("resolution info re-parses");
|
||||
assert_eq!(round, ri, "ResolutionInfo is not stable across a round trip");
|
||||
}
|
||||
if let Ok(vi) = VersionInfo::from_bytes(&body) {
|
||||
let _ = vi.to_bytes();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Fuzz the per-block views of a tagged block: `parsed` (typed, well-known keys) and
|
||||
//! `check_structure` (strict re-parse of blocks that carry their own inner sizes: placed layers,
|
||||
//! linked files, object effects, filter caches). Both must return `Ok` / `Err` / `None`, never
|
||||
//! panic, for arbitrary data.
|
||||
//!
|
||||
//! The first byte selects the key from a table of every key with a typed or strict branch, so the
|
||||
//! fuzzer reaches each branch deterministically instead of having to guess a 4-byte key from raw
|
||||
//! bytes (which it would almost never do); the rest is the block data.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::tagged::TaggedBlock;
|
||||
|
||||
const KEYS: [&[u8; 4]; 20] = [
|
||||
// typed (parsed)
|
||||
b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr", b"iOpa", b"shmd",
|
||||
// strict (check_structure)
|
||||
b"PlLd", b"SoLd", b"lnk2", b"lfx2", b"FEid",
|
||||
];
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (key, body) = match data.split_first() {
|
||||
Some((&sel, rest)) => (*KEYS[sel as usize % KEYS.len()], rest.to_vec()),
|
||||
None => (*b"luni", Vec::new()),
|
||||
};
|
||||
let b = TaggedBlock::new(key, body);
|
||||
let _ = b.parsed();
|
||||
let _ = b.check_structure();
|
||||
});
|
||||
@@ -181,7 +181,7 @@ pub fn write(swatches: &[AcoSwatch]) -> Result<Vec<u8>> {
|
||||
out.put_u16(x);
|
||||
}
|
||||
if version == 2 {
|
||||
let units: Vec<u16> = s.name.encode_utf16().take(MAX_NAME_UNITS).collect();
|
||||
let units = crate::io::utf16_units(&s.name, MAX_NAME_UNITS);
|
||||
// The length counts the terminating null.
|
||||
out.put_u32(units.len() as u32 + 1);
|
||||
for u in units {
|
||||
|
||||
@@ -24,6 +24,9 @@ pub const SIGNATURE: &[u8; 4] = b"ASEF";
|
||||
pub const MAX_SWATCHES: usize = 100_000;
|
||||
/// Longest name read or written, in UTF-16 code units.
|
||||
pub const MAX_NAME_UNITS: usize = 1024;
|
||||
// `put_name` writes the name length as `units.len() as u16 + 1`, so the cap must leave room in a
|
||||
// u16; this guards that invariant if the constant is ever raised.
|
||||
const _: () = assert!(MAX_NAME_UNITS < u16::MAX as usize);
|
||||
|
||||
const GROUP_START: u16 = 0xC001;
|
||||
const GROUP_END: u16 = 0xC002;
|
||||
@@ -226,7 +229,7 @@ pub fn parse(data: &[u8]) -> Result<AseFile> {
|
||||
}
|
||||
|
||||
fn put_name(out: &mut Vec<u8>, name: &str) {
|
||||
let units: Vec<u16> = name.encode_utf16().take(MAX_NAME_UNITS).collect();
|
||||
let units = crate::io::utf16_units(name, MAX_NAME_UNITS);
|
||||
out.put_u16(units.len() as u16 + 1);
|
||||
for u in units {
|
||||
out.put_u16(u);
|
||||
|
||||
@@ -44,6 +44,10 @@ impl UnicodeString {
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes
|
||||
/// (`00000004 'warp'`, not `00000000 'warp'`).
|
||||
pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"];
|
||||
|
||||
/// A class / key / type identifier: a length-prefixed string, where a stored
|
||||
/// length of 0 means a 4-character code follows.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -57,10 +61,15 @@ pub enum Id {
|
||||
|
||||
impl Id {
|
||||
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
|
||||
/// [`Id::Str`].
|
||||
/// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings
|
||||
/// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the
|
||||
/// string and the code as different keys and doesn't recognise the code form.
|
||||
pub fn new(s: &str) -> Self {
|
||||
let b = s.as_bytes();
|
||||
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
|
||||
match b {
|
||||
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
|
||||
_ => Id::Str(b.to_vec()),
|
||||
}
|
||||
}
|
||||
/// The identifier's bytes.
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
@@ -609,6 +618,34 @@ mod tests {
|
||||
assert_eq!(p.to_bytes(), b);
|
||||
}
|
||||
|
||||
/// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both
|
||||
/// as keys and as class ids; other four-character names stay codes. Reading accepts both
|
||||
/// forms, and a code read from a file is written back as a code.
|
||||
#[test]
|
||||
fn photoshop_string_ids_are_written_as_strings() {
|
||||
let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat();
|
||||
let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat();
|
||||
for name in STRING_IDS {
|
||||
assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec()));
|
||||
let d = Descriptor::new(name).with(name, Value::Integer(1));
|
||||
let b = d.to_bytes();
|
||||
// Name (empty unicode string), then the class id; then the item count and the key.
|
||||
assert_eq!(&b[4..12], &str_id(name)[..], "class {name}");
|
||||
assert_eq!(&b[16..24], &str_id(name)[..], "key {name}");
|
||||
let back = Descriptor::from_bytes(&b).unwrap();
|
||||
assert_eq!(back, d);
|
||||
assert!(back.class_id.is(name) && back.get(name).is_some());
|
||||
// A file that wrote the code form keeps it, and lookups still find the key.
|
||||
let mut coded = b.clone();
|
||||
coded.splice(16..24, code_id(name));
|
||||
let back = Descriptor::from_bytes(&coded).unwrap();
|
||||
assert_eq!(back.get(name), Some(&Value::Integer(1)));
|
||||
assert_eq!(back.to_bytes(), coded);
|
||||
}
|
||||
assert_eq!(Id::new("view"), Id::Code(*b"view"));
|
||||
assert_eq!(Id::new("Txt "), Id::Code(*b"Txt "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_roundtrip() {
|
||||
let v = VersionedDescriptor::new(sample());
|
||||
|
||||
+45
-9
@@ -58,6 +58,9 @@ pub struct GrdGradient {
|
||||
pub opacity: Vec<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result
|
||||
/// through `finite`, so filtering here as well would give two guards with two different answers to
|
||||
/// the same input, and mutation testing showed the second one was unreachable.
|
||||
fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
match d.get(k)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
@@ -66,11 +69,36 @@ fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
/// The single guard every number passes through on its way to an `f32`. Only a NaN is truly
|
||||
/// without a value, so only a NaN returns `None`; the caller then falls back to the default its
|
||||
/// own field documents (opacity opaque, a midpoint centred, a channel zero).
|
||||
///
|
||||
/// Everything else — including `±inf` and a value finite as `f64` but larger than `f32::MAX`
|
||||
/// (`1e300`, which would otherwise overflow the cast to an infinity) — is *saturated* to the
|
||||
/// representable range before the cast. That keeps the mapping monotonic: a bigger input never
|
||||
/// yields a smaller output, so "too large" lands at the top of the range rather than snapping to a
|
||||
/// field default and inverting the result.
|
||||
fn finite(v: f64) -> Option<f32> {
|
||||
if v.is_nan() {
|
||||
return None;
|
||||
}
|
||||
Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32)
|
||||
}
|
||||
fn f(d: &Descriptor, k: &str) -> f32 {
|
||||
num(d, k).unwrap_or(0.0) as f32
|
||||
finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0)
|
||||
}
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
/// A hue in degrees. An in-range value (0..=360, including exactly 360) is kept verbatim; only an
|
||||
/// out-of-range value wraps with `rem_euclid`, so 370 -> 10 and -10 -> 350. Wrapping every value
|
||||
/// would alter a legal 360 to 0 (and f32 round-off on a tiny negative can itself land on 360),
|
||||
/// changing the colour at the boundary. The input is already finite, so the result stays finite.
|
||||
fn hue(h: f32) -> f32 {
|
||||
if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) }
|
||||
}
|
||||
|
||||
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
|
||||
match d.get(k)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
@@ -90,11 +118,19 @@ fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool {
|
||||
fn color(c: &Descriptor) -> Option<GrdColor> {
|
||||
let id = c.class_id.as_bytes();
|
||||
Some(match id {
|
||||
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0]),
|
||||
b"HSBC" => GrdColor::Hsb([f(c, "H "), f(c, "Strt") / 100.0, f(c, "Brgh") / 100.0]),
|
||||
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0]),
|
||||
b"LbCl" => GrdColor::Lab([f(c, "Lmnc"), f(c, "A "), f(c, "B ")]),
|
||||
b"Grsc" => GrdColor::Gray(f(c, "Gry ") / 100.0),
|
||||
// Every component documented as 0..=1 is clamped here, like every other range in this
|
||||
// module: a hostile file can store any integer at all, and dividing by 100 or 255 does not
|
||||
// bring it back into range on its own.
|
||||
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0].map(clamp01)),
|
||||
// Hue is a circular degree value in 0..=360; see `hue`. An in-range value (including
|
||||
// exactly 360) is kept, only an out-of-range one wraps, so a hostile hue cannot become a
|
||||
// different, wrong colour at the boundary.
|
||||
b"HSBC" => GrdColor::Hsb([hue(f(c, "H ")), (f(c, "Strt") / 100.0).clamp(0.0, 1.0), (f(c, "Brgh") / 100.0).clamp(0.0, 1.0)]),
|
||||
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0].map(clamp01)),
|
||||
// L* is 0..=100; a*/b* are signed and bounded to the CIELAB gamut (-128..=127) so a hostile
|
||||
// value cannot hand a downstream colour converter an out-of-gamut number to overflow on.
|
||||
b"LbCl" => GrdColor::Lab([f(c, "Lmnc").clamp(0.0, 100.0), f(c, "A ").clamp(-128.0, 127.0), f(c, "B ").clamp(-128.0, 127.0)]),
|
||||
b"Grsc" => GrdColor::Gray(clamp01(f(c, "Gry ") / 100.0)),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -103,7 +139,7 @@ fn location(d: &Descriptor) -> f32 {
|
||||
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn midpoint(d: &Descriptor) -> f32 {
|
||||
(num(d, "Mdpn").unwrap_or(50.0) as f32 / 100.0).clamp(0.0, 1.0)
|
||||
finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
@@ -127,7 +163,7 @@ fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
}
|
||||
for v in list(g, "Trns").iter().take(MAX_STOPS) {
|
||||
let Value::Descriptor(s) = v else { continue };
|
||||
out.opacity.push((location(s), (num(s, "Opct").unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
use crate::error::{PsdError, Result};
|
||||
|
||||
/// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**.
|
||||
/// `encode_utf16().take(max)` would cut between the high and low half of a pair above U+FFFF and
|
||||
/// write ill-formed UTF-16 to disk, so whole scalar values are added until one would not fit.
|
||||
/// Both the `.aco` and `.ase` writers share this; keeping it in one place is deliberate, so the
|
||||
/// surrogate fix cannot drift between the two formats.
|
||||
pub(crate) fn utf16_units(name: &str, max: usize) -> Vec<u16> {
|
||||
let mut units = Vec::new();
|
||||
for c in name.chars() {
|
||||
if units.len() + c.len_utf16() > max {
|
||||
break;
|
||||
}
|
||||
let mut buf = [0u16; 2];
|
||||
units.extend_from_slice(c.encode_utf16(&mut buf));
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
/// A cursor over a byte slice. Every read is bounds checked. Integers are big-endian (the PSD
|
||||
/// byte order) unless the reader was made with [`Reader::new_little`], which only the TIFF
|
||||
/// byte-order transcoder uses ([`crate::tiff`]).
|
||||
|
||||
@@ -148,3 +148,402 @@ proptest! {
|
||||
prop_assert_eq!(p.to_bytes().unwrap(), b);
|
||||
}
|
||||
}
|
||||
|
||||
// The three standalone swatch/gradient parsers sit outside `PsdFile::from_bytes`, so their
|
||||
// round-trip invariants and no-panic guarantee get their own properties here. `write` covers only
|
||||
// what each format can express, so each property states the invariant that actually holds instead
|
||||
// of claiming byte equality everywhere.
|
||||
|
||||
/// A position or midpoint the writer can actually store: the file keeps 1/4096ths, so generating
|
||||
/// arbitrary `0.0..=1.0` values would make every property fail on quantisation, not on a bug.
|
||||
fn unit() -> impl Strategy<Value = f32> {
|
||||
(0.0f32..=1.0).prop_map(|v| (v * 4096.0).round() / 4096.0)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
|
||||
#[test]
|
||||
fn grd_roundtrip_keeps_stops_in_range(
|
||||
stops in proptest::collection::vec((unit(), unit(), any::<u8>()), 0..12),
|
||||
opacity in proptest::collection::vec((unit(), unit(), unit()), 0..12),
|
||||
) {
|
||||
use photocraft_psd::grd::{self, GrdColor, GrdGradient, GrdStop};
|
||||
let g = GrdGradient {
|
||||
name: "p".into(),
|
||||
noise: false,
|
||||
stops: stops.iter().map(|&(l, m, tone)| GrdStop {
|
||||
location: l,
|
||||
midpoint: m,
|
||||
color: GrdColor::Rgb([tone as f32 / 255.0, 0.0, 0.0]),
|
||||
}).collect(),
|
||||
opacity: opacity.clone(),
|
||||
};
|
||||
let bytes = grd::write(&[g]);
|
||||
let parsed = grd::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(parsed.len(), 1);
|
||||
let back = &parsed[0];
|
||||
prop_assert_eq!(back.stops.len(), stops.len());
|
||||
prop_assert_eq!(back.opacity.len(), opacity.len());
|
||||
for (a, b) in back.stops.iter().zip(&stops) {
|
||||
prop_assert!(a.location.is_finite() && (0.0..=1.0).contains(&a.location));
|
||||
prop_assert!((a.location - b.0).abs() < 1.0 / 4096.0);
|
||||
prop_assert!(a.midpoint.is_finite() && (0.0..=1.0).contains(&a.midpoint));
|
||||
prop_assert!((a.midpoint - b.1).abs() < 0.01);
|
||||
// The colour was written, so it must come back: a parser that zeroed every colour
|
||||
// would pass a property that only looked at positions.
|
||||
let GrdColor::Rgb([r, g, bl]) = a.color else {
|
||||
prop_assert!(false, "colour must survive the round trip");
|
||||
return Ok(());
|
||||
};
|
||||
prop_assert!((r - b.2 as f32 / 255.0).abs() < 1.0 / 255.0, "red channel {r} lost");
|
||||
prop_assert!(g.is_finite() && g.abs() < 1.0 / 255.0, "green channel {g} corrupted");
|
||||
prop_assert!(bl.is_finite() && bl.abs() < 1.0 / 255.0, "blue channel {bl} corrupted");
|
||||
}
|
||||
for (a, b) in back.opacity.iter().zip(&opacity) {
|
||||
prop_assert!(a.0.is_finite() && (0.0..=1.0).contains(&a.0));
|
||||
prop_assert!((0.0..=1.0).contains(&a.1));
|
||||
prop_assert!(a.2.is_finite() && (0.0..=1.0).contains(&a.2));
|
||||
prop_assert!((a.0 - b.0).abs() < 1.0 / 4096.0);
|
||||
prop_assert!((a.1 - b.1).abs() < 0.01);
|
||||
prop_assert!((a.2 - b.2).abs() < 0.01, "transparency midpoint not preserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_roundtrip_is_byte_stable_after_one_write(
|
||||
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>()), 0..10),
|
||||
) {
|
||||
use photocraft_psd::ase::{self, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
|
||||
let mk = |(name, tone): &(String, u8)| AseSwatch {
|
||||
name: name.clone(),
|
||||
color: AseColor::Rgb([*tone as f32 / 255.0, 0.0, 0.0]),
|
||||
kind: AseKind::Process,
|
||||
};
|
||||
let file = AseFile {
|
||||
entries: vec![
|
||||
AseEntry::Group { name: "g".into(), swatches: swatches.iter().take(3).map(mk).collect() },
|
||||
AseEntry::Group { name: "h".into(), swatches: swatches.iter().skip(3).map(mk).collect() },
|
||||
],
|
||||
};
|
||||
let bytes = ase::write(&file).unwrap();
|
||||
let back = ase::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(back.swatch_count(), swatches.len());
|
||||
prop_assert_eq!(ase::write(&back).unwrap(), bytes, "a second round trip is byte-stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aco_roundtrip_is_byte_stable(
|
||||
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>(), any::<u16>()), 0..10),
|
||||
) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
let s: Vec<AcoSwatch> = swatches.iter().map(|(name, space, v)| AcoSwatch {
|
||||
name: name.clone(),
|
||||
color: match space % 3 {
|
||||
0 => AcoColor::Rgb([*v, 0, 0]),
|
||||
1 => AcoColor::Gray(*v),
|
||||
_ => AcoColor::Lab { l: *v, a: -12800, b: 12700 },
|
||||
},
|
||||
}).collect();
|
||||
let bytes = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(&back.swatches, &s);
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), bytes, "a second round trip is byte-stable");
|
||||
}
|
||||
|
||||
/// A name longer than the cap is truncated on the way in, so the file stays writable and the
|
||||
/// bytes settle after one round trip.
|
||||
#[test]
|
||||
fn aco_long_names_settle_after_one_truncation(len in 1024usize..4000) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
let s = vec![AcoSwatch { name: "x".repeat(len), color: AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&once).unwrap();
|
||||
prop_assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
|
||||
}
|
||||
|
||||
/// Pure junk rarely gets past the signature check, so most of each parser is never reached.
|
||||
/// These damage files that are already valid instead, which is where the interesting inputs
|
||||
/// live: well-formed structure with one byte corrupted.
|
||||
#[test]
|
||||
fn swatch_parsers_never_panic_on_damaged_valid_files(
|
||||
idx in 0usize..4000,
|
||||
bit in 0u32..8,
|
||||
mask in 0u8..=255,
|
||||
) {
|
||||
use photocraft_psd::{aco, ase, grd};
|
||||
let grd_bytes = {
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
|
||||
write(&[GrdGradient {
|
||||
name: "d".into(),
|
||||
noise: false,
|
||||
stops: vec![GrdStop { location: 0.25, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.5, 0.0]) }],
|
||||
opacity: vec![(0.5, 1.0, 0.5)],
|
||||
}])
|
||||
};
|
||||
let ase_bytes = {
|
||||
use photocraft_psd::ase::{write, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
|
||||
let sw = || AseSwatch { name: "d".into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
write(&AseFile {
|
||||
entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![sw()] }, AseEntry::Swatch(sw())],
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let aco_bytes = aco::write(&[aco::AcoSwatch { name: "d".into(), color: aco::AcoColor::Rgb([1, 2, 3]) }]).unwrap();
|
||||
|
||||
let damage = |mut b: Vec<u8>| -> Vec<u8> {
|
||||
if !b.is_empty() {
|
||||
let i = idx % b.len();
|
||||
b[i] ^= 1 << bit;
|
||||
b[i] = b[i].wrapping_add(mask);
|
||||
}
|
||||
b
|
||||
};
|
||||
let _ = grd::parse(&damage(grd_bytes));
|
||||
let _ = ase::parse(&damage(ase_bytes));
|
||||
let _ = aco::parse(&damage(aco_bytes));
|
||||
}
|
||||
|
||||
/// Names that are not ASCII, so the name-unit cap can land between the two halves of a
|
||||
/// surrogate pair. The cap counts UTF-16 code units, so that split is the boundary case.
|
||||
#[test]
|
||||
fn aco_non_ascii_names_are_capped_and_still_round_trip(len in 600usize..2600) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
// One leading ASCII character, then two-unit pairs. Without it the cap would always
|
||||
// fall on a pair boundary and the half-written case this property exists for would
|
||||
// never happen.
|
||||
let name = format!("a{}", "\u{1F600}".repeat(len / 2 + 1));
|
||||
let s = vec![AcoSwatch { name: name.clone(), color: AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&once).unwrap();
|
||||
let got = back.swatches[0].name.encode_utf16().count();
|
||||
prop_assert!(got <= aco::MAX_NAME_UNITS, "name came back as {got} units");
|
||||
// The cap cuts on a character, so the result is a prefix of the input: whole characters
|
||||
// only, and never a lone surrogate.
|
||||
prop_assert!(name.starts_with(&back.swatches[0].name), "the capped name must be a prefix of the input");
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
|
||||
}
|
||||
|
||||
/// Arbitrary bytes must never panic in any of the three parsers, whatever the length.
|
||||
#[test]
|
||||
fn swatch_parsers_never_panic_on_arbitrary_bytes(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::grd::parse(&data);
|
||||
let _ = photocraft_psd::ase::parse(&data);
|
||||
let _ = photocraft_psd::aco::parse(&data);
|
||||
}
|
||||
}
|
||||
|
||||
// The five auxiliary binary parsers (image resources, metadata setting, patterns, filter-effects
|
||||
// caches and the per-block tagged-block views) parse structures that sit outside the whole-file
|
||||
// `PsdFile::from_bytes` path when read on their own. Their round-trip invariants and no-panic
|
||||
// guarantee get their own properties here, so normal CI covers them: the matching fuzz targets run
|
||||
// only on a manual trigger.
|
||||
|
||||
/// A small, valid pattern at a byte-aligned depth, generated the way the unit `pat()` helper builds
|
||||
/// one: `mode_channels(mode)` colour planes of `width × height × bytes-per-sample`, optional alpha.
|
||||
fn pattern_strategy() -> impl Strategy<Value = patterns::PsdPattern> {
|
||||
(prop_oneof![Just(1u32), Just(3u32), Just(4u32)], 1u32..8, 1u32..6, prop_oneof![Just(8u16), Just(16), Just(32)], any::<bool>(), any::<u64>()).prop_map(
|
||||
|(mode, width, height, depth, alpha, seed)| {
|
||||
let bpp = usize::from(depth / 8);
|
||||
let plane = |s: u64| (0..width as usize * height as usize * bpp).map(|i| (i as u64).wrapping_mul(31).wrapping_add(s) as u8).collect::<Vec<u8>>();
|
||||
let nc = patterns::mode_channels(mode);
|
||||
patterns::PsdPattern {
|
||||
mode,
|
||||
width,
|
||||
height,
|
||||
name: "p".into(),
|
||||
id: "0bd2d3ba-1234-11d4-8f8f-aabbccddeeff".into(),
|
||||
palette: None,
|
||||
depth,
|
||||
channels: (0..nc).map(|c| plane(seed.wrapping_add(c as u64 + 1))).collect(),
|
||||
alpha: alpha.then(|| plane(seed ^ 0xffff)),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
|
||||
/// `shmd` is exact: Photoshop keeps each item's even padding inside its stored length, so a
|
||||
/// built block writes, parses and re-writes byte for byte.
|
||||
#[test]
|
||||
fn metadata_shmd_round_trip_is_exact(
|
||||
items in proptest::collection::vec((any::<[u8; 4]>(), proptest::collection::vec(any::<u8>(), 0..40)), 0..8),
|
||||
) {
|
||||
use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd};
|
||||
let items: Vec<MetadataItem> = items.into_iter().map(|(k, d)| MetadataItem::new(k, d)).collect();
|
||||
let bytes = write_shmd(&items);
|
||||
let back = parse_shmd(&bytes).unwrap();
|
||||
prop_assert_eq!(&back, &items);
|
||||
prop_assert_eq!(write_shmd(&back), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_shmd_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::metadata::parse_shmd(&data);
|
||||
}
|
||||
|
||||
/// A built pattern round-trips through both the `Patt`/`Pat2`/`Pat3` block and the `.pat` file,
|
||||
/// and re-serialising a parsed block is byte-stable.
|
||||
#[test]
|
||||
fn pattern_blocks_round_trip(ps in proptest::collection::vec(pattern_strategy(), 0..4)) {
|
||||
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
|
||||
let blk = write_pattern_block(&ps).unwrap();
|
||||
prop_assert_eq!(parse_pattern_block(&blk).unwrap(), ps.clone());
|
||||
prop_assert_eq!(write_pattern_block(&parse_pattern_block(&blk).unwrap()).unwrap(), blk);
|
||||
let f = write_pat_file(&ps).unwrap();
|
||||
prop_assert_eq!(parse_pat_file(&f).unwrap(), ps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_parsers_never_panic(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::patterns::parse_pattern_block(&data);
|
||||
let _ = photocraft_psd::patterns::parse_pat_file(&data);
|
||||
}
|
||||
|
||||
/// A filter-effects cache with one or more items (each with cached slots and an optional mask)
|
||||
/// re-serialises and re-parses byte for byte, and the cached planes decode.
|
||||
#[test]
|
||||
fn filter_effects_round_trip(items in proptest::collection::vec(("[ -~]{0,8}", any::<bool>()), 0..4)) {
|
||||
use photocraft_psd::compression::Compression;
|
||||
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
|
||||
use photocraft_psd::layer::Rect;
|
||||
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
|
||||
let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap();
|
||||
let items: Vec<FilterEffectsItem> = items
|
||||
.iter()
|
||||
.map(|(id, mask)| {
|
||||
let mut slots = vec![None; 26];
|
||||
slots[0] = Some(plane(10));
|
||||
slots[25] = Some(plane(255));
|
||||
FilterEffectsItem { id: id.clone(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) }
|
||||
})
|
||||
.collect();
|
||||
let fx = FilterEffects { version: 3, items };
|
||||
let bytes = fx.to_bytes();
|
||||
let back = FilterEffects::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(&back, &fx);
|
||||
prop_assert_eq!(back.to_bytes(), bytes);
|
||||
for it in &back.items {
|
||||
if let Some((_, m)) = it.decoded_mask() {
|
||||
prop_assert_eq!(m, vec![128; 16]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_effects_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
if let Ok(fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) {
|
||||
for it in &fx.items {
|
||||
let _ = it.decoded_mask();
|
||||
}
|
||||
let _ = fx.to_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolution info is 16 fixed-point bytes; a built value survives a byte round trip.
|
||||
#[test]
|
||||
fn resolution_info_round_trips(dpi in 1.0f64..=10_000.0) {
|
||||
use photocraft_psd::resources::ResolutionInfo;
|
||||
let ri = ResolutionInfo::from_dpi(dpi);
|
||||
prop_assert_eq!(ResolutionInfo::from_bytes(&ri.to_bytes()).unwrap(), ri);
|
||||
}
|
||||
|
||||
/// The typed view of an arbitrary resource id over arbitrary data never panics.
|
||||
#[test]
|
||||
fn resource_parsed_never_panics(id in any::<u16>(), data in proptest::collection::vec(any::<u8>(), 0..64)) {
|
||||
use photocraft_psd::resources::ImageResource;
|
||||
let r = ImageResource::new(id, data);
|
||||
let _ = r.parsed();
|
||||
let _ = r.xmp_str();
|
||||
let _ = photocraft_psd::resources::VersionInfo::from_bytes(&r.data);
|
||||
}
|
||||
|
||||
/// `parsed` (typed) and `check_structure` (strict inner re-parse) over an arbitrary key and
|
||||
/// arbitrary data never panic, only ever returning `Ok` / `Err` / `None`.
|
||||
#[test]
|
||||
fn tagged_block_views_never_panic(key in any::<[u8; 4]>(), data in proptest::collection::vec(any::<u8>(), 0..200)) {
|
||||
let b = TaggedBlock::new(key, data);
|
||||
let _ = b.parsed();
|
||||
let _ = b.check_structure();
|
||||
}
|
||||
|
||||
/// Each well-known typed key over arbitrary data: every typed branch is reached and none panics.
|
||||
#[test]
|
||||
fn tagged_known_keys_never_panic(ki in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..64)) {
|
||||
const KEYS: [&[u8; 4]; 13] =
|
||||
[b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr"];
|
||||
let b = TaggedBlock::new(*KEYS[ki], data);
|
||||
let _ = b.parsed();
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// Pure random bytes rarely get past a header, so this damages a *valid* block one byte at a
|
||||
/// time -- well-formed structure with one byte corrupted, where the interesting inputs live --
|
||||
/// across the metadata, pattern and filter-effects parsers. None may panic.
|
||||
#[test]
|
||||
fn aux_parsers_never_panic_on_damaged_valid_blocks(idx in 0usize..4000, bit in 0u32..8, add in 0u8..=255) {
|
||||
use photocraft_psd::compression::Compression;
|
||||
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
|
||||
use photocraft_psd::layer::Rect;
|
||||
use photocraft_psd::metadata::{self, MetadataItem};
|
||||
use photocraft_psd::patterns::{self, PsdPattern};
|
||||
|
||||
let shmd = metadata::write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2, 3, 4]), MetadataItem::new(*b"cust", vec![9; 6])]);
|
||||
let pattern = PsdPattern {
|
||||
mode: 3,
|
||||
width: 4,
|
||||
height: 3,
|
||||
name: "d".into(),
|
||||
id: "0bd2d3ba".into(),
|
||||
palette: None,
|
||||
depth: 8,
|
||||
channels: vec![vec![7u8; 12], vec![8u8; 12], vec![9u8; 12]],
|
||||
alpha: None,
|
||||
};
|
||||
let pat_block = patterns::write_pattern_block(std::slice::from_ref(&pattern)).unwrap();
|
||||
let pat_file = patterns::write_pat_file(std::slice::from_ref(&pattern)).unwrap();
|
||||
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
|
||||
let plane = EffectsPlane::encode(Compression::Rle, &[10u8; 16], 4, 4, 8).unwrap();
|
||||
let mut slots = vec![None; 26];
|
||||
slots[0] = Some(plane.clone());
|
||||
slots[25] = Some(plane.clone());
|
||||
let fx = FilterEffects {
|
||||
version: 3,
|
||||
items: vec![FilterEffectsItem { id: "x".into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: Some((rect, plane)) }],
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let damage = |mut b: Vec<u8>| -> Vec<u8> {
|
||||
if !b.is_empty() {
|
||||
let i = idx % b.len();
|
||||
b[i] ^= 1u8 << bit;
|
||||
b[i] = b[i].wrapping_add(add);
|
||||
}
|
||||
b
|
||||
};
|
||||
let _ = metadata::parse_shmd(&damage(shmd));
|
||||
let _ = patterns::parse_pattern_block(&damage(pat_block));
|
||||
let _ = patterns::parse_pat_file(&damage(pat_file));
|
||||
if let Ok(f) = FilterEffects::parse(&damage(fx)) {
|
||||
for it in &f.items {
|
||||
let _ = it.decoded_mask();
|
||||
}
|
||||
let _ = f.to_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `check_structure` key over arbitrary data: the strict inner re-parse for placed layers,
|
||||
/// linked files, object effects and filter caches is entered and never panics.
|
||||
#[test]
|
||||
fn tagged_structural_keys_never_panic(ki in 0usize..8, data in proptest::collection::vec(any::<u8>(), 0..200)) {
|
||||
const KEYS: [&[u8; 4]; 8] = [b"PlLd", b"SoLd", b"SoLE", b"lnk2", b"lnk3", b"lnkD", b"lfx2", b"FEid"];
|
||||
let b = TaggedBlock::new(*KEYS[ki], data);
|
||||
let _ = b.check_structure();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
//! `.grd`, `.ase` and `.aco` hostile-input handling.
|
||||
//!
|
||||
//! These three parsers are standalone entry points that `PsdFile::from_bytes` never reaches
|
||||
//! (nothing in `file.rs` calls them), so the `parse.rs` fuzz target cannot exercise them. They are
|
||||
//! also user-reachable: `grd::parse` backs the gradient-preset import in
|
||||
//! `engine::preset_import_cmds`, and `ase`/`aco` back swatch import and export. Each module has its
|
||||
//! own `hostile_input` unit test, but those assert `let _ = parse(...)` on mutations: a panic would
|
||||
//! abort the test without saying which parser and which byte caused it.
|
||||
//!
|
||||
//! What this file adds:
|
||||
//! 1. every single-bit flip in a valid file, each wrapped in `catch_unwind` so a panic is
|
||||
//! reported with the exact byte and parser rather than as an opaque abort (§0.8: "parsers and
|
||||
//! decoders get mutation-fuzz tests run under `catch_unwind`");
|
||||
//! 2. the round-trip and cap invariants the fuzz targets assert, checked deterministically so CI
|
||||
//! covers them even though the `fuzz` job is workflow-dispatch only;
|
||||
//! 3. inputs sized to *exceed* each advertised cap, because a cap asserted only against a file
|
||||
//! that fits under it is a tautology — it cannot fail, so it proves nothing.
|
||||
//!
|
||||
//! The rule under test throughout: hostile input returns `Err`, never a panic and never an
|
||||
//! unbounded allocation.
|
||||
|
||||
use photocraft_psd::aco::{AcoColor, AcoSwatch};
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop};
|
||||
use photocraft_psd::{PsdError, aco, ase, grd};
|
||||
use std::panic::{RefUnwindSafe, UnwindSafe, catch_unwind};
|
||||
|
||||
fn sample_ase() -> Vec<u8> {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch as Sw, write};
|
||||
let sw = |name: &str| Sw { name: name.into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
write(&AseFile { entries: vec![AseEntry::Group { name: "Brand".into(), swatches: vec![sw("Red"), sw("Blue")] }, AseEntry::Swatch(sw("Loose"))] })
|
||||
.expect("writing a sample .ase")
|
||||
}
|
||||
|
||||
fn sample_aco_swatches() -> Vec<AcoSwatch> {
|
||||
vec![
|
||||
AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) },
|
||||
AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) },
|
||||
AcoSwatch { name: "Lab".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } },
|
||||
AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) },
|
||||
AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } },
|
||||
]
|
||||
}
|
||||
|
||||
fn sample_aco() -> Vec<u8> {
|
||||
aco::write(&sample_aco_swatches()).expect("writing a sample .aco")
|
||||
}
|
||||
|
||||
fn sample_grd() -> Vec<u8> {
|
||||
use photocraft_psd::grd::write;
|
||||
write(&[
|
||||
GrdGradient {
|
||||
name: "Sunset".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)],
|
||||
},
|
||||
GrdGradient { name: "Noisy".into(), noise: true, stops: Vec::new(), opacity: Vec::new() },
|
||||
])
|
||||
}
|
||||
|
||||
/// Flips every single bit of a valid file and requires that the parser returns or fails without
|
||||
/// panicking. A flipped length or count must never be trusted into an allocation.
|
||||
fn bitflip_sweep<T, F>(bytes: &[u8], name: &str, parse: F)
|
||||
where
|
||||
F: Fn(&[u8]) -> Result<T, PsdError> + RefUnwindSafe + UnwindSafe,
|
||||
{
|
||||
for byte in 0..bytes.len() {
|
||||
for bit in 0..8 {
|
||||
let mut b = bytes.to_vec();
|
||||
b[byte] ^= 1 << bit;
|
||||
let r = catch_unwind(|| parse(&b));
|
||||
assert!(r.is_ok(), "{name}: parse panicked after flipping bit {bit} of byte {byte} of {}", bytes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_ase(), "ase", ase::parse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aco_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_aco(), "aco", aco::parse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_grd(), "grd", grd::parse);
|
||||
}
|
||||
|
||||
fn assert_finite(values: &[f32], what: &str) {
|
||||
for v in values {
|
||||
assert!(v.is_finite(), "{what} component {v} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every number a parsed gradient carries must be finite and inside the ranges the module
|
||||
/// documents, so a caller never divides by or formats a NaN and never reads past a 0..=1 bound.
|
||||
fn assert_gradient_sane(g: &GrdGradient) {
|
||||
for s in &g.stops {
|
||||
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
|
||||
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
|
||||
match s.color {
|
||||
GrdColor::Rgb(v) => assert_finite(&v, "RGB"),
|
||||
GrdColor::Hsb(v) => assert_finite(&v, "HSB"),
|
||||
GrdColor::Cmyk(v) => assert_finite(&v, "CMYK"),
|
||||
GrdColor::Lab(v) => assert_finite(&v, "Lab"),
|
||||
GrdColor::Gray(v) => assert!(v.is_finite(), "gray {v} is not finite"),
|
||||
GrdColor::Foreground | GrdColor::Background => {}
|
||||
}
|
||||
}
|
||||
for &(l, o, m) in &g.opacity {
|
||||
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
|
||||
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
|
||||
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient file that parses must obey the caps the module advertises and keep every numeric
|
||||
/// field finite, so callers never divide by or format a NaN.
|
||||
#[test]
|
||||
fn grd_parsed_values_respect_caps_and_are_finite() {
|
||||
// No cap assertion here: the sample has two gradients and two stops, so a cap check would
|
||||
// hold for any writer and prove nothing. `grd_gradient_count_is_capped` and
|
||||
// `grd_stop_counts_are_capped` drive inputs over the caps instead.
|
||||
for g in &grd::parse(&sample_grd()).expect("sample .grd parses") {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file declaring more gradients than [`grd::MAX_GRADIENTS`] must be capped at the cap, not
|
||||
/// trusted: the cap is the only thing between a file's own list length and the allocation it asks
|
||||
/// for. The input deliberately exceeds the cap, otherwise the assertion below cannot fail.
|
||||
#[test]
|
||||
fn grd_gradient_count_is_capped() {
|
||||
let over = vec![GrdGradient { name: "g".into(), ..Default::default() }; grd::MAX_GRADIENTS + 1];
|
||||
let bytes = grd::write(&over);
|
||||
// Precondition: the file really must carry more gradients than the cap, or the assertion
|
||||
// below would hold for the wrong reason (the writer capping rather than the reader).
|
||||
// Precondition, by size: a file carrying one gradient more than the cap must be larger than
|
||||
// the same file at the cap. Counting `Grdn` windows would not work — the object nests — and
|
||||
// would keep passing even if the writer started capping, which is what this must catch.
|
||||
let at_cap = grd::write(&over[..grd::MAX_GRADIENTS]).len();
|
||||
assert!(bytes.len() > at_cap, "the written file is not larger than the capped one: {} vs {at_cap}", bytes.len());
|
||||
let f = grd::parse(&bytes).expect("a file over the gradient cap still parses");
|
||||
assert_eq!(f.len(), grd::MAX_GRADIENTS, "gradients must be capped at MAX_GRADIENTS, not taken as declared");
|
||||
}
|
||||
|
||||
/// Same for the per-gradient stop lists: colour stops and transparency stops are capped
|
||||
/// separately, so both are driven over their cap.
|
||||
#[test]
|
||||
fn grd_stop_counts_are_capped() {
|
||||
let stop = GrdStop { location: 0.5, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) };
|
||||
let over = GrdGradient { name: "wide".into(), noise: false, stops: vec![stop; grd::MAX_STOPS + 1], opacity: vec![(0.5, 1.0, 0.5); grd::MAX_STOPS + 1] };
|
||||
let bytes = grd::write(std::slice::from_ref(&over));
|
||||
// Precondition by size: a file carrying one stop more than the cap must be larger than the
|
||||
// same file at the cap. Counting `Lctn` windows would not work — it appears in both the colour
|
||||
// and the transparency list — and would keep passing even if the writer started capping,
|
||||
// which is exactly what this must catch. Each list is driven on its own, since they are
|
||||
// capped independently.
|
||||
let capped_stops = over.stops[..grd::MAX_STOPS].to_vec();
|
||||
let capped_opacity = over.opacity[..grd::MAX_STOPS].to_vec();
|
||||
let colour_only_over = GrdGradient { opacity: Vec::new(), ..over.clone() };
|
||||
let colour_only_capped = GrdGradient { stops: capped_stops, opacity: Vec::new(), ..over.clone() };
|
||||
assert!(grd::write(&[colour_only_over]).len() > grd::write(&[colour_only_capped]).len(), "colour stops over the cap are not larger than at the cap");
|
||||
let trans_only_over = GrdGradient { stops: Vec::new(), ..over.clone() };
|
||||
let trans_only_capped = GrdGradient { stops: Vec::new(), opacity: capped_opacity, ..over };
|
||||
assert!(grd::write(&[trans_only_over]).len() > grd::write(&[trans_only_capped]).len(), "transparency stops over the cap are not larger than at the cap");
|
||||
let f = grd::parse(&bytes).expect("a file over the stop cap still parses");
|
||||
assert_eq!(f[0].stops.len(), grd::MAX_STOPS, "colour stops must be capped at MAX_STOPS");
|
||||
assert_eq!(f[0].opacity.len(), grd::MAX_STOPS, "transparency stops must be capped at MAX_STOPS");
|
||||
}
|
||||
|
||||
/// `Opct` is a unit float (`UntF` + a 4-byte unit tag), not a `doub`, so patching it needs a
|
||||
/// different offset from the RGB components. Every numeric field now passes through `finite()`
|
||||
/// before any clamp, so this pins that a non-finite value is repaired first — saturated, or for a
|
||||
/// NaN replaced by the field default — rather than reaching `f32::clamp`, which propagates NaN and
|
||||
/// would hand it straight back out of the parser. Every non-finite shape is tried at both places.
|
||||
#[test]
|
||||
fn grd_non_finite_opacity_and_midpoint_do_not_survive() {
|
||||
// 3.0e38 is finite as both f64 and f32, so it is not caught by the finiteness check and must
|
||||
// be brought into range by the saturate-then-clamp path; it belongs here to keep that honest.
|
||||
let bad = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, 3.0e38];
|
||||
// `doub`: key + 4-byte OSType + 8-byte value. `UntF`: key + OSType + 4-byte unit tag + value.
|
||||
// `Opct` is the interesting one: a unit float at a different offset from the RGB components, so
|
||||
// both the `doub` and `UntF` shapes are patched. `Mdpn` is written as an integer here, which
|
||||
// cannot carry a non-finite value at all, so it is skipped rather than pretended to be covered;
|
||||
// a hostile file storing `Mdpn` as a double is still guarded by `finite()` and by the fuzz
|
||||
// target's range assertion.
|
||||
for key in ["Opct", "Rd ", "Grn ", "Bl "] {
|
||||
for v in bad {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
|
||||
let skip = match &b[at + 4..at + 8] {
|
||||
b"doub" => 8usize,
|
||||
b"UntF" => 12usize,
|
||||
other => panic!("{key} is stored as {other:?}, which this test cannot patch"),
|
||||
};
|
||||
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
|
||||
let f = grd::parse(&b).unwrap_or_else(|e| panic!("{key} = {v} still parses: {e:?}"));
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `num` returns the raw f64 and `finite()` is the single guard every number passes through. This
|
||||
/// pins that one policy end to end: only a NaN is truly absent and falls back to the documented
|
||||
/// default (opacity 100 %, midpoint centred); every other shape — including `+/-inf` and a value
|
||||
/// finite as `f64` but above `f32::MAX` — saturates and then clamps, so a large positive lands at
|
||||
/// 1.0 and a large negative at 0.0, monotonic, never a snap back to the default.
|
||||
/// `Mdpn` is written as a `long` by this crate's own writer, so no fixture can smuggle a
|
||||
/// non-finite value into it and the guard inside `midpoint()` would never be exercised. The file
|
||||
/// format allows a `double` there, so the descriptor is built by hand with `Mdpn` as a `doub`:
|
||||
/// otherwise removing that guard changes nothing any test can see.
|
||||
#[test]
|
||||
fn grd_midpoint_stored_as_a_double_is_still_sanitised() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
for v in [f64::NAN, f64::INFINITY, 1e300, 3.0e38] {
|
||||
// Driven through `Trns`, where the reader keeps the midpoint, rather than through `Clrs`,
|
||||
// where a colour stop also needs a colour this descriptor does not carry.
|
||||
let trns = Descriptor::new("TrnS").with("Mdpn", Value::Double(v));
|
||||
let grad = Descriptor::new("Grdn").with("Trns", Value::List(vec![Value::Descriptor(trns)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
|
||||
let parsed = grd::parse(&g).unwrap_or_else(|e| panic!("Mdpn = {v} still parses: {e:?}"));
|
||||
let Some((_, _, m)) = parsed[0].opacity.first() else { panic!("the transparency stop was dropped") };
|
||||
// Pin the exact value, not just the range. Only a NaN is truly absent, so only it falls
|
||||
// back to the centred default; every other shape saturates and then clamps, so a large
|
||||
// positive lands at 1.0 and a large negative at 0.0 — monotonic, never a snap to 0.5.
|
||||
let expected = if v.is_nan() {
|
||||
0.5
|
||||
} else if v > 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
assert_eq!(*m, expected, "a midpoint of {v} must be {expected}, got {m}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `.aco` stores CMYK as *ink* amounts in memory and the complement on disk: the file holds 0 for
|
||||
/// 100 % ink and the reader adds the back. Nothing pinned that, so flipping either side would
|
||||
/// silently invert every CMYK swatch.
|
||||
#[test]
|
||||
fn aco_cmyk_is_stored_inverted_and_reads_back_as_ink() {
|
||||
let swatches = vec![AcoSwatch { name: "k".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }];
|
||||
let bytes = aco::write(&swatches).expect("writing one CMYK swatch");
|
||||
|
||||
// Version 1 section: version u16, count u16, then a 10-byte record.
|
||||
let space = u16::from_be_bytes([bytes[4], bytes[5]]);
|
||||
let stored: Vec<u16> = (0..4).map(|i| u16::from_be_bytes([bytes[6 + i * 2], bytes[7 + i * 2]])).collect();
|
||||
assert_eq!(space, aco::space::CMYK, "the record must be tagged CMYK");
|
||||
assert_eq!(stored, vec![0, u16::MAX, u16::MAX, u16::MAX - 1234], "the file stores the complement of the ink amounts");
|
||||
assert_eq!(aco::parse(&bytes).expect("the file parses").swatches, swatches, "the ink amounts must survive the round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_unrepresentable_numbers_follow_the_documented_policy() {
|
||||
// The *second* transparency stop carries opacity 0.25; the first is already 1.0, so patching
|
||||
// it could not tell "fell back to the default" from "the patch had no effect".
|
||||
let patch = |key: &[u8], v: f64| {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).rposition(|w| w == key).expect("the sample stores the key");
|
||||
let skip = match &b[at + 4..at + 8] {
|
||||
b"doub" => 8usize,
|
||||
b"UntF" => 12usize,
|
||||
other => panic!("stored as {other:?}"),
|
||||
};
|
||||
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
|
||||
grd::parse(&b).expect("still parses")
|
||||
};
|
||||
// Opacity. A NaN is the only truly absent value, so it alone falls back to the 100 % default.
|
||||
// Every other shape saturates then clamps, which is monotonic: a large positive stays opaque,
|
||||
// a large negative becomes transparent. Nothing snaps the wrong way as the number grows.
|
||||
for (v, expected) in [
|
||||
(f64::NAN, 1.0), // absent -> the field default (opaque)
|
||||
(f64::INFINITY, 1.0), // saturates to the top of the range
|
||||
(f64::NEG_INFINITY, 0.0), // saturates to the bottom
|
||||
(1e300, 1.0), // over f32::MAX -> saturates up
|
||||
(-1e300, 0.0), // under -f32::MAX -> saturates down
|
||||
(3.0e38, 1.0), // finite as f32 -> clamps up
|
||||
(-3.0e38, 0.0), // finite as f32 -> clamps down
|
||||
] {
|
||||
let got = patch(b"Opct", v)[0].opacity[1].1;
|
||||
assert_eq!(got, expected, "Opct = {v} must read back as {expected}, got {got}");
|
||||
}
|
||||
// A colour channel has its own default (zero) for the absent case, and the same monotonic
|
||||
// saturate-then-clamp for everything else. Pin the exact number: a value reaching the clamp
|
||||
// from above would come back as 1.0, which is in range and would slip past a range-only check.
|
||||
for (v, expected) in [
|
||||
(f64::NAN, 0.0), // absent -> the channel default
|
||||
(f64::INFINITY, 1.0), // saturates up, then clamps to 1.0
|
||||
(f64::NEG_INFINITY, 0.0), // saturates down, then clamps to 0.0
|
||||
(1e300, 1.0), // over f32::MAX -> saturates up
|
||||
(-1e300, 0.0), // under -f32::MAX -> saturates down
|
||||
(3.0e38, 1.0), // finite as f32 -> clamps up
|
||||
(-3.0e38, 0.0), // finite as f32 -> clamps down
|
||||
] {
|
||||
let f = patch(b"Bl ", v);
|
||||
let GrdColor::Rgb([_, _, b]) = f[0].stops[0].color else { panic!("expected an RGB stop") };
|
||||
assert_eq!(b, expected, "a blue component of {v} must be {expected}, got {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name long enough that the code-unit cap lands *between* the halves of a surrogate pair must
|
||||
/// not be cut in half: a lone surrogate is ill-formed UTF-16, and writing it would make the file
|
||||
/// unreadable to anything strict. Capping has to drop the whole character instead, and the file
|
||||
/// must then survive its own round trip unchanged.
|
||||
#[test]
|
||||
fn aco_and_ase_never_split_a_surrogate_pair() {
|
||||
// "a" plus 600 emoji: 1 + 1200 units, so a 1024-unit cap lands mid-pair.
|
||||
let name = format!("a{}", "\u{1F600}".repeat(600));
|
||||
assert!(name.encode_utf16().count() > ase::MAX_NAME_UNITS, "the name must be longer than the cap");
|
||||
|
||||
let swatches = vec![aco::AcoSwatch { name: name.clone(), color: aco::AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&swatches).expect("writing a long non-ASCII name is capped, not refused");
|
||||
let back = aco::parse(&once).expect("the written file parses");
|
||||
// The exact expected name, not merely "it is valid UTF-16": the cap must drop the whole
|
||||
// character that does not fit, leaving "a" plus 511 complete pairs and nothing half-written.
|
||||
let expected = format!("a{}", "\u{1F600}".repeat(511));
|
||||
assert_eq!(back.swatches[0].name, expected, "the capped name must be whole characters only");
|
||||
assert_eq!(aco::write(&back.swatches).unwrap(), once, "the file must be stable across a round trip");
|
||||
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
|
||||
let file = AseFile { entries: vec![AseEntry::Swatch(AseSwatch { name, color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process })] };
|
||||
let a1 = write(&file).expect("writing a long non-ASCII name is capped, not refused");
|
||||
let ab = ase::parse(&a1).expect("the written file parses");
|
||||
let ase::AseEntry::Swatch(s) = &ab.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name, expected, "the capped name must be whole characters only");
|
||||
assert_eq!(write(&ab).unwrap(), a1, "the file must be stable across a round trip");
|
||||
}
|
||||
|
||||
/// Locations, midpoints and opacity are documented as 0..=1. A hostile file stores raw integers,
|
||||
/// so the values are driven well outside that range and must come back clamped.
|
||||
#[test]
|
||||
fn grd_out_of_range_numbers_are_clamped() {
|
||||
let g = GrdGradient {
|
||||
name: "out of range".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 2.0, midpoint: 5.0, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: -3.0, midpoint: -1.0, color: GrdColor::Rgb([0.0, 1.0, 0.0]) },
|
||||
],
|
||||
opacity: vec![(9.0, 5.0, 7.0), (-2.0, -1.0, 42.0)],
|
||||
};
|
||||
let f = grd::parse(&grd::write(&[g])).expect("out-of-range numbers still parse");
|
||||
assert_gradient_sane(&f[0]);
|
||||
// Range membership alone would be a tautology once the values are already in range: assert the
|
||||
// exact clamped result, so a clamp silently degrading into a pass-through is caught. Positive
|
||||
// over-range -> 1.0, negative -> 0.0.
|
||||
assert_eq!(f[0].stops[0].location, 1.0, "location 2.0 must clamp to 1.0");
|
||||
assert_eq!(f[0].stops[0].midpoint, 1.0, "midpoint 5.0 must clamp to 1.0");
|
||||
assert_eq!(f[0].stops[1].location, 0.0, "location -3.0 must clamp to 0.0");
|
||||
assert_eq!(f[0].stops[1].midpoint, 0.0, "midpoint -1.0 must clamp to 0.0");
|
||||
assert_eq!(f[0].opacity[0], (1.0, 1.0, 1.0), "all-positive over-range opacity must clamp to 1.0");
|
||||
assert_eq!(f[0].opacity[1], (0.0, 0.0, 1.0), "location/opacity clamp to 0.0, midpoint 42.0 to 1.0");
|
||||
}
|
||||
|
||||
/// `num` returns the raw f64 unfiltered; `finite()` is the single guard, and a NaN is not the only
|
||||
/// non-finite f32 it has to handle: a value that is finite as f64 and above `f32::MAX` (1e300)
|
||||
/// would become an infinity on a bare cast, so `finite()` saturates it. Every numeric field must
|
||||
/// come back finite, so both doors are checked.
|
||||
#[test]
|
||||
fn grd_huge_f64_does_not_overflow_to_infinity() {
|
||||
for key in ["Rd ", "Grn ", "Bl "] {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
|
||||
assert_eq!(&b[at + 4..at + 8], b"doub", "{key} is stored as a double");
|
||||
b[at + 8..at + 16].copy_from_slice(&1e300f64.to_be_bytes());
|
||||
let f = grd::parse(&b).expect("a huge but finite component still parses");
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hostile file carrying a NaN must have it replaced, not handed on: `Rd ` in the sample is a
|
||||
/// double, so patching those eight bytes is enough to try to smuggle a NaN past the reader.
|
||||
#[test]
|
||||
fn grd_non_finite_numbers_do_not_survive_the_reader() {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == b"Rd ").expect("the sample stores an RGB component");
|
||||
assert_eq!(&b[at + 4..at + 8], b"doub", "an RGB component is stored as a double");
|
||||
b[at + 8..at + 16].copy_from_slice(&f64::NAN.to_be_bytes());
|
||||
let f = grd::parse(&b).expect("a NaN component still parses");
|
||||
assert!(!f.is_empty());
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// An `.ase` that parses must stay within the swatch cap and re-serialize to the same number of
|
||||
/// colours, so an import never silently drops or invents entries.
|
||||
#[test]
|
||||
fn ase_round_trip_preserves_swatch_count() {
|
||||
use photocraft_psd::ase::write;
|
||||
let f = ase::parse(&sample_ase()).expect("sample .ase parses");
|
||||
// No cap assertion: three swatches cannot exceed a cap of 100 000. `ase_swatch_cap_is_enforced_
|
||||
// on_hostile_input` drives a file that does.
|
||||
let n = f.swatch_count();
|
||||
let out = write(&f).expect("re-writing a parsed .ase");
|
||||
let back = ase::parse(&out).expect("the rewritten .ase parses");
|
||||
assert_eq!(back.swatch_count(), n, "swatch count changed across a round trip");
|
||||
}
|
||||
|
||||
/// A file with more colour blocks than [`ase::MAX_SWATCHES`] must be refused. Each block here is
|
||||
/// the smallest legal colour block, so the file is small but the declared count is over the cap.
|
||||
#[test]
|
||||
fn ase_swatch_cap_is_enforced_on_hostile_input() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // name length 0
|
||||
body.extend_from_slice(b"Gray");
|
||||
body.extend_from_slice(&0f32.to_be_bytes()); // gray component
|
||||
body.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
|
||||
let mut block = Vec::new();
|
||||
block.extend_from_slice(&1u16.to_be_bytes()); // block type: colour entry
|
||||
block.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
block.extend_from_slice(&body);
|
||||
|
||||
let total = ase::MAX_SWATCHES + 1;
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // major
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // minor
|
||||
f.extend_from_slice(&(total as u32).to_be_bytes());
|
||||
for _ in 0..total {
|
||||
f.extend_from_slice(&block);
|
||||
}
|
||||
assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_SWATCHES must be refused, not silently truncated");
|
||||
// The same file one block under the cap is accepted, so the cap is what refuses it.
|
||||
f.truncate(f.len() - block.len());
|
||||
let trimmed = total - 1;
|
||||
// signature (4) + major (2) + minor (2) puts the block count at offset 8.
|
||||
f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes());
|
||||
assert!(ase::parse(&f).is_ok(), "a file exactly at MAX_SWATCHES must still parse");
|
||||
}
|
||||
|
||||
/// A name longer than [`ase::MAX_NAME_UNITS`] is truncated on read rather than refused: the file
|
||||
/// is legal, only the name is long, and `read_name` is the only thing standing between the declared
|
||||
/// length and the string it builds.
|
||||
#[test]
|
||||
fn ase_over_long_name_is_truncated_on_read() {
|
||||
let units = ase::MAX_NAME_UNITS + 976; // 2000
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&(units as u16).to_be_bytes()); // name length, no trailing null here
|
||||
for _ in 0..units {
|
||||
body.extend_from_slice(&(b'x' as u16).to_be_bytes());
|
||||
}
|
||||
body.extend_from_slice(b"Gray");
|
||||
body.extend_from_slice(&0f32.to_be_bytes());
|
||||
body.extend_from_slice(&2u16.to_be_bytes());
|
||||
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes());
|
||||
f.extend_from_slice(&0u16.to_be_bytes());
|
||||
f.extend_from_slice(&1u32.to_be_bytes()); // one block
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // colour entry
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
|
||||
let parsed = ase::parse(&f).expect("an over-long name is truncated, not refused");
|
||||
let ase::AseEntry::Swatch(s) = &parsed.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
|
||||
}
|
||||
|
||||
/// An `.aco` that parses must re-serialize to the same swatch count, with no name over the cap.
|
||||
#[test]
|
||||
fn aco_round_trip_preserves_swatch_count() {
|
||||
use photocraft_psd::aco::write;
|
||||
// No cap assertion here. `aco::MAX_SWATCHES` is `u16::MAX` against a `u16` on-disk count, so it
|
||||
// is unfalsifiable by construction, and the sample names are far shorter than the name cap;
|
||||
// `aco_over_long_name_is_truncated_on_read` is where that cap is actually driven.
|
||||
let f = aco::parse(&sample_aco()).expect("sample .aco parses");
|
||||
let out = write(&f.swatches).expect("re-writing parsed swatches");
|
||||
let back = aco::parse(&out).expect("the rewritten .aco parses");
|
||||
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
|
||||
}
|
||||
|
||||
/// A version 2 section whose name declares more units than [`aco::MAX_NAME_UNITS`] must come back
|
||||
/// truncated. The count is written by hand because `write` will not emit one.
|
||||
#[test]
|
||||
fn aco_over_long_name_is_truncated_on_read() {
|
||||
let units = 2000usize;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&2u16.to_be_bytes()); // version 2 section
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // one swatch
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // colour space: RGB
|
||||
for v in [1u16, 2, 3, 0] {
|
||||
f.extend_from_slice(&v.to_be_bytes()); // the four stored values
|
||||
}
|
||||
// The count includes the terminating null, as `write` does and as the format requires.
|
||||
f.extend_from_slice(&((units + 1) as u32).to_be_bytes());
|
||||
for _ in 0..units {
|
||||
f.extend_from_slice(&(b'x' as u16).to_be_bytes());
|
||||
}
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // terminating null
|
||||
let parsed = aco::parse(&f).expect("an over-long name is truncated, not refused");
|
||||
assert_eq!(parsed.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
|
||||
}
|
||||
|
||||
/// `write` must cap too, or a long name from a caller is written whole. The declared length in
|
||||
/// the version 2 record counts the terminating null, so it must read back as MAX_NAME_UNITS + 1.
|
||||
#[test]
|
||||
fn aco_write_caps_the_name() {
|
||||
let swatches = vec![AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(5000) }];
|
||||
let bytes = aco::write(&swatches).expect("an over-long name is capped on write, not refused");
|
||||
// Names go out as UTF-16 big-endian, so 'x' is the byte pair 00 78.
|
||||
let run = [0u8, b'x'];
|
||||
let mut needle = Vec::new();
|
||||
for _ in 0..8 {
|
||||
needle.extend_from_slice(&run);
|
||||
}
|
||||
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
|
||||
let declared = u32::from_be_bytes([bytes[at - 4], bytes[at - 3], bytes[at - 2], bytes[at - 1]]);
|
||||
assert_eq!(declared as usize, aco::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
|
||||
let back = aco::parse(&bytes).expect("the written file parses");
|
||||
assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
|
||||
}
|
||||
|
||||
/// `write` must cap an over-long name too, and it must count the terminating null in the u16
|
||||
/// length field it writes. Without the cap that field would wrap, but only once the name passes
|
||||
/// 65 535 units — so the case that proves it uses 70 000, not merely "long".
|
||||
#[test]
|
||||
fn ase_write_caps_the_name() {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
|
||||
let sw = AseSwatch { name: "x".repeat(70_000), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
let bytes = write(&AseFile { entries: vec![AseEntry::Swatch(sw)] }).expect("an over-long name is capped, not refused");
|
||||
let run = [0u8, b'x'];
|
||||
let mut needle = Vec::new();
|
||||
for _ in 0..8 {
|
||||
needle.extend_from_slice(&run);
|
||||
}
|
||||
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
|
||||
let declared = u16::from_be_bytes([bytes[at - 2], bytes[at - 1]]) as usize;
|
||||
assert_eq!(declared, ase::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
|
||||
let back = ase::parse(&bytes).expect("the written file parses");
|
||||
let ase::AseEntry::Swatch(s) = &back.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS);
|
||||
}
|
||||
|
||||
/// Truncating a `.grd` anywhere must be an error: unlike `.aco` there is no optional second
|
||||
/// section, so a prefix can never be a complete file.
|
||||
#[test]
|
||||
fn truncate_grd_is_always_an_error() {
|
||||
let b = sample_grd();
|
||||
for cut in 0..b.len() {
|
||||
let r = catch_unwind(|| grd::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "grd: parse panicked on a {cut}/{}-byte prefix", b.len());
|
||||
assert!(r.unwrap().is_err(), "grd: truncation at {cut}/{} parsed successfully", b.len());
|
||||
}
|
||||
assert!(grd::parse(&b).is_ok(), "grd: the full file must parse");
|
||||
}
|
||||
|
||||
/// Truncating an `.ase` anywhere must be an error: the block count is fixed up front, so a prefix
|
||||
/// cannot contain the number of blocks the header claims.
|
||||
#[test]
|
||||
fn truncate_ase_is_always_an_error() {
|
||||
let b = sample_ase();
|
||||
for cut in 0..b.len() {
|
||||
let r = catch_unwind(|| ase::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "ase: parse panicked on a {cut}/{}-byte prefix", b.len());
|
||||
assert!(r.unwrap().is_err(), "ase: truncation at {cut}/{} parsed successfully", b.len());
|
||||
}
|
||||
assert!(ase::parse(&b).is_ok(), "ase: the full file must parse");
|
||||
}
|
||||
|
||||
/// Truncating an `.aco` inside its first section must be an error. A cut that lands exactly after a
|
||||
/// complete first section is *not* an error — the reader stops there, having read the version 1
|
||||
/// section, and ignores whatever follows — so the sweep stops at that boundary rather than
|
||||
/// claiming every prefix fails. The boundary is derived from the sample so adding a swatch
|
||||
/// cannot silently weaken the sweep.
|
||||
#[test]
|
||||
fn truncate_aco_inside_first_section_is_an_error() {
|
||||
let b = sample_aco();
|
||||
// version u16 + count u16, then one 10-byte colour record per swatch.
|
||||
let first_section = 4 + sample_aco_swatches().len() * 10;
|
||||
assert!(first_section <= b.len(), "the sample must contain a whole first section");
|
||||
for cut in 0..first_section {
|
||||
let r = catch_unwind(|| aco::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "aco: parse panicked on a {cut}/{first_section}-byte prefix");
|
||||
assert!(r.unwrap().is_err(), "aco: truncation inside the first section at {cut} parsed successfully");
|
||||
}
|
||||
match catch_unwind(|| aco::parse(&b[..first_section])) {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => panic!("aco: a complete first section must parse, got {e:?}"),
|
||||
Err(_) => panic!("aco: a complete first section panicked"),
|
||||
}
|
||||
assert!(aco::parse(&b).is_ok(), "aco: the full file must parse");
|
||||
}
|
||||
|
||||
/// Structural `.ase` cases the block reader has to survive: a group end with no start, a group
|
||||
/// started inside another, a block length that disagrees with what the body consumes, and an
|
||||
/// unknown colour model whose components are cut short. Each is a clean error or a clean parse,
|
||||
/// never a panic and never a read past the block.
|
||||
#[test]
|
||||
fn ase_structurally_hostile_blocks() {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry};
|
||||
let header = |count: u32| {
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes());
|
||||
f.extend_from_slice(&0u16.to_be_bytes());
|
||||
f.extend_from_slice(&count.to_be_bytes());
|
||||
f
|
||||
};
|
||||
let block = |kind: u16, body: &[u8]| {
|
||||
let mut b = kind.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(body);
|
||||
b
|
||||
};
|
||||
let group_start = |name: &str| {
|
||||
// The name is a u16 code-unit count followed by the units themselves, not raw bytes.
|
||||
let units: Vec<u16> = name.encode_utf16().collect();
|
||||
let mut b = (units.len() as u16).to_be_bytes().to_vec();
|
||||
for u in units {
|
||||
b.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
block(0xC001, &b)
|
||||
};
|
||||
let group_end = || block(0xC002, &[]);
|
||||
let colour = |model: &[u8], floats: &[u8]| {
|
||||
let mut b = 0u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(model);
|
||||
b.extend_from_slice(floats);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
block(0x0001, &b)
|
||||
};
|
||||
|
||||
// A group end with no start: documented as ignored, so this parses and yields nothing.
|
||||
let mut f = header(1);
|
||||
f.extend_from_slice(&group_end());
|
||||
let parsed = ase::parse(&f).expect("an unbalanced group end must parse");
|
||||
assert!(parsed.entries.is_empty(), "a stray group end must not invent an entry or an empty group");
|
||||
|
||||
// A group started inside another: the outer one closes, both groups are emitted.
|
||||
let mut f = header(3);
|
||||
f.extend_from_slice(&group_start("outer"));
|
||||
f.extend_from_slice(&group_start("inner"));
|
||||
f.extend_from_slice(&colour(b"Gray", &1f32.to_be_bytes()));
|
||||
let parsed = ase::parse(&f).expect("a nested group start must parse");
|
||||
assert_eq!(parsed.swatch_count(), 1, "the colour must land in exactly one group");
|
||||
// The message above is only true if the colour is in the *inner* group; check it.
|
||||
let inner_holds_it = parsed.entries.iter().any(|e| matches!(e, AseEntry::Group { name, swatches } if name == "inner" && swatches.len() == 1));
|
||||
assert!(inner_holds_it, "the colour must land in the inner group, not the outer one");
|
||||
let names: Vec<&str> = parsed
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
AseEntry::Group { name, .. } => Some(name.as_str()),
|
||||
AseEntry::Swatch(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, vec!["outer", "inner"], "both groups must be closed and emitted");
|
||||
|
||||
// A block length shorter than the body it describes: the colour's floats are cut off.
|
||||
// Gray carries exactly one component, so declaring the block one byte shorter leaves that
|
||||
// component half-read. It must be refused, not guessed at.
|
||||
let mut short = 0u16.to_be_bytes().to_vec();
|
||||
short.extend_from_slice(b"Gray");
|
||||
short.extend_from_slice(&0f32.to_be_bytes());
|
||||
short.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
|
||||
let mut f = header(1);
|
||||
let mut b = 0x0001u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&((short.len() - 4) as u32).to_be_bytes());
|
||||
b.extend_from_slice(&short);
|
||||
f.extend_from_slice(&b);
|
||||
let r = catch_unwind(|| ase::parse(&f));
|
||||
assert!(r.is_ok(), "a truncated colour component must not panic");
|
||||
assert!(r.unwrap().is_err(), "a colour component cut short must be refused, not guessed at");
|
||||
|
||||
// A block that ends after the components, with no colour type at all, is *accepted*: some
|
||||
// writers omit it and the reader defaults it to process. Pinning that, because it is the one
|
||||
// place this parser is deliberately lenient.
|
||||
let mut f = header(1);
|
||||
let mut b = 0x0001u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&(short.len().saturating_sub(2) as u32).to_be_bytes());
|
||||
b.extend_from_slice(&short[..short.len() - 2]);
|
||||
f.extend_from_slice(&b);
|
||||
let parsed = ase::parse(&f).expect("a colour block with no colour type is accepted by design");
|
||||
assert_eq!(parsed.swatch_count(), 1, "it must still yield exactly one swatch");
|
||||
|
||||
// An unknown colour model keeps whatever bytes follow it verbatim.
|
||||
// The model tag is four bytes; give it four, then a known payload, so "kept verbatim" can be
|
||||
// checked rather than merely asserted.
|
||||
let mut f = header(1);
|
||||
let payload = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
f.extend_from_slice(&colour(b"XYZ ", &payload));
|
||||
let parsed = ase::parse(&f).expect("an unknown colour model is kept verbatim");
|
||||
assert_eq!(parsed.swatch_count(), 1, "an unknown model still yields one swatch");
|
||||
let AseEntry::Swatch(sw) = &parsed.entries[0] else { panic!("expected a loose swatch") };
|
||||
let AseColor::Other { model, data } = &sw.color else { panic!("an unknown model must be kept as Other, got {:?}", sw.color) };
|
||||
assert_eq!(model, b"XYZ ", "the model tag must be preserved verbatim");
|
||||
assert_eq!(data.as_slice(), &payload, "the component bytes must be preserved verbatim");
|
||||
}
|
||||
|
||||
/// A `.aco` whose version 1 and version 2 sections disagree about the number of swatches is the
|
||||
/// realistic hostile shape: Photoshop writes both, and a corrupted length lands in one of them.
|
||||
/// The version 2 names must still line up with the version 1 colours they belong to.
|
||||
#[test]
|
||||
fn aco_mismatched_section_counts_do_not_panic() {
|
||||
// version 1 with two colours and no names, then a version 2 section claiming five.
|
||||
let mut f = vec![0u8, 1, 0, 2];
|
||||
for _ in 0..2 {
|
||||
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
|
||||
}
|
||||
f.extend_from_slice(&[0, 2, 0, 5]);
|
||||
for _ in 0..5 {
|
||||
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
|
||||
// An .aco record has no colour-type field, so a one-character name is exactly two units:
|
||||
// two u16 values, not one.
|
||||
f.extend_from_slice(&2u32.to_be_bytes());
|
||||
f.extend_from_slice(&[0, 78, 0, 0]); // 'N' and its null
|
||||
}
|
||||
let r = catch_unwind(|| aco::parse(&f));
|
||||
assert!(r.is_ok(), "mismatched section counts must not panic");
|
||||
let parsed = r.unwrap().expect("the version 2 section is the one with names");
|
||||
assert_eq!(parsed.swatches.len(), 5, "the named section is authoritative when present");
|
||||
for sw in &parsed.swatches {
|
||||
assert_eq!(sw.name, "N", "each version 2 record must keep its name beside its own colour");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep descriptor nesting is the one hostile shape that aborts rather than returning: a stack
|
||||
/// overflow is not a panic, so `catch_unwind` cannot catch it and it would take the process down.
|
||||
/// `descriptor` bounds it at `MAX_DEPTH` and its own unit test drives `Descriptor::from_bytes`
|
||||
/// directly; this drives the same limit through `grd::parse`, which is the path a user's gradient
|
||||
/// preset actually takes, and asserts the specific error rather than merely surviving.
|
||||
///
|
||||
/// The previous version of this test built `b"null"` repeated 2000 times, which is not a descriptor
|
||||
/// at all — no item counts, no `Objc` tags — so the parser rejected it on the first field and
|
||||
/// nothing ever recursed. It passed for the wrong reason.
|
||||
#[test]
|
||||
fn grd_deeply_nested_descriptor_hits_the_depth_limit() {
|
||||
use photocraft_psd::descriptor::{Descriptor, MAX_DEPTH, Value, VersionedDescriptor};
|
||||
let mut d = Descriptor::new("null");
|
||||
for _ in 0..(MAX_DEPTH + 5) {
|
||||
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
|
||||
}
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
|
||||
// This is the real assertion: nesting past the limit is refused, not recursed into.
|
||||
assert!(matches!(grd::parse(&g), Err(PsdError::LimitExceeded(_))), "nesting past MAX_DEPTH must be refused through the .grd path, not recursed into");
|
||||
|
||||
// The limit discriminates rather than being a blanket refusal: a shallow nesting is accepted
|
||||
// (rejected later, for holding no gradients — a different error), while a deep one is a depth
|
||||
// refusal. Each wrapper adds more than one unit of depth, so the exact crossover is an
|
||||
// implementation detail; what matters is that normal files pass and runaway ones are stopped.
|
||||
let build = |levels: usize| {
|
||||
let mut d = Descriptor::new("null");
|
||||
for _ in 0..levels {
|
||||
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
|
||||
}
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
|
||||
g
|
||||
};
|
||||
assert!(!matches!(grd::parse(&build(4)), Err(PsdError::LimitExceeded(_))), "a shallow descriptor must not be refused for depth");
|
||||
assert!(
|
||||
matches!(grd::parse(&build(MAX_DEPTH + 1)), Err(PsdError::LimitExceeded(_))),
|
||||
"a descriptor past the depth limit must be a depth refusal, not an abort"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every colour model's components must come back finite and inside their documented range, not
|
||||
/// just RGB. The writer only emits RGB and foreground/background stops, so each model is built by
|
||||
/// hand with an out-of-range or non-representable value in every component, then driven through
|
||||
/// `grd::parse`. Without this, the clamps on HSB, CMYK, Gray and the L* channel — and the finite
|
||||
/// guard on the Lab a*/b* channels — have no test and can be removed unnoticed.
|
||||
#[test]
|
||||
fn grd_every_colour_model_is_clamped_and_finite() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
|
||||
// Build a one-stop gradient whose colour is `class_id` with the given (key, value) components.
|
||||
let build = |class_id: &str, comps: &[(&str, f64)]| -> Vec<u8> {
|
||||
let mut colour = Descriptor::new(class_id);
|
||||
for (k, v) in comps {
|
||||
colour = colour.with(k, Value::Double(*v));
|
||||
}
|
||||
let stop = Descriptor::new("Clrt")
|
||||
.with("Clr ", Value::Descriptor(colour))
|
||||
.with("Type", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("Clry"), value: photocraft_psd::descriptor::Id::new("UsrS") })
|
||||
.with("Lctn", Value::Integer(0))
|
||||
.with("Mdpn", Value::Integer(50));
|
||||
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
g
|
||||
};
|
||||
|
||||
let hostile = [1e300, 3.0e38, -3.0e38, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
for &x in &hostile {
|
||||
// The exact boundary each clamp must land on, so a weaker clamp (e.g. a*/b* to -100..=100
|
||||
// instead of -128..=127, or a unit range to 0..=0.5) is caught, not just an out-of-range
|
||||
// value. A NaN becomes 0.0 (the finite guard's fallback for `f`), a positive saturates to
|
||||
// the top of the range, a negative to the bottom.
|
||||
let unit = if x.is_nan() || x < 0.0 { 0.0f32 } else { 1.0 };
|
||||
let lab_l = if x.is_nan() || x < 0.0 { 0.0f32 } else { 100.0 };
|
||||
let lab_ab = if x.is_nan() {
|
||||
0.0f32
|
||||
} else if x > 0.0 {
|
||||
127.0
|
||||
} else {
|
||||
-128.0
|
||||
};
|
||||
// RGB components are 0..=255 before the /255; the result must land in 0..=1.
|
||||
let f = grd::parse(&build("RGBC", &[("Rd ", x), ("Grn ", x), ("Bl ", x)])).expect("RGB parses");
|
||||
let GrdColor::Rgb(rgb) = f[0].stops[0].color else { panic!("expected an RGB stop") };
|
||||
for v in rgb {
|
||||
assert_eq!(v, unit, "RGB component for input {x} must saturate to {unit}");
|
||||
}
|
||||
// HSB: hue 0..=360, saturation and brightness 0..=1.
|
||||
let f = grd::parse(&build("HSBC", &[("H ", x), ("Strt", x), ("Brgh", x)])).expect("HSB parses");
|
||||
if let GrdColor::Hsb([h, sat, br]) = f[0].stops[0].color {
|
||||
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} out of range for input {x}");
|
||||
assert_eq!(sat, unit, "saturation for input {x} must saturate to {unit}");
|
||||
assert_eq!(br, unit, "brightness for input {x} must saturate to {unit}");
|
||||
} else {
|
||||
panic!("expected an HSB stop");
|
||||
}
|
||||
// CMYK: every ink 0..=1.
|
||||
let f = grd::parse(&build("CMYC", &[("Cyn ", x), ("Mgnt", x), ("Ylw ", x), ("Blck", x)])).expect("CMYK parses");
|
||||
if let GrdColor::Cmyk(c) = f[0].stops[0].color {
|
||||
for v in c {
|
||||
assert_eq!(v, unit, "CMYK ink for input {x} must saturate to {unit}");
|
||||
}
|
||||
} else {
|
||||
panic!("expected a CMYK stop");
|
||||
}
|
||||
// Lab: L* 0..=100, a*/b* bounded to the CIELAB gamut -128..=127.
|
||||
let f = grd::parse(&build("LbCl", &[("Lmnc", x), ("A ", x), ("B ", x)])).expect("Lab parses");
|
||||
if let GrdColor::Lab([l, a, b]) = f[0].stops[0].color {
|
||||
assert_eq!(l, lab_l, "L* for input {x} must saturate to {lab_l}");
|
||||
assert_eq!(a, lab_ab, "a* for input {x} must saturate to the gamut edge {lab_ab}");
|
||||
assert_eq!(b, lab_ab, "b* for input {x} must saturate to the gamut edge {lab_ab}");
|
||||
} else {
|
||||
panic!("expected a Lab stop");
|
||||
}
|
||||
// Gray: 0..=1.
|
||||
let f = grd::parse(&build("Grsc", &[("Gry ", x)])).expect("Gray parses");
|
||||
if let GrdColor::Gray(g) = f[0].stops[0].color {
|
||||
assert_eq!(g, unit, "gray for input {x} must saturate to {unit}");
|
||||
} else {
|
||||
panic!("expected a Gray stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue is circular, so an out-of-range hue must *wrap*, not clamp: 370 degrees is 10, not 360, and
|
||||
/// -10 is 350, not 0. Clamping would hand back a different colour; this pins the wrap so a
|
||||
/// regression to `clamp` is caught (both produce in-range values, so a range check alone would not).
|
||||
#[test]
|
||||
fn grd_hue_wraps_rather_than_clamping() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor};
|
||||
let build = |hue: f64| {
|
||||
let colour = Descriptor::new("HSBC").with("H ", Value::Double(hue)).with("Strt", Value::Double(50.0)).with("Brgh", Value::Double(50.0));
|
||||
let stop = Descriptor::new("Clrt")
|
||||
.with("Clr ", Value::Descriptor(colour))
|
||||
.with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") })
|
||||
.with("Lctn", Value::Integer(0))
|
||||
.with("Mdpn", Value::Integer(50));
|
||||
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
g
|
||||
};
|
||||
let hue_of = |bytes: &[u8]| -> f32 {
|
||||
let f = grd::parse(bytes).expect("parses");
|
||||
let GrdColor::Hsb([h, _, _]) = f[0].stops[0].color else { panic!("expected an HSB stop") };
|
||||
h
|
||||
};
|
||||
assert!((hue_of(&build(370.0)) - 10.0).abs() < 0.01, "370 degrees must wrap to 10, not clamp to 360");
|
||||
assert!((hue_of(&build(-10.0)) - 350.0).abs() < 0.01, "-10 degrees must wrap to 350, not clamp to 0");
|
||||
// And a value already in range is unchanged.
|
||||
assert!((hue_of(&build(123.0)) - 123.0).abs() < 0.01, "an in-range hue must be unchanged");
|
||||
// Exactly 360 is in range and legal, so it is kept, not wrapped to 0 (a different position for
|
||||
// anything that interpolates in HSB). Unconditional `rem_euclid(360)` would map it to 0, so this
|
||||
// pins the in-range guard that keeps the boundary intact.
|
||||
assert_eq!(hue_of(&build(360.0)), 360.0, "an in-range hue of exactly 360 must be preserved, not wrapped to 0");
|
||||
assert_eq!(hue_of(&build(0.0)), 0.0, "a hue of 0 must stay 0");
|
||||
}
|
||||
|
||||
/// A signature-only or empty file must be a clean error, never a panic and never an
|
||||
/// out-of-bounds read.
|
||||
#[test]
|
||||
fn empty_and_signature_only_inputs_error() {
|
||||
fn no_panic_then_err<T>(r: Result<Result<T, PsdError>, Box<dyn core::any::Any + Send>>, what: &str) {
|
||||
match r {
|
||||
Ok(Ok(_)) => panic!("{what}: an empty file must be a clean error, not parse"),
|
||||
Ok(Err(_)) => {}
|
||||
Err(_) => panic!("{what}: parse panicked on an empty file"),
|
||||
}
|
||||
}
|
||||
no_panic_then_err(catch_unwind(|| grd::parse(b"")), "grd");
|
||||
no_panic_then_err(catch_unwind(|| ase::parse(b"")), "ase");
|
||||
no_panic_then_err(catch_unwind(|| aco::parse(b"")), "aco");
|
||||
assert!(matches!(grd::parse(b"8BGR"), Err(PsdError::UnexpectedEof { .. })));
|
||||
assert!(matches!(grd::parse(b"8BG"), Err(PsdError::UnexpectedEof { .. })));
|
||||
assert!(ase::parse(b"ASEF").is_err());
|
||||
assert!(aco::parse(b"").is_err());
|
||||
}
|
||||
+191
-31
@@ -1762,6 +1762,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
let mut close = None;
|
||||
let mut tab_action = None;
|
||||
let mut tab_move = None;
|
||||
let mut tear_off = None;
|
||||
let (mut focus_open, mut cancel_open) = (None, None);
|
||||
let focused_open = app.jobs.focus.is_some();
|
||||
// Layers dragged over a tab show its document (`layer_transfer`).
|
||||
@@ -1851,18 +1852,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
tab_drop_line(ui, &placed, p.x, &t);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
|
||||
let to = tab_move_target(i, tab_drop_index(&placed, x));
|
||||
if to != i {
|
||||
tab_move = Some((i, to));
|
||||
}
|
||||
match tab_drag(ui, &resp, i, r, row, &placed, &t) {
|
||||
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
|
||||
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
|
||||
None => {}
|
||||
}
|
||||
let has_path = st.path.is_some();
|
||||
resp.context_menu(|ui| {
|
||||
@@ -1925,6 +1918,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
float_torn_off(app, ui.ctx(), tear_off);
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
@@ -1968,6 +1962,70 @@ fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: boo
|
||||
|
||||
/// Where a tab dragged to `x` would land: before the first shown tab whose middle is right of
|
||||
/// `x`, else after the last shown tab (its document index + 1), like [`TabStrip::slot`].
|
||||
/// How far (points) above or below the tab strip a dragged tab must be to tear off (#3733).
|
||||
const TEAR_OFF: f32 = 24.0;
|
||||
|
||||
/// Whether a tab dragged to `p` has left the strip and floats into its own window, as in
|
||||
/// Photoshop; sideways drags along the strip still reorder (#217).
|
||||
fn torn_off(strip: Rect, p: egui::Pos2) -> bool {
|
||||
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
|
||||
}
|
||||
|
||||
/// What a finished document-tab drag asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum TabDrop {
|
||||
/// Reorder the tab to this index.
|
||||
Move(usize),
|
||||
/// Float the document in its own OS window at this screen position (points), when known.
|
||||
Float(Option<[f32; 2]>),
|
||||
}
|
||||
|
||||
/// Drag a document tab: along the strip it reorders (a line shows where it will land); off the
|
||||
/// strip it tears off into a floating window that can go anywhere, other screens included
|
||||
/// (an outline under the pointer shows it). Returns the drop once the drag ends.
|
||||
fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect, placed: &[(usize, Rect)], t: &crate::theme::Tokens) -> Option<TabDrop> {
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
if torn_off(strip, p) {
|
||||
let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0));
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off")));
|
||||
painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12));
|
||||
painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
} else {
|
||||
tab_drop_line(ui, placed, p.x, t);
|
||||
}
|
||||
}
|
||||
if !resp.drag_stopped() {
|
||||
return None;
|
||||
}
|
||||
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos()));
|
||||
if let Some(p) = p.filter(|&p| torn_off(strip, p)) {
|
||||
// The pointer is in this window's points; the OS wants screen coordinates. Keep the new
|
||||
// window's title bar under the pointer, as the ghost showed.
|
||||
let at = ui.ctx().input(|i| i.viewport().inner_rect).map(|inner| {
|
||||
let s = inner.min + p.to_vec2() - egui::vec2(40.0, 12.0);
|
||||
[s.x, s.y]
|
||||
});
|
||||
return Some(TabDrop::Float(at));
|
||||
}
|
||||
let to = tab_move_target(i, tab_drop_index(placed, p.map_or(r.center().x, |p| p.x)));
|
||||
(to != i).then_some(TabDrop::Move(to))
|
||||
}
|
||||
|
||||
/// Float a torn-off tab's document in its own window (`window.arrange.floatInWindow`).
|
||||
fn float_torn_off(app: &mut PhotocraftApp, ctx: &egui::Context, tear_off: Option<(usize, Option<[f32; 2]>)>) {
|
||||
let Some((doc, at)) = tear_off else { return };
|
||||
let params = match at {
|
||||
Some(at) => json!({"document": doc, "position": at}),
|
||||
None => json!({"document": doc}),
|
||||
};
|
||||
if let Err(e) = crate::menus::invoke(app, ctx, "window.arrange.floatInWindow", params) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn tab_drop_index(placed: &[(usize, Rect)], x: f32) -> usize {
|
||||
match placed.iter().find(|(_, r)| r.center().x >= x) {
|
||||
Some(&(i, _)) => i,
|
||||
@@ -2027,7 +2085,7 @@ const STUDIO_TAB_GAP: f32 = 4.0;
|
||||
fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
|
||||
let Some(st) = app.session.documents().get(i) else { return String::new() };
|
||||
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
|
||||
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
|
||||
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id));
|
||||
let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some());
|
||||
let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() };
|
||||
let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default();
|
||||
@@ -2051,6 +2109,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
let (mut activate, mut close) = (None, None);
|
||||
let mut tab_action = None;
|
||||
let mut tab_move = None;
|
||||
let mut tear_off = None;
|
||||
let tab_count = app.session.documents().len();
|
||||
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
|
||||
let mut drag_over = None;
|
||||
@@ -2111,18 +2170,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
// Drag a tab sideways to reorder it (#217); a line shows where it will land.
|
||||
if resp.dragged()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
tab_drop_line(ui, &placed, p.x, &t);
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x);
|
||||
let to = tab_move_target(i, tab_drop_index(&placed, x));
|
||||
if to != i {
|
||||
tab_move = Some((i, to));
|
||||
}
|
||||
match tab_drag(ui, &resp, i, r, strip, &placed, &t) {
|
||||
Some(TabDrop::Move(to)) => tab_move = Some((i, to)),
|
||||
Some(TabDrop::Float(at)) => tear_off = Some((i, at)),
|
||||
None => {}
|
||||
}
|
||||
let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some());
|
||||
resp.context_menu(|ui| {
|
||||
@@ -2179,6 +2230,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
float_torn_off(app, ui.ctx(), tear_off);
|
||||
if let Some((id, params)) = tab_action
|
||||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params)
|
||||
{
|
||||
@@ -2779,7 +2831,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// zooms nor slides the image towards the pointer.
|
||||
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
|
||||
if nz != view.zoom {
|
||||
zoom_about(&mut view, &xf, p, nz, false, ppp);
|
||||
// Over the pasteboard the anchor is the image's nearest point (#3720).
|
||||
let at = wheel_anchor(&xf, p, view.doc_size);
|
||||
zoom_about(&mut view, &xf, at, nz, false, ppp);
|
||||
}
|
||||
}
|
||||
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
|
||||
@@ -3515,6 +3569,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift);
|
||||
}
|
||||
}
|
||||
if primary && hand_pan && response.dragged() {
|
||||
crate::transform_tool::draw_overlay(app, &painter, &xf);
|
||||
}
|
||||
app.tool_override = None;
|
||||
if let Some(b) = layer_edges_rect(app, idx) {
|
||||
painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside));
|
||||
@@ -3557,6 +3614,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under
|
||||
/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the
|
||||
/// centre of the view instead (#204).
|
||||
/// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image,
|
||||
/// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray
|
||||
/// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps
|
||||
/// the image in view. Clamped in document space, so a rotated or flipped view behaves the same.
|
||||
fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 {
|
||||
let [w, h] = doc_size;
|
||||
let d = xf.to_doc(p);
|
||||
if w == 0 || h == 0 || !(d[0].is_finite() && d[1].is_finite()) {
|
||||
return p;
|
||||
}
|
||||
let c = [d[0].clamp(0.0, f64::from(w)), d[1].clamp(0.0, f64::from(h))];
|
||||
if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) }
|
||||
}
|
||||
|
||||
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) {
|
||||
let before = xf.to_doc(p);
|
||||
view.zoom = new_zoom;
|
||||
@@ -3663,9 +3734,21 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate
|
||||
pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) {
|
||||
let dash = 4.0f32;
|
||||
let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32;
|
||||
let white = Stroke::new(1.0, Color32::WHITE);
|
||||
let black = Stroke::new(1.0, Color32::BLACK);
|
||||
let clip = painter.clip_rect();
|
||||
// Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a
|
||||
// line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen
|
||||
// whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted
|
||||
// segment (a transformed outline) stays a line shape, which egui antialiases.
|
||||
let mut mesh = egui::Mesh::default();
|
||||
// A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line).
|
||||
let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| {
|
||||
let i = mesh.vertices.len() as u32;
|
||||
for p in [a + side, a - side, b - side, b + side] {
|
||||
mesh.colored_vertex(p, color);
|
||||
}
|
||||
mesh.add_triangle(i, i + 1, i + 2);
|
||||
mesh.add_triangle(i, i + 2, i + 3);
|
||||
};
|
||||
for (a, b) in segs {
|
||||
let pa = to_screen(*a);
|
||||
let pb = to_screen(*b);
|
||||
@@ -3674,12 +3757,19 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
|
||||
}
|
||||
let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5);
|
||||
let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5);
|
||||
painter.line_segment([pa, pb], white);
|
||||
let len = pa.distance(pb);
|
||||
if len < 0.5 {
|
||||
continue;
|
||||
}
|
||||
let dir = (pb - pa) / len;
|
||||
let side = dir.rot90() * 0.5;
|
||||
let straight = pa.x == pb.x || pa.y == pb.y;
|
||||
if straight {
|
||||
// The white base runs half a pixel past both ends, so joined segments meet at corners.
|
||||
quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE);
|
||||
} else {
|
||||
painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
// Phase by screen position so dashes line up across joined segments.
|
||||
let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0);
|
||||
let mut t = -start;
|
||||
@@ -3687,11 +3777,18 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli
|
||||
let s0 = t.max(0.0);
|
||||
let s1 = (t + dash).min(len);
|
||||
if s1 > s0 {
|
||||
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
|
||||
if straight {
|
||||
quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK);
|
||||
} else {
|
||||
painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
}
|
||||
t += dash * 2.0;
|
||||
}
|
||||
}
|
||||
if !mesh.is_empty() {
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -5031,7 +5128,10 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let Some(st) = app.session.documents().get(w.document) else { continue };
|
||||
let title = format!("{} — window {}", st.doc.name, w.id);
|
||||
let vid = egui::ViewportId::from_hash_of(("docwin", w.id));
|
||||
let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
|
||||
let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
|
||||
if let Some([x, y]) = w.position {
|
||||
builder = builder.with_position([x, y]);
|
||||
}
|
||||
let mut view = w.view.clone();
|
||||
let mut close = false;
|
||||
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
|
||||
@@ -5688,6 +5788,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging a tab off the strip floats its document in its own OS window instead of
|
||||
/// reordering (#3733), in both strips; a short wobble below the strip still reorders.
|
||||
#[test]
|
||||
fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let strip = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(700.0, 26.0));
|
||||
assert!(!torn_off(strip, egui::pos2(50.0, 13.0)));
|
||||
assert!(!torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF - 1.0)), "a wobble below the strip still reorders");
|
||||
assert!(torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF + 1.0)));
|
||||
assert!(torn_off(strip, egui::pos2(50.0, -TEAR_OFF - 1.0)), "dragged up, off the window's top");
|
||||
for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
for name in ["a", "b", "c"] {
|
||||
app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 300.0)).build_ui_state(
|
||||
|ui, (app, ready): &mut (PhotocraftApp, bool)| {
|
||||
if *ready {
|
||||
tabs(app, ui);
|
||||
}
|
||||
},
|
||||
(app, false),
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, kind);
|
||||
h.state_mut().1 = true;
|
||||
h.run_steps(2);
|
||||
let b = h.get_by_label_contains("b").rect();
|
||||
let target = egui::pos2(b.center().x, b.bottom() + 150.0);
|
||||
h.drag_at(b.center());
|
||||
h.run_steps(1);
|
||||
h.hover_at(target);
|
||||
h.run_steps(1);
|
||||
h.drop_at(target);
|
||||
h.run_steps(2);
|
||||
let app = &h.state().0;
|
||||
let names: Vec<&str> = app.session.documents().iter().map(|d| d.doc.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b", "c"], "{kind:?}: tearing off doesn't reorder");
|
||||
assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::<Vec<_>>(), [1], "{kind:?}: b floats in its own window");
|
||||
assert_eq!(app.session.active_index(), Some(1), "{kind:?}: the floated document is active");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_overlay_is_circular_and_respects_visibility() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -6687,6 +6830,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_anchor_is_the_pointer_over_the_image_and_the_nearest_image_point_off_it() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
for (rotation, flip) in [(0.0, false), (30.0, false), (90.0, true)] {
|
||||
let xf = ViewXform { rect, zoom: 0.5, center: [400.0, 300.0], flip, rotation, aspect: 1.0 };
|
||||
let inside = xf.to_screen(100.0, 200.0);
|
||||
assert_eq!(wheel_anchor(&xf, inside, [800, 600]), inside);
|
||||
for (p, want) in [((-300.0, -200.0), [0.0, 0.0]), ((1200.0, 300.0), [800.0, 300.0]), ((400.0, 900.0), [400.0, 600.0])] {
|
||||
let a = xf.to_doc(wheel_anchor(&xf, xf.to_screen(p.0, p.1), [800, 600]));
|
||||
assert!((a[0] - want[0]).abs() < 0.01 && (a[1] - want[1]).abs() < 0.01, "rot {rotation}: {p:?} -> {a:?}");
|
||||
}
|
||||
}
|
||||
let p = pos2(5.0, 5.0);
|
||||
let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
assert_eq!(wheel_anchor(&xf, p, [0, 0]), p, "no document: the pointer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_about_centres_the_clicked_point_with_the_preference() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
|
||||
@@ -174,7 +174,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
|
||||
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
// Built-in labels only: alpha/spot names and "{layer} Mask" stay verbatim.
|
||||
let label = match row {
|
||||
Row::Composite | Row::Color(_) | Row::QuickMask => tl!(&name).to_owned(),
|
||||
Row::Alpha(_) | Row::LayerMask => name.clone(),
|
||||
};
|
||||
job.append(&label, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
let galley = painter.layout_job(job);
|
||||
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
painter.galley(text_pos, galley, t.text);
|
||||
|
||||
@@ -540,7 +540,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let mut inv = flag(f, "invert");
|
||||
if widgets::checkbox(ui, &mut inv, tl!("Invert")).changed() {
|
||||
// Selection invert (≠ Image › Adjustments › Invert → Đảo màu).
|
||||
if widgets::checkbox(ui, &mut inv, crate::i18n::tr_ctx(crate::i18n::current(), "colorRange", "Invert")).changed() {
|
||||
f.insert("invert".into(), json!(inv));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -820,4 +820,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1919: with Delete Cropped Pixels off, a second crop's handle dragged out toward the hidden
|
||||
/// pixels snaps exactly onto their edge (a layer edge), however far from the handle's centre
|
||||
/// the press grabbed it, in both modes, with and without Auto Center Preview: the committed
|
||||
/// crop has no transparent strip.
|
||||
#[test]
|
||||
fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() {
|
||||
// The image 200 × 100 cropped to [40, 20, 140, 70]: its pixels stay at [-40, -20, 160, 80].
|
||||
let hidden = [-40.0, -20.0, 160.0, 80.0];
|
||||
for classic in [false, true] {
|
||||
for auto in [true, false] {
|
||||
for (hx, hy) in [(1i8, 0i8), (0, -1), (-1, -1), (1, 1)] {
|
||||
// A corner also with the image's 2:1 ratio held, which both hidden corners keep.
|
||||
for (grab, ratio) in [(-4.0f32, false), (0.0, false), (4.0, false), (-4.0, true), (4.0, true)] {
|
||||
if ratio && (hx == 0 || hy == 0) {
|
||||
continue;
|
||||
}
|
||||
let what = format!("Classic Mode {classic}, Auto Center {auto}, handle ({hx}, {hy}), grabbed {grab} px off, ratio {ratio}");
|
||||
let mut app = app();
|
||||
app.ui.tool_options.crop_delete = false;
|
||||
app.ui.tool_options.crop_shield.classic_mode = classic;
|
||||
app.ui.tool_options.crop_shield.auto_center_preview = auto;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
app.ui.crop_rect = Some([40.0, 20.0, 140.0, 70.0]);
|
||||
app.crop.default_frame = false;
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
let v = &mut app.ui.views[0];
|
||||
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0);
|
||||
app.ui.extras.snap = true;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0]));
|
||||
if ratio {
|
||||
app.ui.tool_options.crop_ratio = "original".into();
|
||||
}
|
||||
let grab = vec2(f32::from(hx), f32::from(hy)) * grab;
|
||||
let from = xf(&app).to_screen(50.0 + f32::from(hx) * 50.0, 25.0 + f32::from(hy) * 25.0) + grab;
|
||||
// Toward the hidden corner (or edge), a screen pixel at a time on the longer axis.
|
||||
let gap = vec2(f32::from(hx) * if hx < 0 { 40.0 } else { 60.0 }, f32::from(hy) * if hy < 0 { 20.0 } else { 30.0 });
|
||||
let dir = gap / gap.x.abs().max(gap.y.abs());
|
||||
let d = xf(&app).to_doc(from);
|
||||
tool_event(&mut app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, NONE);
|
||||
// Drag outward a screen pixel at a time; let go once the moving edges show
|
||||
// the snap to the hidden pixels' edges (as the user would).
|
||||
let snapped = |app: &PhotocraftApp| {
|
||||
let on = |vertical: bool, pos: f64| {
|
||||
app.prefs_rt
|
||||
.snap_lines
|
||||
.iter()
|
||||
.any(|l| l.vertical == vertical && l.pos == pos && l.kind == photocraft_engine::snap::SnapKind::LayerEdge)
|
||||
};
|
||||
(hx == 0 || on(true, if hx < 0 { hidden[0] } else { hidden[2] }))
|
||||
&& (hy == 0 || on(false, if hy < 0 { hidden[1] } else { hidden[3] }))
|
||||
};
|
||||
let mut end = None;
|
||||
for i in 1..=80 {
|
||||
let d = xf(&app).to_doc(from + dir * i as f32);
|
||||
tool_event(&mut app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, NONE);
|
||||
if snapped(&app) {
|
||||
end = Some(from + dir * i as f32);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let end = end.unwrap_or_else(|| panic!("{what}: never snapped to the hidden edge"));
|
||||
// Released where it stopped, read through the view as it is now.
|
||||
let end = xf(&app).to_doc(end);
|
||||
tool_event(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, NONE);
|
||||
let pick = |h: i8, lo: f64, hi: f64, a: f64, b: f64| match h {
|
||||
-1 => [a, hi],
|
||||
1 => [lo, b],
|
||||
_ => [lo, hi],
|
||||
};
|
||||
let [x0, x1] = pick(hx, 0.0, 100.0, hidden[0], hidden[2]);
|
||||
let [y0, y1] = pick(hy, 0.0, 50.0, hidden[1], hidden[3]);
|
||||
assert_eq!(app.ui.crop_rect, Some([x0, y0, x1, y1]), "{what}: the frame lands exactly on the hidden edge");
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), ((x1 - x0) as u32, (y1 - y0) as u32), "{what}");
|
||||
let layer = st.doc.walk().into_iter().map(|(_, _, l)| l.id).next().unwrap();
|
||||
let r = photocraft_engine::snap::layer_rect(&st.doc, layer).unwrap();
|
||||
assert_eq!(r, [hidden[0] - x0, hidden[1] - y0, hidden[2] - x0, hidden[3] - y0], "{what}: no transparent strip");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,16 @@
|
||||
//! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin.
|
||||
|
||||
use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType};
|
||||
use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
|
||||
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack,
|
||||
/// whatever its (localized) name ([`Document::background`]).
|
||||
pub fn is_background(doc: &Document, l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
|
||||
doc.is_background(l.id)
|
||||
}
|
||||
|
||||
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
|
||||
|
||||
@@ -765,6 +765,10 @@ pub fn restore(app: &mut PhotocraftApp) {
|
||||
}
|
||||
let saved = app.session.prefs().panel_layout.clone();
|
||||
apply(app, &saved);
|
||||
// The floating Brush Settings window is transient, not a dock panel: remembering the
|
||||
// workspace must not leave it open on a fresh launch, especially with no document.
|
||||
// Explicit workspace switches still use `apply` and keep their stored panel visibility.
|
||||
app.ui.panels.brush_settings = false;
|
||||
if let Some(ws) = saved.get("workspace").and_then(Value::as_str) {
|
||||
app.ui.workspace = ws.to_string();
|
||||
}
|
||||
|
||||
@@ -1082,3 +1082,31 @@ fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() {
|
||||
drag(&mut h, paths.center(), paths.center() + vec2(0.0, 500.0));
|
||||
assert_eq!(h.state().ui.dock.order(), order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() {
|
||||
let mut first = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
first.ui.panels.brush_settings = true;
|
||||
first.ui.panels.history = true;
|
||||
let saved = snapshot(&first);
|
||||
first.session.prefs.edit(|p| {
|
||||
p.workspace.remember_workspace_changes = true;
|
||||
p.panel_layout = saved.clone();
|
||||
});
|
||||
let persisted = first.session.prefs_to_json();
|
||||
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.load_prefs_json(&persisted).unwrap();
|
||||
let mut next = PhotocraftApp::new(session, crate::Services::default());
|
||||
restore(&mut next);
|
||||
assert!(!next.ui.panels.brush_settings, "a floating Brush Settings window should not reopen on startup");
|
||||
assert!(next.ui.panels.history, "remembered dock panel visibility is preserved");
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
crate::menus::invoke(&mut next, &ctx, "window.toggle.brushSettings", json!({})).unwrap();
|
||||
assert!(next.ui.panels.brush_settings, "Window > Brush Settings can still open it");
|
||||
|
||||
let mut workspace = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
apply(&mut workspace, &saved);
|
||||
assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout");
|
||||
}
|
||||
@@ -21,12 +21,14 @@ use serde_json::{Value, json};
|
||||
use crate::PhotocraftApp;
|
||||
use crate::file_open::display_name;
|
||||
|
||||
/// What a file dialog asks the user for.
|
||||
/// What a file or folder dialog asks the user for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileDialogRequest {
|
||||
/// Files to read: several for File › Open, one for a command that reads a file (Place,
|
||||
/// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3).
|
||||
Open { multiple: bool, initial_dir: Option<String>, extensions: Option<Vec<String>> },
|
||||
/// Which folder to read from. Starts in `initial_dir` when available.
|
||||
Folder { initial_dir: Option<String> },
|
||||
/// Where to write, starting from `suggested` (a file name, or the document's own path).
|
||||
Save { suggested: String },
|
||||
}
|
||||
@@ -42,6 +44,8 @@ pub enum FileDialogAnswer {
|
||||
ContentsMany(Vec<(String, Vec<u8>)>),
|
||||
/// The path to save to (desktop), or the download's name (web).
|
||||
SaveTo(String),
|
||||
/// The selected folder's path (desktop).
|
||||
FolderTo(String),
|
||||
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message
|
||||
/// says what to install. Reported as an error rather than taken for Cancel.
|
||||
Unavailable(String),
|
||||
@@ -88,7 +92,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
|
||||
/// Show a file dialog; `then` continues with the user's choice on a later frame (see the
|
||||
/// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a
|
||||
/// module docs). Returns at once with `{"fileDialog": "open" | "folder" | "save"}`; `Err` without a
|
||||
/// dialog service ("cancelled", as before there were async dialogs) or while one is open.
|
||||
pub(crate) fn ask_file(
|
||||
&mut self,
|
||||
@@ -103,6 +107,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
let kind = match request {
|
||||
FileDialogRequest::Open { .. } => "open",
|
||||
FileDialogRequest::Folder { .. } => "folder",
|
||||
FileDialogRequest::Save { .. } => "save",
|
||||
};
|
||||
let then: Then = Box::new(move |app, answer| match answer {
|
||||
@@ -114,6 +119,16 @@ impl PhotocraftApp {
|
||||
Ok(json!({ "fileDialog": kind }))
|
||||
}
|
||||
|
||||
/// Ask the user to choose a folder, starting in the folder of the most recent file when known.
|
||||
/// `then` receives the selected path when the dialog is answered.
|
||||
pub(crate) fn ask_folder(&mut self, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
|
||||
let initial_dir = last_used_dir(&self.ui.recent_files);
|
||||
self.ask_file(FileDialogRequest::Folder { initial_dir }, move |app, answer| match answer {
|
||||
FileDialogAnswer::FolderTo(path) => then(app, path),
|
||||
_ => Err(UNEXPECTED.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask where to save: `then` gets the chosen path, with a lowercase extension when
|
||||
/// Preferences ▸ File Handling › Lowercase Extension is on (its default).
|
||||
///
|
||||
@@ -193,6 +208,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]),
|
||||
FileDialogAnswer::ContentsMany(files) => app.open_contents(files),
|
||||
FileDialogAnswer::FolderTo(_) => return Err(UNEXPECTED.into()),
|
||||
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => return Err(UNEXPECTED.into()),
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -290,6 +306,7 @@ fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec<u8>), String> {
|
||||
let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?;
|
||||
Ok((path, bytes))
|
||||
}
|
||||
FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()),
|
||||
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,30 @@ fn open_starts_in_the_last_used_folder() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_folder_uses_the_async_dialog_and_returns_the_selected_path() {
|
||||
let (mut app, open, _) = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.ui.recent_files = vec!["/pics/cat.psd".into()];
|
||||
let selected = Rc::new(RefCell::new(None));
|
||||
let result = selected.clone();
|
||||
|
||||
assert_eq!(
|
||||
app.ask_folder(move |_, path| {
|
||||
*result.borrow_mut() = Some(path);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap(),
|
||||
json!({"fileDialog": "folder"})
|
||||
);
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { initial_dir: Some(dir) }, _)] if dir == "/pics"));
|
||||
|
||||
answer(&open, Some(FileDialogAnswer::FolderTo("/pics/frames".into())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert_eq!(selected.borrow().as_deref(), Some("/pics/frames"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_picker_requests_only_the_given_extensions() {
|
||||
let (mut app, open, _) = app();
|
||||
|
||||
@@ -616,7 +616,8 @@ fn views_and_windows_stay_with_their_documents() {
|
||||
for (i, v) in app.ui.views.iter_mut().enumerate() {
|
||||
v.zoom = i as f32 + 1.0;
|
||||
}
|
||||
app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).collect();
|
||||
app.ui.windows =
|
||||
(0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect();
|
||||
let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::<Vec<_>>();
|
||||
let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::<Vec<_>>();
|
||||
assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3}));
|
||||
|
||||
@@ -1093,12 +1093,12 @@ mod tests {
|
||||
assert!(!has_dialog("filter.stylize.findEdges"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_covers_generated_filter_options_and_gallery_names() {
|
||||
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
|
||||
/// Generated filter and adjustment labels (param names, symbolic choices) and gallery
|
||||
/// names that `lang` has no catalog row for.
|
||||
fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet<String> {
|
||||
let mut missing = std::collections::BTreeSet::new();
|
||||
let mut check = |s: String| {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
|
||||
missing.insert(s);
|
||||
}
|
||||
};
|
||||
@@ -1125,9 +1125,21 @@ mod tests {
|
||||
for cat in photocraft_algo::GALLERY_CATEGORIES {
|
||||
check(cat.to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap());
|
||||
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italian_covers_generated_filter_options_and_gallery_names() {
|
||||
let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap());
|
||||
assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their
|
||||
/// params, choices and title in every UI language, not just Korean.
|
||||
#[test]
|
||||
|
||||
+1359
-67
File diff suppressed because it is too large.
Load diff
@@ -10,9 +10,17 @@ fn lang(code: &str) -> Lang {
|
||||
|
||||
#[test]
|
||||
fn requested_languages_resolve_regional_preferences() {
|
||||
for (tag, code) in
|
||||
[("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr"), ("id-ID", "id")]
|
||||
{
|
||||
for (tag, code) in [
|
||||
("en-US", "en"),
|
||||
("zh-CN", "zh-hans"),
|
||||
("ja-JP", "ja"),
|
||||
("ko-KR", "ko"),
|
||||
("ru-RU", "ru"),
|
||||
("uk-UA", "uk"),
|
||||
("fr-CA", "fr"),
|
||||
("id-ID", "id"),
|
||||
("vi-VN", "vi"),
|
||||
] {
|
||||
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
|
||||
}
|
||||
assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko")));
|
||||
|
||||
@@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 17] = [
|
||||
pub static LANGUAGES: [LangInfo; 18] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 17] = [
|
||||
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
|
||||
LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese; `vi-VN` resolves here.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -379,6 +381,7 @@ mod tests {
|
||||
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
|
||||
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
|
||||
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
|
||||
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
|
||||
|
||||
#[test]
|
||||
fn indonesian_tags_resolve() {
|
||||
@@ -388,6 +391,17 @@ mod tests {
|
||||
assert_eq!(ID().name(), "Bahasa Indonesia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vietnamese_tags_resolve() {
|
||||
for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}");
|
||||
}
|
||||
assert_eq!(VI().name(), "Tiếng Việt");
|
||||
assert!(VI().complete_menus(), "Vietnamese translates every source key");
|
||||
assert_eq!(Lang::from_pref("vi"), VI());
|
||||
assert_eq!(Lang::from_pref("VI"), VI());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -707,3 +707,62 @@ fn escape_with_targa_options_open_does_not_deselect() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options");
|
||||
}
|
||||
|
||||
/// How much of the 400 × 300 document is on screen: the visible document span on each axis,
|
||||
/// clipped to the image (0 when the image has left the view on that axis).
|
||||
fn visible_image(h: &Harness<'static, PhotocraftApp>) -> [f32; 2] {
|
||||
let r = h.state().last_canvas_rect;
|
||||
let (a, b) = (doc_at(h, r.min), doc_at(h, r.max));
|
||||
[(b[0].min(400.0) - a[0].max(0.0)).max(0.0), (b[1].min(300.0) - a[1].max(0.0)).max(0.0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_over_the_pasteboard_keeps_the_image_in_view() {
|
||||
// #3720: zooming in with the pointer on the gray pasteboard anchored on the pointer, so a few
|
||||
// notches pushed the image out of the window. The anchor is now the nearest point of the image.
|
||||
for (zoom_with_wheel, m) in [(true, Modifiers::NONE), (false, Modifiers::ALT)] {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel);
|
||||
let r = h.state().last_canvas_rect;
|
||||
let p = pos2(r.left() + 20.0, r.top() + 20.0);
|
||||
let d = doc_at(&h, p);
|
||||
assert!(d[0] < 0.0 && d[1] < 0.0, "the pointer starts on the pasteboard: {d:?}");
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let z0 = zoom(&h);
|
||||
for _ in 0..20 {
|
||||
wheel(&h, 1.0, m);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.run_steps(20);
|
||||
assert!(zoom(&h) > z0 * 5.0, "the notches zoomed in: {z0} -> {}", zoom(&h));
|
||||
let v = visible_image(&h);
|
||||
assert!(v[0] > 0.0 && v[1] > 0.0, "zoom_with_wheel {zoom_with_wheel}: the image left the view {v:?}");
|
||||
// The image's corner nearest the pointer is the anchor: it stays where it was on screen.
|
||||
let c = doc_at(&h, pos2(r.left() + 20.0, r.top() + 20.0));
|
||||
assert!(c[0] < 0.0 && c[1] < 0.0, "still pasteboard under the pointer, the corner held: {c:?}");
|
||||
// A pinch from there behaves the same.
|
||||
h.event(egui::Event::Zoom(4.0));
|
||||
h.run_steps(3);
|
||||
let v = visible_image(&h);
|
||||
assert!(v[0] > 0.0 && v[1] > 0.0, "pinch: the image left the view {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() {
|
||||
let mut h = harness();
|
||||
let r = h.state().last_canvas_rect;
|
||||
let p = pos2(r.center().x - 150.0, r.center().y - 100.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let d0 = doc_at(&h, p);
|
||||
assert!(d0[0] > 0.0 && d0[1] > 0.0);
|
||||
for _ in 0..5 {
|
||||
wheel(&h, 1.0, Modifiers::ALT);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.run_steps(20);
|
||||
let d1 = doc_at(&h, p);
|
||||
assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}");
|
||||
}
|
||||
@@ -383,6 +383,32 @@ fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() {
|
||||
assert!(sel.sample_channel(120, 110, 0) < 0.01 && sel.sample_channel(90, 110, 0) > 0.99, "subtracted, not centred");
|
||||
}
|
||||
|
||||
/// #208: `⇧⌥` held at the press intersects, the third selection mode after add (`⇧`) and subtract
|
||||
/// (`⌥`). Like those, the modifier at the press picks the mode; here the keys are released once the
|
||||
/// press has fixed the mode, so the drag stays a plain rectangle and only the overlap of the
|
||||
/// existing selection and the dragged rectangle survives. The three sample points below are each in
|
||||
/// exactly one of {overlap, first-only, drag-only}, so they pass only for intersect, not for
|
||||
/// replace, add or subtract.
|
||||
#[test]
|
||||
fn shift_alt_before_the_drag_intersects_the_selection() {
|
||||
let mut h = harness(Tool::RectMarquee);
|
||||
// A: the existing selection, 50..200 on both axes.
|
||||
h.state_mut().run("select.rect", json!({"x": 50, "y": 50, "width": 150, "height": 150})).unwrap();
|
||||
let both = Modifiers::SHIFT | Modifiers::ALT;
|
||||
mods(&mut h, both);
|
||||
press_at(&mut h, 120.0, 120.0, both);
|
||||
// Mode is fixed at the press; releasing the keys keeps intersect mode but leaves the drag a
|
||||
// plain rectangle (no square/centre constraint), so B is roughly 120..300 on both axes.
|
||||
mods(&mut h, Modifiers::NONE);
|
||||
move_to(&mut h, 300.0, 300.0);
|
||||
release_at(&mut h, 300.0, 300.0, Modifiers::NONE);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sel = st.doc.selection.as_ref().expect("a selection survives the intersect");
|
||||
assert!(sel.sample_channel(160, 160, 0) > 0.99, "the overlap of the two rectangles stays selected");
|
||||
assert!(sel.sample_channel(60, 60, 0) < 0.01, "a point only in the existing selection is dropped (not add)");
|
||||
assert!(sel.sample_channel(260, 260, 0) < 0.01, "a point only in the dragged rectangle is dropped (not replace)");
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-ui-egui marquee_drag_bench -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
||||
@@ -11,6 +11,52 @@ const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.
|
||||
/// The tallest menus.
|
||||
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
|
||||
|
||||
/// #3737: on Wayland egui knows the monitor's size but never where the window is (no
|
||||
/// `inner_rect`). A window taller than the monitor less its taskbar or panel runs under it, so
|
||||
/// Layer, View and Window (and every other long menu) must end above it and scroll to their last
|
||||
/// row, at 1× and 1.5×.
|
||||
#[test]
|
||||
fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() {
|
||||
for scale in [1.0, 1.5] {
|
||||
for top in ["Layer", "View", "Window", "Filter", "Image", "Edit"] {
|
||||
let mut h = harness((1366.0 * scale, 740.0 * scale, scale));
|
||||
let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default();
|
||||
v.monitor_size = Some(egui::vec2(1366.0, 768.0));
|
||||
v.inner_rect = None;
|
||||
v.outer_rect = None;
|
||||
v.maximized = Some(false);
|
||||
h.run_steps(3);
|
||||
let bottom = 768.0 - crate::work_area::TASKBAR;
|
||||
let what = format!("{scale}x {top}");
|
||||
open(&mut h, top);
|
||||
for r in popups(&h) {
|
||||
assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})");
|
||||
}
|
||||
check_bar_clear(&h, &what);
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
let in_view = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5
|
||||
};
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(view.center());
|
||||
for _ in 0..80 {
|
||||
if in_view(&h) {
|
||||
break;
|
||||
}
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(in_view(&h), "{what}: the wheel reaches the last row, above the panel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
|
||||
@@ -292,6 +292,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
app.open_dialog_file()
|
||||
}
|
||||
}
|
||||
"file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => {
|
||||
app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string()))
|
||||
}
|
||||
"file.save" => {
|
||||
// Writes back only to a layered file; a flat one goes through Save As.
|
||||
let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?;
|
||||
@@ -340,7 +343,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
let doc = app.session.active_index().ok_or("no document")?;
|
||||
let wid = app.ui.alloc_id();
|
||||
let view = app.ui.views[doc].clone();
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
|
||||
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
|
||||
Ok(json!({"window": wid}))
|
||||
}
|
||||
"window.theme.toggle" => {
|
||||
|
||||
@@ -182,3 +182,31 @@ fn every_theme_shows_the_type_options() {
|
||||
assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_theme_shows_the_gradient_options() {
|
||||
// #3601: the Studio themes' options bar was only the hint "Drag to draw a gradient", with no
|
||||
// mode, style, blend mode or opacity control.
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
for theme in crate::theme::ThemeKind::ALL {
|
||||
let (mut app, _, _) = app();
|
||||
app.ui.tool = Tool::Gradient;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::options_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.run_steps(4);
|
||||
let combos: Vec<String> = h
|
||||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||||
.map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value()))
|
||||
.collect();
|
||||
assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user