Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffd811d679 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 04:13:51 -04:00
135 changed files with 600 additions and 10122 deletions

No files matched your search

-8
View File
@@ -257,14 +257,6 @@ 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 }
-1
View File
@@ -19,7 +19,6 @@ 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
+29 -30
View File
@@ -4182,7 +4182,7 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"arboard",
"async-io",
@@ -4224,7 +4224,7 @@ dependencies = [
[[package]]
name = "photocraft-affinity"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4234,7 +4234,7 @@ dependencies = [
[[package]]
name = "photocraft-algo"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4247,7 +4247,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"base64",
"cap-std",
@@ -4269,7 +4269,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"moxcms",
"rayon",
@@ -4291,7 +4291,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"exr",
"flate2",
@@ -4312,7 +4312,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -4320,7 +4320,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"blake3",
"exr",
@@ -4380,10 +4380,9 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"blake3",
"crc32fast",
"flate2",
"photocraft-codecs",
"photocraft-color",
@@ -4400,14 +4399,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"half",
"log",
@@ -4424,14 +4423,14 @@ dependencies = [
[[package]]
name = "photocraft-heif"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"blake3",
"crc32fast",
@@ -4460,14 +4459,14 @@ dependencies = [
[[package]]
name = "photocraft-ops"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4478,7 +4477,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4493,7 +4492,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -4504,7 +4503,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -4514,7 +4513,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"flate2",
"image",
@@ -4529,7 +4528,7 @@ dependencies = [
[[package]]
name = "photocraft-tablet"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
@@ -4541,7 +4540,7 @@ dependencies = [
[[package]]
name = "photocraft-testkit"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"memory-stats",
"photocraft-color",
@@ -4552,7 +4551,7 @@ dependencies = [
[[package]]
name = "photocraft-text"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"flate2",
"parley",
@@ -4566,7 +4565,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"base64",
"craft-ui",
@@ -4605,7 +4604,7 @@ dependencies = [
[[package]]
name = "photocraft-vector"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -4616,7 +4615,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"eframe",
"egui",
@@ -7449,7 +7448,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.7.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-2
View File
@@ -331,8 +331,6 @@ 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
-3
View File
@@ -311,9 +311,6 @@ 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()
-44
View File
@@ -203,50 +203,6 @@ 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();
+19 -16
View File
@@ -305,22 +305,27 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if let Some(error) = self.presets.error() {
let error = self.presets.error();
if error.is_some() || self.presets.unsaved() {
egui::Panel::top("browser_preset_storage").show(ui, |ui| {
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.")
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.")
});
} else {
i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.")
});
ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes."));
}
});
}
self.app.ui(ui, frame);
@@ -377,8 +382,6 @@ 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());
-9
View File
@@ -644,13 +644,4 @@ 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));
}
}
-7
View File
@@ -115,13 +115,6 @@ 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);
-8
View File
@@ -104,16 +104,8 @@ 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
-16
View File
@@ -59,22 +59,6 @@ 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;
+56 -196
View File
@@ -154,7 +154,7 @@ impl LiquifyStroke {
}
/// The displacement field plus the freeze mask, over `bounds` (document pixels).
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct LiquifyField {
pub bounds: Rect,
/// Document pixels per field node.
@@ -165,38 +165,6 @@ 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.
@@ -209,7 +177,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], cursor: None }
LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] }
}
/// Document position of node (i, j) (node 0 sits on the first pixel centre).
@@ -252,7 +220,6 @@ 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 => {
@@ -339,120 +306,77 @@ 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 {
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 }
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 }
}
/// 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`.
/// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary
/// tool held still dabs once at `b`).
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 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);
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len == 0.0 {
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;
return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty };
}
let r = (s.size / 2.0).max(0.5);
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 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 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 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)> {
/// 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)> {
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 (mut u0, mut u1) = (0.0f64, 1.0f64);
let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 });
let (mut k0, mut k1) = (1.0f64, n);
for ax in 0..2 {
let d = dir[ax];
let d = b[ax] - a[ax];
if d == 0.0 {
if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] {
return None;
}
continue;
}
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));
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);
}
if u0.is_nan() || u1.is_nan() || u0 > u1 {
// NaN: a segment whose length overflowed.
if k0.is_nan() || k1.is_nan() || k0 > k1 {
return None;
}
Some((u0 * len, u1 * len))
Some((k0 as usize, k1 as usize))
}
/// 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 {
/// 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 {
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 * weight;
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
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 {
@@ -997,9 +921,21 @@ mod tests {
use super::*;
use photocraft_color::{ColorMode, PixelFormat, SampleType};
/// The segment walk before #937: every dab, whether or not it can reach the field.
/// The segment walk before #937: every step, whether or not its dab can reach the field.
fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect {
f.walk_segment(s, a, b, false)
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
}
#[test]
@@ -1009,7 +945,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]); 10] = [
let segments: [([f64; 3], [f64; 3]); 9] = [
([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
@@ -1019,7 +955,6 @@ 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] {
@@ -1038,81 +973,6 @@ 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
@@ -1250,8 +1110,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 twentieth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 1.0);
// A drag of one dab spacing (a fifth of the radius): one dab, centred where it started.
let (r, drag) = (20.0, 4.0);
for density in [0.0, 50.0, 100.0] {
let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r);
s.density = density;
-99
View File
@@ -343,102 +343,3 @@ 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();
}
-184
View File
@@ -1,184 +0,0 @@
//! 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();
}
+3 -3
View File
@@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool {
!layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer))
}
/// 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`]).
/// 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).
pub fn is_background(l: &Layer) -> bool {
l.has_background_traits()
l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_))
}
/// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background
-23
View File
@@ -437,26 +437,3 @@ 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");
}
}
+30 -139
View File
@@ -1114,24 +1114,27 @@ 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 {
// 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 src = if inner {
match g.source {
GlowSource::Edge => shape.clone().map(|a| 1.0 - a),
GlowSource::Center => shape.clone(),
}
} else {
shape.clone()
};
let mut m = match g.technique {
GlowTechnique::Precise => {
// 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.
// 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).
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
let solid = g.size * g.spread;
let soft = (g.size + 1.0 - solid).max(1e-3);
let soft = (g.size - 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) {
let d = d + 0.5;
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
*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);
}
m
}
@@ -1139,13 +1142,16 @@ 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) {
@@ -1455,9 +1461,7 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelGeom {
paint,
width,
// 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,
depth: b.depth * if smooth { width } else { size } * sign,
pillow: b.style == BevelStyle::PillowEmboss,
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
}
@@ -1478,13 +1482,10 @@ 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], soft);
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]);
}
// 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 {
if g.chisel_soft > 0.0 {
tent(&mut h, g.chisel_soft);
}
h
@@ -1526,46 +1527,17 @@ pub struct TextureCtx<'a> {
}
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
///
/// 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;
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 {
match paint {
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 {
(size + 2.0) / ramp
}
}
BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0),
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)
0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0)
} else {
0.0
}
}
BevelPaint::Inner => {
if din > 0.0 {
((din + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.0
0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0)
}
}
BevelPaint::Inner => (din / size).clamp(0.0, 1.0),
}
}
@@ -1583,9 +1555,7 @@ 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).
// 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 reach_past = g.paint == BevelPaint::Both;
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))
};
@@ -2370,40 +2340,6 @@ 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);
@@ -2947,51 +2883,6 @@ 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);
-50
View File
@@ -636,13 +636,6 @@ 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(),
@@ -1047,25 +1040,6 @@ 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)) })
@@ -1231,30 +1205,6 @@ 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();
-40
View File
@@ -1224,44 +1224,4 @@ 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");
}
}
-1
View File
@@ -428,7 +428,6 @@ 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];
+1 -1
View File
@@ -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| d.is_background(l.id));
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position);
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| {
+8 -7
View File
@@ -10,13 +10,14 @@ 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.background().map(|l| l.id)
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
}
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
@@ -154,7 +155,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!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}");
assert!(is_background(l) && 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);
@@ -169,10 +170,10 @@ mod tests {
assert!(s.undo());
assert_eq!(order(&s), [l0, a, b], "{depth}");
let l = doc(&s).layer(a).unwrap();
assert!(!doc(&s).is_background(l.id) && l.name == "A");
assert!(!is_background(l) && l.name == "A");
assert!(s.redo());
assert_eq!(order(&s), [a, l0, b], "{depth}");
assert!(doc(&s).is_background(doc(&s).layers[0].id));
assert!(is_background(&doc(&s).layers[0]));
}
}
@@ -190,7 +191,7 @@ mod tests {
assert_eq!(d.layers[1].id, l0);
assert_eq!(d.layers[2].id, a);
let bg = &d.layers[0];
assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept");
assert!(is_background(bg) && !bg.visible, "visibility is kept");
assert_eq!(bg.opacity, 1.0);
}
@@ -210,7 +211,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).background().is_none());
assert!(!doc(&s).layers.iter().any(is_background));
// 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();
@@ -219,7 +220,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).background().is_none());
assert!(!doc(&s).layers.iter().any(is_background));
}
#[test]
+28 -23
View File
@@ -415,9 +415,8 @@ pub struct LiveStroke {
symmetry: Option<crate::symmetry_cmds::PaintingSymmetry>,
mirrors: Vec<StrokeRenderer>,
mirror_distinct: Vec<bool>,
/// 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)>,
/// Restore passes separately; their union may span an otherwise untouched document.
symmetry_regions: Vec<Rect>,
pre: Surface,
sel: Option<Surface>,
lock: bool,
@@ -509,7 +508,7 @@ impl LiveStroke {
symmetry,
mirrors,
mirror_distinct,
union_live: None,
symmetry_regions: Vec::new(),
pre,
sel,
lock,
@@ -568,27 +567,33 @@ 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) {
// The stroke and its distinct mirrors composite as one stroke, as the commit
// merges them; only the tiles that changed are redrawn.
// Merge every finished pass once, both here and on commit, including shared axes.
let old = self.tail;
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))
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
})
.collect();
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 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));
}
let mut dmg = Rect::EMPTY;
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
+1 -2
View File
@@ -608,8 +608,7 @@ 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.
// Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`).
routed(id) || id == "select.float" || id == "layer.translate"
routed(id) || id == "select.float"
}
/// Commands whose target follows the Channels panel.
+2 -2
View File
@@ -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 && !d.doc.is_background(l.id) {
if locks.transparency && !crate::extra_cmds::is_background(l) {
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!(d.doc.is_background(d.active_layer.unwrap()));
assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap()));
}
#[test]
+2 -2
View File
@@ -878,7 +878,7 @@ fn build() -> Vec<CommandSpec> {
"Move Layer",
[],
None,
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)"##,
r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##,
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 = doc.is_background(id);
let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id);
let mut dup = src.duplicate();
dup.name = doc.copy_name(&dup.name);
if from_background {
+12 -4
View File
@@ -213,6 +213,15 @@ 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) {
@@ -238,15 +247,14 @@ 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)")));
+6 -7
View File
@@ -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 d.doc.is_background(*id) {
if crate::extra_cmds::is_background(&l) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
@@ -277,8 +277,7 @@ 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<()> {
doc.layer(id).ok_or(EngineError::NoLayer(id))?;
let bg = doc.is_background(id);
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
if bg {
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
@@ -462,7 +461,7 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
}
let lowest = visible[0];
let base = doc.layers[lowest].clone();
let is_background = lowest == 0 && doc.is_background(base.id);
let is_background = base.name == "Background" && base.locks.transparency;
// Composite of visible layers only (hidden ones stay where they are).
let mut solo = doc.clone();
solo.layers.retain(|l| l.visible);
@@ -1364,7 +1363,7 @@ mod tests {
let mut s = fresh();
s.execute(cmd, json!({})).unwrap();
let d = &s.active().unwrap().doc;
assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background");
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{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");
}
@@ -1610,9 +1609,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!(!st.doc.is_background(pasted.id));
assert!(!crate::extra_cmds::is_background(pasted));
assert_eq!(pasted.locks, Default::default());
assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched");
assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched");
}
/// #2428: a smart object copied in one document pastes into another as a smart object, in
+16 -5
View File
@@ -59,6 +59,19 @@ 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)))
@@ -101,9 +114,7 @@ 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();
// 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);
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(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"))?;
@@ -138,15 +149,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 && !d.doc.is_background(id) {
if d.doc.effective_locks(id).transparency && !is_background(l) {
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()))
})?;
+8 -11
View File
@@ -43,9 +43,13 @@ 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.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into())
d.doc.layers.first().filter(|l| is_background(l)).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
@@ -56,7 +60,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| !d.doc.is_background(l.id) && fits(l))).collect();
let ids: Vec<LayerId> = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take this layer style change".into()));
}
@@ -437,7 +441,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.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?;
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
unlock_background(l);
*active = Some(l.id);
Ok(l.id)
@@ -454,14 +458,7 @@ 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) {
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) {
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
unlock_background(l);
}
}
+2 -2
View File
@@ -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 || doc.is_background(layer));
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
// 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 && !doc.is_background(l.id))
locks.all || (locks.position && !crate::extra_cmds::is_background(l))
}
fn float(s: &mut Session, p: &Value) -> Result<Value> {
+1 -1
View File
@@ -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(_)) || doc.is_background(id) {
if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) {
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![]));
+23 -62
View File
@@ -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.background_mut()
&& let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l))
&& let LayerContent::Raster(surf) = &mut bg.content
{
let old = old_canvas.translate(dx, dy);
@@ -315,28 +315,22 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
Ok(())
}
/// 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 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)
}
fn extension_color(s: &Session, p: &Value) -> [f32; 4] {
@@ -363,7 +357,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("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?;
let (ax, ay) = anchor_factors(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);
@@ -379,13 +373,16 @@ 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.background_mut()
&& let Some(bg) = doc.layers.first_mut()
&& crate::extra_cmds::is_background(bg)
{
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.background_mut()
&& let Some(bg) = doc.layers.first_mut()
&& bg.name == "Background"
&& bg.locks.transparency
&& let LayerContent::Raster(surf) = &mut bg.content
{
let fmt = surf.format();
@@ -983,42 +980,6 @@ 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();
+1 -1
View File
@@ -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 sdoc.is_background(*id) {
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
l.name = ddoc.next_layer_name("Layer");
l.locks = Default::default();
}
+8 -88
View File
@@ -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() && !d.doc.is_background(l.id))).collect();
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();
if ids.is_empty() {
return Err(EngineError::Other("none of the selected layers can take a layer mask".into()));
}
@@ -163,9 +163,13 @@ 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)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect();
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect();
if ids.is_empty() {
return Err(EngineError::Other("there are no layers to select".into()));
}
@@ -279,14 +283,7 @@ 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);
@@ -300,27 +297,14 @@ 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 (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() {
let ids = move_targets(doc, &roots);
if ids.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);
@@ -342,11 +326,6 @@ 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);
@@ -354,12 +333,6 @@ pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_targ
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)
@@ -1471,59 +1444,6 @@ 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);
+1 -2
View File
@@ -287,7 +287,6 @@ 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 };
@@ -297,7 +296,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 && background {
if float_bg && l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
+1 -2
View File
@@ -91,9 +91,8 @@ 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 = Some(l.id) == background_id;
let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_));
if background && let Some(surf) = l.surface_mut() {
// The Background stays a Background: rotated pixels over the background colour.
let src = surf.content_bounds();
+5 -7
View File
@@ -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, LayerId, Size};
use photocraft_doc::{Document, Layer, LayerContent, Size};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@@ -154,13 +154,12 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result<
}
/// Maps every layer's pixels and masks through `h` into `clip`.
/// `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<()> {
fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> {
for l in layers {
crate::allocation::checkpoint("a perspective crop")?;
let background = background_id == Some(l.id);
let background = crate::extra_cmds::is_background(l);
match &mut l.content {
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?,
LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?,
LayerContent::Raster(surf) => {
let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?;
*surf = if background {
@@ -189,8 +188,7 @@ 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);
let background_id = doc.background().map(|l| l.id);
warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?;
warp_layers(&mut doc.layers, h, interp, clip, &bg)?;
for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) {
ch.surface = warp_gray(&ch.surface, h, interp, clip)?;
}
+1 -1
View File
@@ -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(_))
|| doc.is_background(l.id)
|| crate::layer_multi_cmds::is_locked_background(l)
|| doc.effective_locks(l.id).all
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
{
+6 -2
View File
@@ -191,9 +191,13 @@ 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 {
// Styles can't sit on the Background: Photoshop turns it into a normal layer first.
crate::extra_cmds::background_to_layer(doc, *id);
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;
}
if add {
l.effects.items.extend(effects.iter().cloned());
} else {
+1 -1
View File
@@ -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.is_background(id) {
if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) {
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)) {
+1 -1
View File
@@ -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 = doc.is_background(l.id);
let background = l.name == "Background" && l.locks.position;
let mut inner = l.clone();
inner.visible = true;
inner.opacity = 1.0;
+1 -2
View File
@@ -32,10 +32,9 @@ 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 Some(l.id) == background_id {
if crate::extra_cmds::is_background(l) {
l.name = "Layer 0".into();
l.locks = Default::default();
}
+15 -12
View File
@@ -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.
fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks {
/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]).
fn transform_locks(l: &Layer, group: Locks) -> Locks {
let mut own = l.locks;
if background {
if own.position && l.name == "Background" {
own.position = false;
own.transparency = false;
}
@@ -217,24 +217,29 @@ fn transform_locks(l: &Layer, group: Locks, background: bool) -> 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), doc.is_background(id))];
while let Some((l, group, background)) = stack.pop() {
let locks = transform_locks(l, group, background);
let mut stack = vec![(l, group_locks(doc, id))];
while let Some((l, group)) = stack.pop() {
let locks = transform_locks(l, group);
if locks.position || locks.all {
return true;
}
if let LayerContent::Group(g) = &l.content {
stack.extend(g.children.iter().map(|c| (c, locks, false)));
stack.extend(g.children.iter().map(|c| (c, locks)));
}
}
false
}
/// `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`]).
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
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, false);
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();
}
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
@@ -488,8 +493,6 @@ 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() {
+2 -42
View File
@@ -121,23 +121,10 @@ 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;
@@ -182,11 +169,6 @@ 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);
@@ -1007,36 +989,14 @@ 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}));
// #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.postscript_name.is_none());
assert!(style.font_style.is_empty());
apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"}));
assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"));
assert!(style.postscript_name.is_none());
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() {
+5 -3
View File
@@ -110,9 +110,12 @@ 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)));
@@ -267,7 +270,6 @@ 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();
-2
View File
@@ -19,8 +19,6 @@ 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"
+20 -68
View File
@@ -3,7 +3,6 @@
//! 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;
@@ -12,9 +11,25 @@ const LOCAL_SIG: u32 = 0x0403_4b50;
const CENTRAL_SIG: u32 = 0x0201_4b50;
const EOCD_SIG: u32 = 0x0605_4b50;
/// CRC-32 (IEEE), as stored in ZIP headers.
/// CRC-32 (IEEE), table-driven.
pub fn crc32(data: &[u8]) -> u32 {
crc32fast::hash(data)
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
}
/// Streaming writer for STORE entries.
@@ -107,12 +122,7 @@ 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> {
@@ -184,16 +194,11 @@ impl<'a> ZipReader<'a> {
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
p = entry_end;
}
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 })
Ok(ZipReader { bytes, entries })
}
pub fn find(&self, name: &str) -> Option<&Entry> {
self.by_name.get(name).and_then(|&i| self.entries.get(i))
self.entries.iter().find(|e| e.name == name)
}
/// Entry payload (decompressed), at most `max` bytes, CRC-checked.
@@ -258,59 +263,6 @@ 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();
+13 -33
View File
@@ -1152,33 +1152,31 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
return mout(acc);
}
// 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.
// Precise glow from a distance map: p0 = (solid, soft, invert).
@fragment
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
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);
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);
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 (1: before the contour, 2: after it,
// as a centre-source inner glow), contour (LUT row 0), × shape (`layer_tex`)).
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v, 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 && op.p0.y < 1.5) { v = 1.0 - v; }
if (op.p0.y > 0.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, Chisel Soft).
// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size).
@fragment
fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
@@ -1186,33 +1184,16 @@ 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: {
if (soft) {
h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0);
} else {
let top = (size + 2.0) / ramp;
h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0);
}
}
case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.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);
h = 0.5 + 0.5 * clamp(din / (size / 2.0), 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);
h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0);
}
}
default: { h = clamp(din / size, 0.0, 1.0); }
}
return mout(h);
}
@@ -1226,8 +1207,7 @@ 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);
// 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) {
if (op.p1.y > 1.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);
+6 -22
View File
@@ -236,19 +236,6 @@ 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);
@@ -332,9 +319,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 + 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);
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);
}
GlowTechnique::Softer => {
// Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha.
@@ -346,7 +333,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_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0);
}
}
1
@@ -376,10 +363,8 @@ 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 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 }
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 }
};
if let Some(c) = &bv.contour {
// Contour element: the height through the contour (`bevel_contour_lut`).
@@ -420,7 +405,6 @@ 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);
+6 -107
View File
@@ -267,10 +267,7 @@ 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. 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.
/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported.
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
let version = be16(d, 0)?;
let reverse = *d.get(2)? != 0;
@@ -288,9 +285,11 @@ 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;
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)?])?));
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)?)));
mids.push(mid);
at += 20;
}
@@ -310,23 +309,6 @@ 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> {
@@ -842,89 +824,6 @@ 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:?}");
-1
View File
@@ -151,7 +151,6 @@ 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;
+13 -70
View File
@@ -15,21 +15,13 @@ 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): 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> {
/// Resource 1077 for `channels` (in file order).
pub(crate) fn display_info(channels: &[&AlphaChannel]) -> 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),
@@ -42,16 +34,15 @@ pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Ve
}
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: 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)) {
/// 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) {
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);
@@ -67,25 +58,9 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan
}
}
/// 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;
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])
}
match space {
// 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 };
@@ -104,23 +79,7 @@ pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option<Color> {
let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0;
Color::rgb(v, v, v)
}
_ => 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],
_ => Color::rgb(n(c[0]), n(c[1]), n(c[2])),
}
}
@@ -138,25 +97,17 @@ 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], false);
// 13 bytes per channel, as every corpus file (Photoshop's included) lays out 1077.
assert_eq!(bytes.len(), 4 + 2 * 13);
let bytes = display_info(&[&a, &s]);
assert_eq!(bytes.len(), 4 + 28);
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
apply_display_info(&bytes, true, 0, &mut back);
apply_display_info(&bytes, true, &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, 0, &mut c);
apply_display_info(&bytes[..10], true, &mut c);
assert_eq!(c[0].opacity, 0.5);
}
@@ -166,13 +117,5 @@ 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)));
}
}
-3
View File
@@ -376,9 +376,6 @@ 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])]))];
+3 -3
View File
@@ -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, 0, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, true, &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, 0, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, false, &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, false)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels)));
}
if let Some(x) = &doc.metadata.xmp {
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
+2 -41
View File
@@ -529,12 +529,6 @@ 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);
@@ -1441,12 +1435,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
resources.push(ImageResource::new(1032, guides_resource(doc)));
}
if !extra.is_empty() {
// 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();
let names: Vec<&str> = 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, has_alpha)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra)));
}
if let Some(id) = quick_mask_id {
let mut data = id.to_be_bytes().to_vec();
@@ -1725,35 +1717,4 @@ 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);
}
}
+3 -19
View File
@@ -702,20 +702,7 @@ 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);
// 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;
}
doc.layers = cx.build(&tree, 0);
// 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)
@@ -821,9 +808,6 @@ 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);
@@ -834,9 +818,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, skip, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, true, &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, skip, &mut doc.channels);
crate::channel_map::apply_display_info(&r.data, false, &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)
-22
View File
@@ -817,28 +817,6 @@ 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 }
}
-100
View File
@@ -179,103 +179,3 @@ 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);
}
}
}
-39
View File
@@ -1,39 +0,0 @@
//! 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());
}
-85
View File
@@ -857,91 +857,6 @@ 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.
+1 -1
View File
@@ -37,7 +37,7 @@ pub use brush::{
SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke};
pub use render::{BrushContext, StrokeRenderer, 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.
-121
View File
@@ -491,13 +491,6 @@ 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) {
@@ -534,21 +527,6 @@ 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 {
@@ -592,105 +570,6 @@ 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, &region);
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();
-56
View File
@@ -42,59 +42,3 @@ 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
-21
View File
@@ -1,21 +0,0 @@
//! 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");
});
-39
View File
@@ -1,39 +0,0 @@
//! 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");
});
@@ -1,22 +0,0 @@
//! 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");
});
-52
View File
@@ -1,52 +0,0 @@
//! 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");
});
-17
View File
@@ -1,17 +0,0 @@
//! 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");
});
-20
View File
@@ -1,20 +0,0 @@
//! 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");
}
});
-24
View File
@@ -1,24 +0,0 @@
//! 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();
}
});
-28
View File
@@ -1,28 +0,0 @@
//! 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();
});
+1 -1
View File
@@ -181,7 +181,7 @@ pub fn write(swatches: &[AcoSwatch]) -> Result<Vec<u8>> {
out.put_u16(x);
}
if version == 2 {
let units = crate::io::utf16_units(&s.name, MAX_NAME_UNITS);
let units: Vec<u16> = s.name.encode_utf16().take(MAX_NAME_UNITS).collect();
// The length counts the terminating null.
out.put_u32(units.len() as u32 + 1);
for u in units {
+1 -4
View File
@@ -24,9 +24,6 @@ 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;
@@ -229,7 +226,7 @@ pub fn parse(data: &[u8]) -> Result<AseFile> {
}
fn put_name(out: &mut Vec<u8>, name: &str) {
let units = crate::io::utf16_units(name, MAX_NAME_UNITS);
let units: Vec<u16> = name.encode_utf16().take(MAX_NAME_UNITS).collect();
out.put_u16(units.len() as u16 + 1);
for u in units {
out.put_u16(u);
+2 -39
View File
@@ -44,10 +44,6 @@ 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)]
@@ -61,15 +57,10 @@ pub enum Id {
impl Id {
/// Convenience constructor: 4-byte strings become [`Id::Code`], others
/// [`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.
/// [`Id::Str`].
pub fn new(s: &str) -> Self {
let b = s.as_bytes();
match b {
[a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]),
_ => Id::Str(b.to_vec()),
}
if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) }
}
/// The identifier's bytes.
pub fn as_bytes(&self) -> &[u8] {
@@ -618,34 +609,6 @@ 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());
+9 -45
View File
@@ -58,9 +58,6 @@ 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),
@@ -69,36 +66,11 @@ fn num(d: &Descriptor, k: &str) -> Option<f64> {
Value::LargeInteger(v) => Some(*v as f64),
_ => None,
}
}
/// 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)
.filter(|v| v.is_finite())
}
fn f(d: &Descriptor, k: &str) -> f32 {
finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0)
num(d, k).unwrap_or(0.0) as f32
}
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),
@@ -118,19 +90,11 @@ 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 {
// 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)),
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),
_ => return None,
})
}
@@ -139,7 +103,7 @@ fn location(d: &Descriptor) -> f32 {
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
}
fn midpoint(d: &Descriptor) -> f32 {
finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
(num(d, "Mdpn").unwrap_or(50.0) as f32 / 100.0).clamp(0.0, 1.0)
}
fn gradient(g: &Descriptor) -> GrdGradient {
@@ -163,7 +127,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), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
out.opacity.push((location(s), (num(s, "Opct").unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0), midpoint(s)));
}
out
}
-17
View File
@@ -2,23 +2,6 @@
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`]).
-399
View File
@@ -148,402 +148,3 @@ 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();
}
}
-917
View File
@@ -1,917 +0,0 @@
//! `.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());
}
+31 -191
View File
@@ -1762,7 +1762,6 @@ 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`).
@@ -1852,10 +1851,18 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
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 => {}
// 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));
}
}
let has_path = st.path.is_some();
resp.context_menu(|ui| {
@@ -1918,7 +1925,6 @@ 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)
{
@@ -1962,70 +1968,6 @@ 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,
@@ -2085,7 +2027,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| !st.doc.is_background(l.id));
let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency));
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();
@@ -2109,7 +2051,6 @@ 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;
@@ -2170,10 +2111,18 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.clicked() {
activate = Some(i);
}
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 => {}
// 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));
}
}
let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some());
resp.context_menu(|ui| {
@@ -2230,7 +2179,6 @@ 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)
{
@@ -2831,9 +2779,7 @@ 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 {
// 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);
zoom_about(&mut view, &xf, p, nz, false, ppp);
}
}
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
@@ -3569,9 +3515,6 @@ 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));
@@ -3614,20 +3557,6 @@ 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;
@@ -3734,21 +3663,9 @@ 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);
@@ -3757,19 +3674,12 @@ 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;
@@ -3777,18 +3687,11 @@ 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 {
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));
}
painter.line_segment([pa + dir * s0, pa + dir * s1], black);
}
t += dash * 2.0;
}
}
if !mesh.is_empty() {
painter.add(egui::Shape::mesh(mesh));
}
}
#[allow(dead_code)]
@@ -5128,10 +5031,7 @@ 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 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 builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]);
let mut view = w.view.clone();
let mut close = false;
ctx.show_viewport_immediate(vid, builder, |ui, _class| {
@@ -5788,49 +5688,6 @@ 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();
@@ -6830,23 +6687,6 @@ 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));
+1 -6
View File
@@ -174,12 +174,7 @@ 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) };
// 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() });
job.append(&name, 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);
+1 -2
View File
@@ -540,8 +540,7 @@ 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");
// Selection invert (≠ Image › Adjustments › Invert → Đảo màu).
if widgets::checkbox(ui, &mut inv, crate::i18n::tr_ctx(crate::i18n::current(), "colorRange", "Invert")).changed() {
if widgets::checkbox(ui, &mut inv, tl!("Invert")).changed() {
f.insert("invert".into(), json!(inv));
}
});
-85
View File
@@ -820,89 +820,4 @@ 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 -4
View File
@@ -3,16 +3,15 @@
//! 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, LayerId, SampleType};
use photocraft_doc::{ColorMode, Document, Layer, LayerContent, 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,
/// whatever its (localized) name ([`Document::background`]).
/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack.
pub fn is_background(doc: &Document, l: &Layer) -> bool {
doc.is_background(l.id)
l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id)
}
/// Does the Properties panel show the Document view? (No layer selected, or the Background layer.)
-4
View File
@@ -765,10 +765,6 @@ 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();
}
-28
View File
@@ -1082,31 +1082,3 @@ 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");
}
+2 -19
View File
@@ -21,14 +21,12 @@ use serde_json::{Value, json};
use crate::PhotocraftApp;
use crate::file_open::display_name;
/// What a file or folder dialog asks the user for.
/// What a file 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 },
}
@@ -44,8 +42,6 @@ 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),
@@ -92,7 +88,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" | "folder" | "save"}`; `Err` without a
/// module docs). Returns at once with `{"fileDialog": "open" | "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,
@@ -107,7 +103,6 @@ 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 {
@@ -119,16 +114,6 @@ 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).
///
@@ -208,7 +193,6 @@ 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)
@@ -306,7 +290,6 @@ 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()),
}
}
-24
View File
@@ -175,30 +175,6 @@ 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();
+1 -2
View File
@@ -616,8 +616,7 @@ 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, position: None }).collect();
app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).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}));
+4 -16
View File
@@ -1093,12 +1093,12 @@ mod tests {
assert!(!has_dialog("filter.stylize.findEdges"));
}
/// 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> {
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
let mut missing = std::collections::BTreeSet::new();
let mut check = |s: String| {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
missing.insert(s);
}
};
@@ -1125,21 +1125,9 @@ 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]
File diff suppressed because it is too large. Load diff
+3 -11
View File
@@ -10,17 +10,9 @@ 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"),
("vi-VN", "vi"),
] {
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")]
{
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
}
assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko")));
+1 -15
View File
@@ -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; 18] = [
pub static LANGUAGES: [LangInfo; 17] = [
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,8 +164,6 @@ pub static LANGUAGES: [LangInfo; 18] = [
// 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 {
@@ -381,7 +379,6 @@ 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() {
@@ -391,17 +388,6 @@ 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
-59
View File
@@ -707,62 +707,3 @@ 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:?}");
}
-26
View File
@@ -383,32 +383,6 @@ 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]
-46
View File
@@ -11,52 +11,6 @@ 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();
+1 -4
View File
@@ -292,9 +292,6 @@ 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(&params, id)?;
@@ -343,7 +340,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, position: None });
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true });
Ok(json!({"window": wid}))
}
"window.theme.toggle" => {
@@ -182,31 +182,3 @@ 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