mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 12:16:50 -04:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea1268cac | ||
|
|
62f4b9d453 | ||
|
|
986f977a01 | ||
|
|
986f68f969 | ||
|
|
403e321034 | ||
|
|
4cf8a5fd05 | ||
|
|
97c6769d6b | ||
|
|
78c611007b | ||
|
|
dac995ee6c | ||
|
|
8c6e4a3abc | ||
|
|
84aa8df05a | ||
|
|
b623db1ed7 | ||
|
|
55b3301384 | ||
|
|
6ca04e5afa | ||
|
|
c7c6031254 | ||
|
|
d2b5b144aa | ||
|
|
c5a9e7f9b2 | ||
|
|
857a4f7d8a | ||
|
|
da3da0942c | ||
|
|
dbca08cd20 | ||
|
|
649b1d5d92 | ||
|
|
35b5b8ceb0 | ||
|
|
20cb95bc27 |
No files matched your search
@@ -257,6 +257,14 @@ jobs:
|
||||
- { crate: psd, name: parse }
|
||||
- { crate: psd, name: descriptor }
|
||||
- { crate: psd, name: image_source_data }
|
||||
- { crate: psd, name: grd }
|
||||
- { crate: psd, name: ase }
|
||||
- { crate: psd, name: aco }
|
||||
- { crate: psd, name: patterns }
|
||||
- { crate: psd, name: filter_effects }
|
||||
- { crate: psd, name: metadata }
|
||||
- { crate: psd, name: tagged }
|
||||
- { crate: psd, name: resources }
|
||||
- { crate: codecs, name: decode_any }
|
||||
- { crate: codecs, name: decode_png }
|
||||
- { crate: codecs, name: decode_jpeg }
|
||||
|
||||
@@ -19,6 +19,7 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
||||
Generated
+1
@@ -4383,6 +4383,7 @@ name = "photocraft-format"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
|
||||
@@ -331,6 +331,8 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi
|
||||
| arm64 (Snapdragon and other ARM PCs) | `photocraft-<ver>-windows-arm64.msi` | `photocraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `photocraft-<ver>-windows-x86.msi` | `photocraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so).
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -382,6 +382,8 @@ fn services(inbox: Inbox) -> Services {
|
||||
};
|
||||
reply.send(answer);
|
||||
}),
|
||||
// Browser folder handles cannot be represented as filesystem paths for engine commands.
|
||||
FileDialogRequest::Folder { .. } => reply.send(None),
|
||||
// No save dialog on the web: the suggested name becomes the download name.
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string());
|
||||
|
||||
@@ -644,4 +644,13 @@ mod tests {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
|
||||
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
|
||||
#[test]
|
||||
fn the_desktop_entry_names_the_window_class() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop");
|
||||
let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass="));
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>,
|
||||
Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) })
|
||||
}
|
||||
}
|
||||
FileDialogRequest::Folder { initial_dir } => {
|
||||
if let Some(dir) = initial_dir {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let picked = dialog.pick_folder();
|
||||
Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) })
|
||||
}
|
||||
FileDialogRequest::Save { suggested } => {
|
||||
for (name, exts) in save_filters(&suggested) {
|
||||
dialog = dialog.add_filter(name, &exts);
|
||||
|
||||
@@ -104,8 +104,16 @@ sync_github_name = true
|
||||
[people.timothydaly1]
|
||||
display_name = "SyntaxError"
|
||||
|
||||
[people.atoslins]
|
||||
real_name = "Atos Lins"
|
||||
display_name = "Atos Lins"
|
||||
sync_github_name = false
|
||||
|
||||
[people.axlecoffee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FredApps]
|
||||
sync_github_name = true
|
||||
@@ -343,3 +343,102 @@ async fn agent_play_refuses_a_recorded_file_open() {
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges,
|
||||
/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round
|
||||
/// trip — all through MCP tools and checked from outside the engine.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_completes_ten_more_scripted_tasks() {
|
||||
let dir = tmp("tasks2");
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 11. Stroke a selection: a red square with a black outside stroke.
|
||||
tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await;
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await;
|
||||
let inside = pixel(&c, 40, 40).await;
|
||||
let edge = pixel(&c, 18, 40).await;
|
||||
let away = pixel(&c, 5, 5).await;
|
||||
assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}");
|
||||
assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}");
|
||||
assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 12. Inverse the selection: a blue frame around the red square.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "select.inverse", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#0000ff"})).await;
|
||||
let centre = pixel(&c, 40, 40).await;
|
||||
let frame = pixel(&c, 4, 4).await;
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}");
|
||||
assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default).
|
||||
run(&c, "layer.new.layer", json!({"name": "Patch"})).await;
|
||||
run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#00ff00"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "layer.duplicate", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}");
|
||||
let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count();
|
||||
assert_eq!(patches, 2, "task 13: {doc}");
|
||||
|
||||
// 14. Merge the copy down; the two patches become one layer.
|
||||
run(&c, "layer.mergeDown", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14");
|
||||
|
||||
// 15. Flatten the image; the patch survives.
|
||||
run(&c, "layer.flattenImage", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}");
|
||||
let green = pixel(&c, 20, 20).await;
|
||||
assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}");
|
||||
|
||||
// 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels.
|
||||
run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16");
|
||||
let shifted = pixel(&c, 40, 40).await;
|
||||
assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
|
||||
// 17. Feather the selection and reveal a mask through it: the edge blends.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await;
|
||||
run(&c, "select.modify.feather", json!({"radius": 4})).await;
|
||||
run(&c, "layer.layerMask.revealSelection", json!({})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["hasMask"], true, "task 17");
|
||||
let boundary = pixel(&c, 20, 40).await;
|
||||
assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 18. Expand a selection and read its bounds back.
|
||||
run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await;
|
||||
run(&c, "select.modify.expand", json!({"radius": 5})).await;
|
||||
let bounds = inspect(&c).await["selectionBounds"].clone();
|
||||
let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}"));
|
||||
let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap());
|
||||
assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}");
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
|
||||
// 19. Rename a layer and delete it again.
|
||||
run(&c, "layer.new.layer", json!({"name": "Scratch"})).await;
|
||||
run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await;
|
||||
assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19");
|
||||
run(&c, "layer.delete", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted");
|
||||
|
||||
// 20. Export the final image and reopen the PNG as a flat document.
|
||||
tool(&c, "doc_export", json!({"path": "final.png"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": "final.png"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20");
|
||||
assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD).
|
||||
//!
|
||||
//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked
|
||||
//! through `doc_inspect`, `document.pixel` and exported/saved files.
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp};
|
||||
use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig};
|
||||
use rmcp::service::RunningService;
|
||||
use rmcp::{ClientHandler, RoleClient, ServiceExt};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Client;
|
||||
impl ClientHandler for Client {
|
||||
fn get_info(&self) -> ClientConfig {
|
||||
ClientConfig::default()
|
||||
}
|
||||
}
|
||||
|
||||
type Conn = RunningService<RoleClient, Client>;
|
||||
type Server = tokio::task::JoinHandle<()>;
|
||||
|
||||
async fn connect(root: &std::path::Path) -> (Conn, Server) {
|
||||
let (s, c) = tokio::io::duplex(1 << 20);
|
||||
let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace");
|
||||
let server = tokio::spawn(async move {
|
||||
if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await {
|
||||
let _ = running.waiting().await;
|
||||
}
|
||||
});
|
||||
(Client.serve(c).await.expect("client init"), server)
|
||||
}
|
||||
|
||||
async fn shutdown(c: Conn, server: Server) {
|
||||
c.cancel().await.unwrap();
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
fn text(r: &CallToolResult) -> String {
|
||||
r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
async fn tool(c: &Conn, name: &str, args: Value) -> Value {
|
||||
let mut p = CallToolRequestParams::new(name.to_owned());
|
||||
if let Value::Object(m) = args {
|
||||
p = p.with_arguments(m);
|
||||
}
|
||||
let r = c.call_tool(p).await.expect("call_tool transport");
|
||||
assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r));
|
||||
serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r)))
|
||||
}
|
||||
|
||||
async fn run(c: &Conn, id: &str, params: Value) -> Value {
|
||||
tool(c, "command_run", json!({"id": id, "params": params})).await
|
||||
}
|
||||
|
||||
async fn inspect(c: &Conn) -> Value {
|
||||
tool(c, "doc_inspect", json!({})).await
|
||||
}
|
||||
|
||||
async fn pixel(c: &Conn, x: i32, y: i32) -> Vec<f64> {
|
||||
let v = run(c, "document.pixel", json!({"x": x, "y": y})).await;
|
||||
let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) };
|
||||
arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn top_layer(doc: &Value) -> &Value {
|
||||
&doc["layers"][0]
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale
|
||||
/// conversion with an export check, a native save round trip that preserves type, a layer-style
|
||||
/// stroke and inverse fills.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn agent_completes_six_rounding_out_tasks() {
|
||||
let dir = tmp("tasks4");
|
||||
let (c, server) = connect(&dir).await;
|
||||
|
||||
// 31. Undo/redo sweep: four fills go away and come back.
|
||||
tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await;
|
||||
let h0 = inspect(&c).await["history"].as_array().unwrap().len();
|
||||
let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"];
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let x = 10 * i as i32;
|
||||
run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await;
|
||||
run(&c, "edit.fill", json!({"color": color})).await;
|
||||
}
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0;
|
||||
assert!(steps >= 4, "at least the four fills are history steps: {steps}");
|
||||
for _ in 0..steps {
|
||||
run(&c, "edit.undo", json!({})).await;
|
||||
}
|
||||
for i in 0..4 {
|
||||
let x = 10 * i + 5;
|
||||
let p = pixel(&c, x, 5).await;
|
||||
assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}");
|
||||
}
|
||||
for _ in 0..steps {
|
||||
run(&c, "edit.redo", json!({})).await;
|
||||
}
|
||||
let red = pixel(&c, 5, 5).await;
|
||||
let black = pixel(&c, 35, 5).await;
|
||||
assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}");
|
||||
assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}");
|
||||
|
||||
// 32. Paint a barcode: five black bars on white.
|
||||
tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await;
|
||||
for i in 0..5 {
|
||||
let x = 4 + 8 * i;
|
||||
run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#000000"})).await;
|
||||
}
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
for i in 0..5 {
|
||||
let bar = pixel(&c, 5 + 8 * i, 12).await;
|
||||
let gap = pixel(&c, 9 + 8 * i, 12).await;
|
||||
assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}");
|
||||
assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}");
|
||||
}
|
||||
|
||||
// 33. Convert to grayscale: the channels flatten and the export keeps them equal.
|
||||
run(&c, "image.mode.grayscale", json!({})).await;
|
||||
assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33");
|
||||
let g = pixel(&c, 5, 12).await;
|
||||
assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}");
|
||||
tool(&c, "doc_export", json!({"path": "bars.png"})).await;
|
||||
let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap();
|
||||
let px = img.to_rgba8();
|
||||
let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4];
|
||||
assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
// 34. A native save round trip preserves a styled type layer.
|
||||
tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await;
|
||||
run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await;
|
||||
tool(&c, "doc_save", json!({"path": "type.pcraft"})).await;
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
tool(&c, "doc_open", json!({"path": "type.pcraft"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}");
|
||||
assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
// 35. A layer-style stroke frames a swatch.
|
||||
tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await;
|
||||
run(&c, "layer.new.layer", json!({"name": "Swatch"})).await;
|
||||
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await;
|
||||
let doc = inspect(&c).await;
|
||||
assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}");
|
||||
let border = pixel(&c, 11, 24).await;
|
||||
let centre = pixel(&c, 24, 24).await;
|
||||
assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}");
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}");
|
||||
|
||||
// 36. Inverse fill: gray around the swatch, then Select All paints everything white.
|
||||
run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await;
|
||||
run(&c, "select.inverse", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#808080"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let corner = pixel(&c, 2, 2).await;
|
||||
let centre = pixel(&c, 24, 24).await;
|
||||
assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}");
|
||||
assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}");
|
||||
run(&c, "select.all", json!({})).await;
|
||||
run(&c, "edit.fill", json!({"color": "#ffffff"})).await;
|
||||
run(&c, "select.deselect", json!({})).await;
|
||||
let white = pixel(&c, 2, 2).await;
|
||||
assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}");
|
||||
tool(&c, "doc_close", json!({})).await;
|
||||
|
||||
shutdown(c, server).await;
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -1114,27 +1114,24 @@ fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin:
|
||||
}
|
||||
|
||||
fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
|
||||
let src = if inner {
|
||||
match g.source {
|
||||
GlowSource::Edge => shape.clone().map(|a| 1.0 - a),
|
||||
GlowSource::Center => shape.clone(),
|
||||
}
|
||||
} else {
|
||||
shape.clone()
|
||||
};
|
||||
// An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken
|
||||
// after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly
|
||||
// 1 − the edge glow, for both techniques).
|
||||
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
|
||||
let mut m = match g.technique {
|
||||
GlowTechnique::Precise => {
|
||||
// An inner glow, from either source, measures from the edge inwards; a centre glow is
|
||||
// the inverse below (the distance outside is ≤ 0 everywhere inside, #966).
|
||||
// Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to
|
||||
// centre), solid within the spread / choke: after the default 50 % Range that is
|
||||
// Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner,
|
||||
// sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range
|
||||
// values are unmeasured). The distances here are 0.5 px short of centre to centre.
|
||||
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let soft = (g.size + 1.0 - solid).max(1e-3);
|
||||
let mut m = Map::new(shape.w, shape.h, 0.0);
|
||||
for (o, d) in m.v.iter_mut().zip(d) {
|
||||
*o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
|
||||
}
|
||||
if inner && g.source == GlowSource::Center {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
let d = d + 0.5;
|
||||
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
|
||||
}
|
||||
m
|
||||
}
|
||||
@@ -1142,16 +1139,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
|
||||
let (r, bw) = spread_split(g.size, g.spread);
|
||||
let mut m = dilate(&src, r);
|
||||
blur(&mut m, bw);
|
||||
if inner && g.source == GlowSource::Center {
|
||||
// Brightest in the middle: coverage from the distance to the edge.
|
||||
let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r);
|
||||
blur(&mut e, bw);
|
||||
m = e.map(|v| 1.0 - v);
|
||||
}
|
||||
m
|
||||
}
|
||||
};
|
||||
m = apply_lut(m, glow_lut(g));
|
||||
if inner && g.source == GlowSource::Center {
|
||||
m = m.map(|v| 1.0 - v);
|
||||
}
|
||||
noise(&mut m, g.noise, origin.0, origin.1);
|
||||
if inner {
|
||||
for (v, a) in m.v.iter_mut().zip(&shape.v) {
|
||||
@@ -2883,6 +2877,51 @@ mod tests {
|
||||
assert!(glow_lut(&g).is_none());
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise
|
||||
/// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow.
|
||||
#[test]
|
||||
fn glows_match_photoshop() {
|
||||
let glow = |technique, size: f32, spread: f32, source| Glow {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||||
paint: FxPaint::Color(photocraft_color::Color::BLACK),
|
||||
technique,
|
||||
spread,
|
||||
size,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source,
|
||||
};
|
||||
let n = 60;
|
||||
let mut shape = Map::new(n, n, 0.0);
|
||||
for y in 20..40 {
|
||||
for x in 20..40 {
|
||||
shape.v[y * n + x] = 1.0;
|
||||
}
|
||||
}
|
||||
let at = |m: &Map, x: usize| m.v[30 * n + x];
|
||||
// Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232
|
||||
// at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11).
|
||||
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0));
|
||||
for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] {
|
||||
let got = 255.0 * (1.0 - at(&m, 20 - d));
|
||||
assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}");
|
||||
}
|
||||
// Spread 40 %: solid for the first 4 px.
|
||||
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0));
|
||||
assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0);
|
||||
// Centre source = 1 − edge, for both techniques.
|
||||
for t in [GlowTechnique::Precise, GlowTechnique::Softer] {
|
||||
let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0));
|
||||
let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0));
|
||||
for x in 20..40 {
|
||||
assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_follow_light() {
|
||||
let (dx, dy) = offset(120.0, 10.0);
|
||||
|
||||
@@ -315,22 +315,28 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn anchor_factors(a: &str) -> (f64, f64) {
|
||||
let x = if a.contains("Left") || a == "left" {
|
||||
0.0
|
||||
} else if a.contains("Right") || a == "right" {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
let y = if a.starts_with("top") {
|
||||
0.0
|
||||
} else if a.starts_with("bottom") {
|
||||
1.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
(x, y)
|
||||
/// The nine documented canvas-size anchors, in the order the command lists them, each mapped to
|
||||
/// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge).
|
||||
const ANCHORS: [(&str, (f64, f64)); 9] = [
|
||||
("topLeft", (0.0, 0.0)),
|
||||
("top", (0.5, 0.0)),
|
||||
("topRight", (1.0, 0.0)),
|
||||
("left", (0.0, 0.5)),
|
||||
("center", (0.5, 0.5)),
|
||||
("right", (1.0, 0.5)),
|
||||
("bottomLeft", (0.0, 1.0)),
|
||||
("bottom", (0.5, 1.0)),
|
||||
("bottomRight", (1.0, 1.0)),
|
||||
];
|
||||
|
||||
/// Resolves an anchor name to its (x, y) factors, matching the nine documented values exactly.
|
||||
/// Anything else is an error naming `anchor` and listing them, rather than resolving the two axes
|
||||
/// independently (which let a misspelling like `"topleft"` land between two valid anchors, #2946).
|
||||
fn anchor_factors(cmd: &str, a: &str) -> Result<(f64, f64)> {
|
||||
ANCHORS.iter().find(|(name, _)| *name == a).map(|&(_, f)| f).ok_or_else(|| {
|
||||
let names = ANCHORS.iter().map(|&(n, _)| n).collect::<Vec<_>>().join("|");
|
||||
bad(cmd, format!("`anchor` must be one of {names}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn extension_color(s: &Session, p: &Value) -> [f32; 4] {
|
||||
@@ -357,7 +363,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh });
|
||||
let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) };
|
||||
let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32);
|
||||
let (ax, ay) = anchor_factors(p.get("anchor").and_then(Value::as_str).unwrap_or("center"));
|
||||
let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?;
|
||||
let dx = ((nw as f64 - ow) * ax).round() as i32;
|
||||
let dy = ((nh as f64 - oh) * ay).round() as i32;
|
||||
let ext = extension_color(s, p);
|
||||
@@ -980,6 +986,42 @@ mod tests {
|
||||
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_size_anchor_offsets_and_rejects_unlisted() {
|
||||
// 64x48 -> 100x80: dx spans 36 and dy spans 32, so each anchor factor lands on a known
|
||||
// offset. All nine documented values must anchor exactly where they do today (#2946).
|
||||
let expected = [
|
||||
("topLeft", [0, 0]),
|
||||
("top", [18, 0]),
|
||||
("topRight", [36, 0]),
|
||||
("left", [0, 16]),
|
||||
("center", [18, 16]),
|
||||
("right", [36, 16]),
|
||||
("bottomLeft", [0, 32]),
|
||||
("bottom", [18, 32]),
|
||||
("bottomRight", [36, 32]),
|
||||
];
|
||||
for (anchor, off) in expected {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
let r = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap();
|
||||
assert_eq!(r["offset"], json!(off), "anchor {anchor}");
|
||||
}
|
||||
// An omitted anchor still defaults to center.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
assert_eq!(s.execute("image.canvasSize", json!({"width": 100, "height": 80})).unwrap()["offset"], json!([18, 16]));
|
||||
// A misspelling or unknown value is a BadParams error naming `anchor`, never an anchor
|
||||
// between two of the nine (the old independent-axis matching produced top-centre etc.).
|
||||
for anchor in ["topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap();
|
||||
let err = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "anchor {anchor:?} must be rejected, got {err:?}");
|
||||
assert!(err.to_string().contains("anchor"), "the error for {anchor:?} must name `anchor`: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_to_selection_and_trim() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -19,6 +19,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["unbounded_depth"] }
|
||||
thiserror = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
# Same IEEE CRC-32 the ZIP container needs; already in Cargo.lock through flate2.
|
||||
crc32fast = "1.5"
|
||||
blake3 = { version = "1.8", default-features = false, features = ["pure"] }
|
||||
ruzstd = "0.9"
|
||||
|
||||
|
||||
+68
-20
@@ -3,6 +3,7 @@
|
||||
//! unzipped and re-zipped by another tool still loads). No ZIP64, no
|
||||
//! encryption, no multi-disk archives.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
|
||||
use crate::FormatError;
|
||||
@@ -11,25 +12,9 @@ const LOCAL_SIG: u32 = 0x0403_4b50;
|
||||
const CENTRAL_SIG: u32 = 0x0201_4b50;
|
||||
const EOCD_SIG: u32 = 0x0605_4b50;
|
||||
|
||||
/// CRC-32 (IEEE), table-driven.
|
||||
/// CRC-32 (IEEE), as stored in ZIP headers.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
|
||||
let t = TABLE.get_or_init(|| {
|
||||
let mut t = [0u32; 256];
|
||||
for (i, e) in t.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*e = c;
|
||||
}
|
||||
t
|
||||
});
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c = t[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
|
||||
}
|
||||
!c
|
||||
crc32fast::hash(data)
|
||||
}
|
||||
|
||||
/// Streaming writer for STORE entries.
|
||||
@@ -122,7 +107,12 @@ pub struct Entry {
|
||||
/// Read-only view of an archive in memory.
|
||||
pub struct ZipReader<'a> {
|
||||
bytes: &'a [u8],
|
||||
/// All entries, in central-directory order. Treat as read-only: `find`
|
||||
/// looks names up through an index built from this list in `new`.
|
||||
pub entries: Vec<Entry>,
|
||||
/// Name -> index of the first entry with that name, so `find` is O(1)
|
||||
/// instead of a scan per lookup (a bundle reads one entry per tile).
|
||||
by_name: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], o: usize) -> Result<u16, FormatError> {
|
||||
@@ -194,11 +184,16 @@ impl<'a> ZipReader<'a> {
|
||||
entries.push(Entry { name, method, crc, compressed, uncompressed, data_offset });
|
||||
p = entry_end;
|
||||
}
|
||||
Ok(ZipReader { bytes, entries })
|
||||
let mut by_name = HashMap::with_capacity(entries.len());
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
// Duplicate names: the first entry wins, as with a front-to-back scan.
|
||||
by_name.entry(e.name.clone()).or_insert(i);
|
||||
}
|
||||
Ok(ZipReader { bytes, entries, by_name })
|
||||
}
|
||||
|
||||
pub fn find(&self, name: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.name == name)
|
||||
self.by_name.get(name).and_then(|&i| self.entries.get(i))
|
||||
}
|
||||
|
||||
/// Entry payload (decompressed), at most `max` bytes, CRC-checked.
|
||||
@@ -263,6 +258,59 @@ mod tests {
|
||||
assert!(matches!(r.read_by_name("a.txt", 3), Err(FormatError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_matches_bitwise_reference() {
|
||||
// Bit-at-a-time IEEE CRC-32 as the oracle, over lengths that cross
|
||||
// any SIMD / slicing block size.
|
||||
fn reference(data: &[u8]) -> u32 {
|
||||
let mut c = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
c ^= u32::from(b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for len in [0, 1, 7, 15, 16, 17, 63, 64, 65, 255, 256, 1000] {
|
||||
assert_eq!(crc32(&data[..len]), reference(&data[..len]), "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_locates_every_entry_in_a_large_archive() {
|
||||
let mut w = ZipWriter::new();
|
||||
let n = 3000u32;
|
||||
for i in 0..n {
|
||||
w.add(&format!("tiles/{i:05}.zst"), &i.to_le_bytes()).unwrap();
|
||||
}
|
||||
let bytes = w.finish().unwrap();
|
||||
let r = ZipReader::new(&bytes).unwrap();
|
||||
assert_eq!(r.entries.len(), n as usize);
|
||||
for i in (0..n).rev() {
|
||||
let name = format!("tiles/{i:05}.zst");
|
||||
let e = r.find(&name).unwrap();
|
||||
assert_eq!(e.name, name);
|
||||
assert_eq!(r.read(e, 4).unwrap(), i.to_le_bytes());
|
||||
}
|
||||
assert!(r.find("tiles/missing.zst").is_none());
|
||||
assert!(r.find("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_names_resolve_to_the_first_entry() {
|
||||
let mut w = ZipWriter::new();
|
||||
w.add("a", b"first").unwrap();
|
||||
w.add("b", b"other").unwrap();
|
||||
w.add("a", b"second").unwrap();
|
||||
let bytes = w.finish().unwrap();
|
||||
let r = ZipReader::new(&bytes).unwrap();
|
||||
assert_eq!(r.entries.len(), 3);
|
||||
assert_eq!(r.read_by_name("a", 100).unwrap(), b"first");
|
||||
assert_eq!(r.read_by_name("b", 100).unwrap(), b"other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_mismatch_detected() {
|
||||
let mut w = ZipWriter::new();
|
||||
|
||||
@@ -1152,25 +1152,27 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
|
||||
return mout(acc);
|
||||
}
|
||||
|
||||
// Precise glow from a distance map: p0 = (solid, soft, invert).
|
||||
// Precise glow from a distance map (`effects::glow_map`): p0 = (solid, soft). Coverage is 0.5 at
|
||||
// the edge falling to 0 at `size + 1` px centre to centre (the field is 0.5 px short of that),
|
||||
// solid within the spread / choke.
|
||||
@fragment
|
||||
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
|
||||
let d = textureLoad(tex_a, local(in.pos), 0).r;
|
||||
var m = clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
|
||||
let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5;
|
||||
var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
|
||||
if (d <= op.p0.x) { m = 1.0; }
|
||||
if (op.p0.z > 0.5) { m = 1.0 - m; }
|
||||
return mout(m);
|
||||
}
|
||||
|
||||
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v, contour (LUT row 0), × shape
|
||||
// (`layer_tex`)).
|
||||
// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v (1: before the contour, 2: after it,
|
||||
// as a centre-source inner glow), contour (LUT row 0), × shape (`layer_tex`)).
|
||||
@fragment
|
||||
fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
var v = textureLoad(tex_a, p, 0).r;
|
||||
if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); }
|
||||
if (op.p0.y > 0.5) { v = 1.0 - v; }
|
||||
if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; }
|
||||
if (op.p0.z > 0.5) { v = lut(0, v); }
|
||||
if (op.p0.y > 1.5) { v = 1.0 - v; }
|
||||
if (op.p0.w > 0.5) { v = v * textureLoad(layer_tex, p, 0).r; }
|
||||
return mout(v);
|
||||
}
|
||||
|
||||
+17
-4
@@ -236,6 +236,19 @@ impl B {
|
||||
fn finish(&mut self, a: In, b: Option<In>, invert: bool, contour: &Contour, times_shape: bool, out: usize) {
|
||||
self.finish_lut(a, b, invert, contour_lut(contour), times_shape, out);
|
||||
}
|
||||
/// A glow's last step: its contour over the Range, then (centre-source inner glows) the
|
||||
/// inverse, then × the shape when inner.
|
||||
fn finish_glow(&mut self, a: In, lut: Option<Vec<f32>>, inner: bool, center: bool) {
|
||||
let mut s =
|
||||
stage(Kernel::MFinish, Some(a), None, [0.0, if center { 2.0 } else { 0.0 }, f32::from(u8::from(lut.is_some())), f32::from(u8::from(inner))], 0);
|
||||
s.lut = lut.map(Arc::new);
|
||||
if inner {
|
||||
s.s = Some(In::Shape);
|
||||
}
|
||||
s.out = Some(0);
|
||||
self.stages.push(s);
|
||||
}
|
||||
|
||||
fn finish_lut(&mut self, a: In, b: Option<In>, invert: bool, lut: Option<Vec<f32>>, times_shape: bool, out: usize) {
|
||||
let flag = |v: bool| f32::from(u8::from(v));
|
||||
let mut s = stage(Kernel::MFinish, Some(a), b, [flag(b.is_some()), flag(invert), flag(lut.is_some()), flag(times_shape)], 0);
|
||||
@@ -319,9 +332,9 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
GlowTechnique::Precise => {
|
||||
let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size);
|
||||
let solid = g.size * g.spread;
|
||||
let soft = (g.size - solid).max(1e-3);
|
||||
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0));
|
||||
b.finish_lut(m, None, false, photocraft_compose::effects::glow_lut(g), inner, 0);
|
||||
let soft = (g.size + 1.0 - solid).max(1e-3);
|
||||
let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0));
|
||||
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
|
||||
}
|
||||
GlowTechnique::Softer => {
|
||||
// Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha.
|
||||
@@ -333,7 +346,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
m = b.dilate(src, d, r);
|
||||
}
|
||||
let m = b.blur(m, bw);
|
||||
b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0);
|
||||
b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center);
|
||||
}
|
||||
}
|
||||
1
|
||||
|
||||
@@ -19,9 +19,16 @@ const SPACE_CMYK: u16 = 2;
|
||||
const SPACE_LAB: u16 = 7;
|
||||
const SPACE_GRAY: u16 = 8;
|
||||
|
||||
/// Resource 1077 for `channels` (in file order).
|
||||
pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
|
||||
/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour
|
||||
/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13;
|
||||
/// 14-byte records (the older 1007 layout) shift every channel after the first.
|
||||
/// `transparency`: the merged image's transparency channel comes first and gets a record too.
|
||||
pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Vec<u8> {
|
||||
let mut out = 1u32.to_be_bytes().to_vec();
|
||||
if transparency {
|
||||
// As Photoshop writes it: red, 100 %, masked areas.
|
||||
out.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 100, 1]);
|
||||
}
|
||||
for c in channels {
|
||||
let (color, opacity, kind) = match c.spot {
|
||||
Some((ink, solidity)) => (ink, solidity, 2u8),
|
||||
@@ -34,15 +41,16 @@ pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec<u8> {
|
||||
}
|
||||
out.extend_from_slice(&((opacity.clamp(0.0, 1.0) * 100.0).round() as u16).to_be_bytes());
|
||||
out.push(kind);
|
||||
out.push(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Applies a DisplayInfo resource (`versioned` = 1077) to the imported channels in order.
|
||||
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [AlphaChannel]) {
|
||||
let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data };
|
||||
for (c, rec) in channels.iter_mut().zip(body.as_chunks::<14>().0) {
|
||||
/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the
|
||||
/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip`
|
||||
/// records (the merged transparency channel's).
|
||||
pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) {
|
||||
let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) };
|
||||
for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) {
|
||||
let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]);
|
||||
let comps = [u(2), u(4), u(6), u(8)];
|
||||
let color = decode_color(u(0), comps);
|
||||
@@ -97,17 +105,25 @@ mod tests {
|
||||
a.indicates = ColorIndicates::SelectedAreas;
|
||||
let mut s = AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8));
|
||||
s.spot = Some((Color::rgb(1.0, 0.0, 1.0), 0.8));
|
||||
let bytes = display_info(&[&a, &s]);
|
||||
assert_eq!(bytes.len(), 4 + 28);
|
||||
let bytes = display_info(&[&a, &s], false);
|
||||
// 13 bytes per channel, as every corpus file (Photoshop's included) lays out 1077.
|
||||
assert_eq!(bytes.len(), 4 + 2 * 13);
|
||||
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&bytes, true, &mut back);
|
||||
apply_display_info(&bytes, true, 0, &mut back);
|
||||
assert_eq!(back[0].color, a.color);
|
||||
assert!((back[0].opacity - 0.3).abs() < 1e-6);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
// With the merged transparency's record first, the channels still get their own.
|
||||
let with_t = display_info(&[&a, &s], true);
|
||||
assert_eq!(with_t.len(), 4 + 3 * 13);
|
||||
let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&with_t, true, 1, &mut back);
|
||||
assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas);
|
||||
assert_eq!(back[1].spot, s.spot);
|
||||
// Truncated data is ignored rather than misread.
|
||||
let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))];
|
||||
apply_display_info(&bytes[..10], true, &mut c);
|
||||
apply_display_info(&bytes[..10], true, 0, &mut c);
|
||||
assert_eq!(c[0].opacity, 0.5);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St
|
||||
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) });
|
||||
}
|
||||
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, true, 0, &mut doc.channels);
|
||||
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
|
||||
let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect();
|
||||
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names)));
|
||||
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false)));
|
||||
}
|
||||
if let Some(x) = &doc.metadata.xmp {
|
||||
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
|
||||
|
||||
@@ -1435,10 +1435,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
|
||||
resources.push(ImageResource::new(1032, guides_resource(doc)));
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect();
|
||||
// One entry per extra channel, the merged transparency included (as in every file
|
||||
// Photoshop opens; one short, Photoshop refuses the file with "unexpected end-of-file").
|
||||
let names: Vec<&str> = has_alpha.then_some("Transparency").into_iter().chain(extra.iter().map(|a| a.name.as_str())).collect();
|
||||
resources.push(ImageResource::new(1006, pascal_names_resource(&names)));
|
||||
resources.push(ImageResource::new(1045, unicode_names_resource(&names)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra)));
|
||||
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha)));
|
||||
}
|
||||
if let Some(id) = quick_mask_id {
|
||||
let mut data = id.to_be_bytes().to_vec();
|
||||
@@ -1717,4 +1719,35 @@ mod tests {
|
||||
assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd);
|
||||
assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb);
|
||||
}
|
||||
|
||||
/// A layered document with transparency in the merged image and an alpha channel: the name
|
||||
/// and DisplayInfo resources hold one entry per extra channel, the merged transparency first,
|
||||
/// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could
|
||||
/// not import the clipboard because an unexpected end-of-file was encountered"). The alpha
|
||||
/// channel keeps its own name and options on the way back.
|
||||
#[test]
|
||||
fn alpha_names_count_the_merged_transparency() {
|
||||
let mut doc = document(8, 6);
|
||||
let mut layer = Layer::raster("Layer 1", doc.pixel_format());
|
||||
layer.surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]);
|
||||
doc.layers.push(layer);
|
||||
let mut a = photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(PixelFormat::GRAY8));
|
||||
a.opacity = 0.5;
|
||||
doc.channels.push(a);
|
||||
let file = document_to_psd(&doc);
|
||||
let header_extra = usize::from(file.header.channels) - 3;
|
||||
let names = |id| file.resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap();
|
||||
let pascal = names(1006);
|
||||
let (mut count, mut i) = (0, 0);
|
||||
while i < pascal.len() {
|
||||
count += 1;
|
||||
i += 1 + usize::from(pascal[i]);
|
||||
}
|
||||
assert_eq!((header_extra, count), (2, 2), "transparency + Alpha 1");
|
||||
assert_eq!(names(crate::channel_map::DISPLAY_INFO).len(), 4 + 2 * 13);
|
||||
let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document;
|
||||
assert_eq!(back.channels.len(), 1);
|
||||
assert_eq!(back.channels[0].name, "Alpha 1");
|
||||
assert!((back.channels[0].opacity - 0.5).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -808,6 +808,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
let first = cc + usize::from(file.merged_has_alpha());
|
||||
let plane = h.row_bytes() * hh;
|
||||
let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default();
|
||||
// Photoshop lists every extra channel, the merged transparency first when there is one.
|
||||
let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc));
|
||||
let names = names.get(skip..).unwrap_or_default();
|
||||
for (k, idx) in (first..usize::from(h.channels)).enumerate() {
|
||||
let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break };
|
||||
let mut s = Surface::new(cx.mask_fmt);
|
||||
@@ -818,9 +821,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt)
|
||||
}
|
||||
// Channel options (overlay colour, opacity, colour indicates) and spot inks.
|
||||
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
|
||||
crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels);
|
||||
} else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) {
|
||||
crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels);
|
||||
crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels);
|
||||
}
|
||||
// Saved in Quick Mask mode: that channel becomes the document's Quick Mask again.
|
||||
if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO)
|
||||
|
||||
@@ -112,8 +112,8 @@ fn tysh_with_font(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transf
|
||||
.with("boundingBox", rect("boundingBox"))
|
||||
.with("TextIndex", D::Integer(0))
|
||||
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds, font))));
|
||||
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
|
||||
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
|
||||
// Unwarped type: Photoshop writes four zero f32s after the warp descriptor.
|
||||
write_tysh(&TySh { transform, text: desc, warp: None, bounds: [0.0; 4] })
|
||||
}
|
||||
|
||||
struct Case {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Type-layer bytes we write match what Photoshop 2026 writes for the same settings, checked
|
||||
//! against Photoshop-authored files in `corpus/photoshop` (feature `corpus`; fetched by
|
||||
//! `cargo xtask corpus --all`, a missing corpus fails):
|
||||
//! - EngineData `AntiAlias` numbers for every anti-aliasing mode;
|
||||
//! - the four f32 values that end a `TySh` block (zeros unwarped, text-space bounds warped);
|
||||
//! - the version info resource (1057).
|
||||
|
||||
#![cfg(feature = "corpus")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_psd::PsdFile;
|
||||
use photocraft_psd::descriptor::Value as D;
|
||||
use photocraft_text::engine_data::Value as E;
|
||||
|
||||
fn corpus(rel: &str) -> PsdFile {
|
||||
let path: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop").join(rel);
|
||||
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
|
||||
PsdFile::from_bytes(&bytes).unwrap()
|
||||
}
|
||||
|
||||
fn first_tysh(f: &PsdFile) -> Vec<u8> {
|
||||
f.layers().iter().find_map(|l| l.block(b"TySh")).map(|b| b.data.clone()).unwrap()
|
||||
}
|
||||
|
||||
fn engine_antialias(tysh: &[u8]) -> Option<f64> {
|
||||
let t = photocraft_text::psd::parse_tysh(tysh)?;
|
||||
photocraft_text::psd::engine_data(&t.text)?.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)
|
||||
}
|
||||
|
||||
fn rect(t: &photocraft_text::psd::TySh, key: &str) -> [f32; 4] {
|
||||
let Some(D::Descriptor(d)) = t.text.get(key) else { panic!("no {key}") };
|
||||
["Left", "Top ", "Rght", "Btom"].map(|k| match d.get(k) {
|
||||
Some(D::UnitFloat { value, .. }) => *value as f32,
|
||||
other => panic!("{k}: {other:?}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_data_antialias_matches_photoshop() {
|
||||
for name in ["none", "crisp", "strong", "smooth", "sharp"] {
|
||||
let raw = first_tysh(&corpus(&format!("text/antialias-{name}.psd")));
|
||||
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
|
||||
layer.psd_raw = None;
|
||||
let ours = photocraft_text::psd::build_tysh(&layer, 72.0, None);
|
||||
assert_eq!(engine_antialias(&ours), engine_antialias(&raw), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tysh_trailing_bounds_match_photoshop() {
|
||||
for (file, warped) in [("text/warp-arc-50.psd", true), ("text/antialias-smooth.psd", false)] {
|
||||
let raw = first_tysh(&corpus(file));
|
||||
let theirs = photocraft_text::psd::parse_tysh(&raw).unwrap();
|
||||
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
|
||||
assert_eq!(layer.warp.is_some(), warped, "{file}");
|
||||
layer.psd_raw = None;
|
||||
let ours = photocraft_text::psd::parse_tysh(&photocraft_text::psd::build_tysh(&layer, 72.0, Some(rect(&theirs, "bounds")))).unwrap();
|
||||
assert_eq!(ours.bounds, theirs.bounds, "{file}");
|
||||
if warped {
|
||||
assert_eq!(theirs.bounds, rect(&theirs, "bounds"), "{file}");
|
||||
} else {
|
||||
assert_eq!(theirs.bounds, [0.0; 4], "{file}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_info_matches_photoshop() {
|
||||
for file in ["text/antialias-smooth.psd", "text/warp-arc-50.psd"] {
|
||||
let theirs = corpus(file);
|
||||
assert_eq!(photocraft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}");
|
||||
}
|
||||
}
|
||||
@@ -42,3 +42,59 @@ path = "fuzz_targets/image_source_data.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "grd"
|
||||
path = "fuzz_targets/grd.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "ase"
|
||||
path = "fuzz_targets/ase.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "aco"
|
||||
path = "fuzz_targets/aco.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "patterns"
|
||||
path = "fuzz_targets/patterns.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "filter_effects"
|
||||
path = "fuzz_targets/filter_effects.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "metadata"
|
||||
path = "fuzz_targets/metadata.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "tagged"
|
||||
path = "fuzz_targets/tagged.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "resources"
|
||||
path = "fuzz_targets/resources.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Fuzz the `.aco` Photoshop Color Swatches parser: any input must return `Ok` or `Err`, never
|
||||
//! panic; anything that parses must round-trip through `write` and re-parse with the same swatch
|
||||
//! count, and no name may exceed the name-unit cap.
|
||||
//!
|
||||
//! `MAX_SWATCHES` is `u16::MAX` and the on-disk count is a `u16`, so the count is unfalsifiable by
|
||||
//! construction; the assertion is kept as documentation of that invariant, not as a check that can
|
||||
//! fail. The name cap, which is reachable, is the one that bites.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::aco;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(f) = aco::parse(data) else { return };
|
||||
assert!(f.swatches.len() <= aco::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatches.len());
|
||||
for s in &f.swatches {
|
||||
assert!(s.name.encode_utf16().count() <= aco::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
|
||||
}
|
||||
let out = aco::write(&f.swatches).expect("a parsed .aco must be writable");
|
||||
let back = aco::parse(&out).expect("a written .aco must re-parse");
|
||||
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
|
||||
assert_eq!(aco::write(&back.swatches).expect("rewritable"), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Fuzz the `.ase` Adobe Swatch Exchange parser: any input must return `Ok` or `Err`, never panic;
|
||||
//! anything that parses must hold only finite colour components, stay within the swatch cap and
|
||||
//! round-trip through `write` and re-parse with the same number of entries.
|
||||
//!
|
||||
//! A colour outside any group is a legitimate `AseEntry::Swatch`, and a group left open at the
|
||||
//! end is closed by the parser, so entry shape is checked only through the round trip below.
|
||||
//! The cap assertion is an invariant, not a fast check: no 4 KB input can carry 100 001 blocks.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::ase::{self, AseColor, AseEntry};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(f) = ase::parse(data) else { return };
|
||||
assert!(f.swatch_count() <= ase::MAX_SWATCHES, "{} swatches exceeds MAX_SWATCHES", f.swatch_count());
|
||||
for e in &f.entries {
|
||||
let swatches = match e {
|
||||
AseEntry::Swatch(s) => std::slice::from_ref(s),
|
||||
AseEntry::Group { swatches, .. } => swatches.as_slice(),
|
||||
};
|
||||
for s in swatches {
|
||||
assert!(s.name.encode_utf16().count() <= ase::MAX_NAME_UNITS, "name longer than MAX_NAME_UNITS");
|
||||
// Rgb/Lab carry three components, Cmyk four, Gray one.
|
||||
let check = |c: &[f32]| {
|
||||
for v in c {
|
||||
assert!(v.is_finite(), "colour component {v} is not finite");
|
||||
}
|
||||
};
|
||||
match &s.color {
|
||||
AseColor::Rgb(v) | AseColor::Lab(v) => check(v),
|
||||
AseColor::Cmyk(v) => check(v),
|
||||
AseColor::Gray(v) => check(std::slice::from_ref(v)),
|
||||
AseColor::Other { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = ase::write(&f).expect("a parsed .ase must be writable");
|
||||
let back = ase::parse(&out).expect("a written .ase must re-parse");
|
||||
assert_eq!(back.swatch_count(), f.swatch_count(), "swatch count changed across a round trip");
|
||||
assert_eq!(ase::write(&back).expect("rewritable"), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
//! Fuzz the `FEid` / `FXid` smart-filter cache parser: any input must return `Ok` or `Err`, never
|
||||
//! panic. Decoding every cached plane and the filter mask must also stay panic-free (a size the
|
||||
//! data cannot hold is refused before allocation, for RLE and zlib alike), and a parsed block must
|
||||
//! re-parse to the same value and re-serialise byte for byte.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::filter_effects::FilterEffects;
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(fx) = FilterEffects::parse(data) else { return };
|
||||
for it in &fx.items {
|
||||
let _ = it.decoded_mask();
|
||||
if let (Ok(depth), Ok((w, h))) = (u16::try_from(it.depth), it.rect.size()) {
|
||||
for p in it.slots.iter().flatten() {
|
||||
let _ = p.decode(w, h, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = fx.to_bytes();
|
||||
let back = FilterEffects::parse(&out).expect("a written FEid block must re-parse");
|
||||
assert_eq!(back, fx, "a written FEid block does not re-parse to the same value");
|
||||
assert_eq!(back.to_bytes(), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Fuzz the `.grd` gradient parser: any input must return `Ok` or `Err`, never panic, and every
|
||||
//! number it hands back must be finite and in range.
|
||||
//!
|
||||
//! The cap assertions below are invariants, not fast checks: at libFuzzer default `-max_len`
|
||||
//! of 4096 no input can carry 10 001 gradients or 1001 stops, so they only fire on long runs with
|
||||
//! a raised limit. The inputs that do reach those caps are in `crates/psd/tests/swatch_malformed.rs`.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::grd::{self, GrdColor};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(g) = grd::parse(data) else { return };
|
||||
assert!(g.len() <= grd::MAX_GRADIENTS, "{} gradients exceeds MAX_GRADIENTS", g.len());
|
||||
for grad in &g {
|
||||
assert!(grad.stops.len() <= grd::MAX_STOPS, "{} stops exceeds MAX_STOPS", grad.stops.len());
|
||||
assert!(grad.opacity.len() <= grd::MAX_STOPS, "{} transparency stops exceeds MAX_STOPS", grad.opacity.len());
|
||||
for &(l, o, m) in &grad.opacity {
|
||||
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
|
||||
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
|
||||
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
|
||||
}
|
||||
for s in &grad.stops {
|
||||
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
|
||||
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
|
||||
// Each model has its own documented range, not just "finite": the parser clamps RGB,
|
||||
// CMYK and Gray to 0..=1, Hue to 0..=360, L* to 0..=100 and a*/b* to the CIELAB gamut.
|
||||
let unit = |c: &[f32]| {
|
||||
for v in c {
|
||||
assert!(v.is_finite() && (0.0..=1.0).contains(v), "colour component {v} outside 0..=1");
|
||||
}
|
||||
};
|
||||
match s.color {
|
||||
GrdColor::Rgb(v) => unit(&v),
|
||||
GrdColor::Cmyk(v) => unit(&v),
|
||||
GrdColor::Hsb([h, sat, br]) => {
|
||||
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} outside 0..=360");
|
||||
unit(&[sat, br]);
|
||||
}
|
||||
GrdColor::Lab([l, a, b]) => {
|
||||
assert!(l.is_finite() && (0.0..=100.0).contains(&l), "L* {l} outside 0..=100");
|
||||
assert!(a.is_finite() && (-128.0..=127.0).contains(&a), "a* {a} outside the CIELAB gamut");
|
||||
assert!(b.is_finite() && (-128.0..=127.0).contains(&b), "b* {b} outside the CIELAB gamut");
|
||||
}
|
||||
GrdColor::Gray(v) => unit(&[v]),
|
||||
GrdColor::Foreground | GrdColor::Background => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = grd::write(&g);
|
||||
let back = grd::parse(&out).expect("a written .grd must re-parse");
|
||||
assert_eq!(back.len(), g.len(), "gradient count changed across a round trip");
|
||||
assert_eq!(grd::write(&back), out, "a second round trip is not byte-stable");
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Fuzz the `shmd` (metadata setting) parser: any input must return `Ok` or `Err`, never panic.
|
||||
//!
|
||||
//! The `copy` field is a bool, so a non-canonical copy byte (anything but 0 or 1) normalises to 1
|
||||
//! on write; exact byte equality therefore cannot hold for arbitrary input, only for canonical
|
||||
//! files. The invariant that does hold, and that catches reader/writer drift, is that a
|
||||
//! parsed-then-written block re-parses to the *same items* (semantic equality, not just byte
|
||||
//! stability -- byte stability alone would also pass if the writer silently dropped a field).
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::metadata::{parse_shmd, write_shmd};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(items) = parse_shmd(data) else { return };
|
||||
let out = write_shmd(&items);
|
||||
let back = parse_shmd(&out).expect("a written shmd block must re-parse");
|
||||
assert_eq!(back, items, "a written shmd block does not re-parse to the same items");
|
||||
assert_eq!(write_shmd(&back), out, "shmd is not byte-stable across a round trip");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Fuzz the pattern parsers: the `Patt` / `Pat2` / `Pat3` tagged blocks and standalone `.pat`
|
||||
//! files. Any input must return `Ok` or `Err`, never panic. The channel offsets and padding a
|
||||
//! hostile file can carry are wire artifacts, not fields of `PsdPattern`, so a parsed pattern must
|
||||
//! survive a write/parse round trip unchanged -- a stronger invariant than byte stability, which
|
||||
//! would also hold if the writer silently normalised a field away.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
if let Ok(ps) = parse_pattern_block(data) {
|
||||
let out = write_pattern_block(&ps).expect("a parsed pattern block must be writable");
|
||||
let back = parse_pattern_block(&out).expect("a written pattern block must re-parse");
|
||||
assert_eq!(back, ps, "a pattern block does not survive a write/parse round trip");
|
||||
}
|
||||
if let Ok(ps) = parse_pat_file(data) {
|
||||
let out = write_pat_file(&ps).expect("a parsed .pat must be writable");
|
||||
let back = parse_pat_file(&out).expect("a written .pat must re-parse");
|
||||
assert_eq!(back, ps, "a .pat file does not survive a write/parse round trip");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Fuzz the typed image-resource views: `ImageResource::parsed` for an arbitrary resource id, plus
|
||||
//! the standalone `ResolutionInfo` and `VersionInfo` parsers. All must return `Ok` / `Err` / `None`
|
||||
//! (and `xmp_str` an `Option`), never panic. A parsed `ResolutionInfo` (16 fixed bytes) must be
|
||||
//! stable across a byte round trip. The first two bytes pick the id so every typed branch is
|
||||
//! reachable; the rest is the resource data.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::resources::{ImageResource, ResolutionInfo, VersionInfo};
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (id, body) = match data.split_first_chunk::<2>() {
|
||||
Some((p, rest)) => (u16::from_be_bytes(*p), rest.to_vec()),
|
||||
None => (0u16, data.to_vec()),
|
||||
};
|
||||
let r = ImageResource::new(id, body.clone());
|
||||
let _ = r.parsed();
|
||||
let _ = r.xmp_str();
|
||||
if let Ok(ri) = ResolutionInfo::from_bytes(&body) {
|
||||
let round = ResolutionInfo::from_bytes(&ri.to_bytes()).expect("resolution info re-parses");
|
||||
assert_eq!(round, ri, "ResolutionInfo is not stable across a round trip");
|
||||
}
|
||||
if let Ok(vi) = VersionInfo::from_bytes(&body) {
|
||||
let _ = vi.to_bytes();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Fuzz the per-block views of a tagged block: `parsed` (typed, well-known keys) and
|
||||
//! `check_structure` (strict re-parse of blocks that carry their own inner sizes: placed layers,
|
||||
//! linked files, object effects, filter caches). Both must return `Ok` / `Err` / `None`, never
|
||||
//! panic, for arbitrary data.
|
||||
//!
|
||||
//! The first byte selects the key from a table of every key with a typed or strict branch, so the
|
||||
//! fuzzer reaches each branch deterministically instead of having to guess a 4-byte key from raw
|
||||
//! bytes (which it would almost never do); the rest is the block data.
|
||||
#![no_main]
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use photocraft_psd::tagged::TaggedBlock;
|
||||
|
||||
const KEYS: [&[u8; 4]; 20] = [
|
||||
// typed (parsed)
|
||||
b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr", b"iOpa", b"shmd",
|
||||
// strict (check_structure)
|
||||
b"PlLd", b"SoLd", b"lnk2", b"lfx2", b"FEid",
|
||||
];
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let (key, body) = match data.split_first() {
|
||||
Some((&sel, rest)) => (*KEYS[sel as usize % KEYS.len()], rest.to_vec()),
|
||||
None => (*b"luni", Vec::new()),
|
||||
};
|
||||
let b = TaggedBlock::new(key, body);
|
||||
let _ = b.parsed();
|
||||
let _ = b.check_structure();
|
||||
});
|
||||
@@ -181,7 +181,7 @@ pub fn write(swatches: &[AcoSwatch]) -> Result<Vec<u8>> {
|
||||
out.put_u16(x);
|
||||
}
|
||||
if version == 2 {
|
||||
let units: Vec<u16> = s.name.encode_utf16().take(MAX_NAME_UNITS).collect();
|
||||
let units = crate::io::utf16_units(&s.name, MAX_NAME_UNITS);
|
||||
// The length counts the terminating null.
|
||||
out.put_u32(units.len() as u32 + 1);
|
||||
for u in units {
|
||||
|
||||
@@ -24,6 +24,9 @@ pub const SIGNATURE: &[u8; 4] = b"ASEF";
|
||||
pub const MAX_SWATCHES: usize = 100_000;
|
||||
/// Longest name read or written, in UTF-16 code units.
|
||||
pub const MAX_NAME_UNITS: usize = 1024;
|
||||
// `put_name` writes the name length as `units.len() as u16 + 1`, so the cap must leave room in a
|
||||
// u16; this guards that invariant if the constant is ever raised.
|
||||
const _: () = assert!(MAX_NAME_UNITS < u16::MAX as usize);
|
||||
|
||||
const GROUP_START: u16 = 0xC001;
|
||||
const GROUP_END: u16 = 0xC002;
|
||||
@@ -226,7 +229,7 @@ pub fn parse(data: &[u8]) -> Result<AseFile> {
|
||||
}
|
||||
|
||||
fn put_name(out: &mut Vec<u8>, name: &str) {
|
||||
let units: Vec<u16> = name.encode_utf16().take(MAX_NAME_UNITS).collect();
|
||||
let units = crate::io::utf16_units(name, MAX_NAME_UNITS);
|
||||
out.put_u16(units.len() as u16 + 1);
|
||||
for u in units {
|
||||
out.put_u16(u);
|
||||
|
||||
+45
-9
@@ -58,6 +58,9 @@ pub struct GrdGradient {
|
||||
pub opacity: Vec<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
/// The raw number behind a key. It is deliberately *not* filtered: every caller passes the result
|
||||
/// through `finite`, so filtering here as well would give two guards with two different answers to
|
||||
/// the same input, and mutation testing showed the second one was unreachable.
|
||||
fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
match d.get(k)? {
|
||||
Value::Double(v) => Some(*v),
|
||||
@@ -66,11 +69,36 @@ fn num(d: &Descriptor, k: &str) -> Option<f64> {
|
||||
Value::LargeInteger(v) => Some(*v as f64),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
/// The single guard every number passes through on its way to an `f32`. Only a NaN is truly
|
||||
/// without a value, so only a NaN returns `None`; the caller then falls back to the default its
|
||||
/// own field documents (opacity opaque, a midpoint centred, a channel zero).
|
||||
///
|
||||
/// Everything else — including `±inf` and a value finite as `f64` but larger than `f32::MAX`
|
||||
/// (`1e300`, which would otherwise overflow the cast to an infinity) — is *saturated* to the
|
||||
/// representable range before the cast. That keeps the mapping monotonic: a bigger input never
|
||||
/// yields a smaller output, so "too large" lands at the top of the range rather than snapping to a
|
||||
/// field default and inverting the result.
|
||||
fn finite(v: f64) -> Option<f32> {
|
||||
if v.is_nan() {
|
||||
return None;
|
||||
}
|
||||
Some(v.clamp(f64::from(f32::MIN), f64::from(f32::MAX)) as f32)
|
||||
}
|
||||
fn f(d: &Descriptor, k: &str) -> f32 {
|
||||
num(d, k).unwrap_or(0.0) as f32
|
||||
finite(num(d, k).unwrap_or(0.0)).unwrap_or(0.0)
|
||||
}
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
v.clamp(0.0, 1.0)
|
||||
}
|
||||
/// A hue in degrees. An in-range value (0..=360, including exactly 360) is kept verbatim; only an
|
||||
/// out-of-range value wraps with `rem_euclid`, so 370 -> 10 and -10 -> 350. Wrapping every value
|
||||
/// would alter a legal 360 to 0 (and f32 round-off on a tiny negative can itself land on 360),
|
||||
/// changing the colour at the boundary. The input is already finite, so the result stays finite.
|
||||
fn hue(h: f32) -> f32 {
|
||||
if (0.0..=360.0).contains(&h) { h } else { h.rem_euclid(360.0) }
|
||||
}
|
||||
|
||||
fn obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> {
|
||||
match d.get(k)? {
|
||||
Value::Descriptor(o) | Value::GlobalObject(o) => Some(o),
|
||||
@@ -90,11 +118,19 @@ fn enum_is(d: &Descriptor, k: &str, v: &str) -> bool {
|
||||
fn color(c: &Descriptor) -> Option<GrdColor> {
|
||||
let id = c.class_id.as_bytes();
|
||||
Some(match id {
|
||||
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0]),
|
||||
b"HSBC" => GrdColor::Hsb([f(c, "H "), f(c, "Strt") / 100.0, f(c, "Brgh") / 100.0]),
|
||||
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0]),
|
||||
b"LbCl" => GrdColor::Lab([f(c, "Lmnc"), f(c, "A "), f(c, "B ")]),
|
||||
b"Grsc" => GrdColor::Gray(f(c, "Gry ") / 100.0),
|
||||
// Every component documented as 0..=1 is clamped here, like every other range in this
|
||||
// module: a hostile file can store any integer at all, and dividing by 100 or 255 does not
|
||||
// bring it back into range on its own.
|
||||
b"RGBC" => GrdColor::Rgb([f(c, "Rd ") / 255.0, f(c, "Grn ") / 255.0, f(c, "Bl ") / 255.0].map(clamp01)),
|
||||
// Hue is a circular degree value in 0..=360; see `hue`. An in-range value (including
|
||||
// exactly 360) is kept, only an out-of-range one wraps, so a hostile hue cannot become a
|
||||
// different, wrong colour at the boundary.
|
||||
b"HSBC" => GrdColor::Hsb([hue(f(c, "H ")), (f(c, "Strt") / 100.0).clamp(0.0, 1.0), (f(c, "Brgh") / 100.0).clamp(0.0, 1.0)]),
|
||||
b"CMYC" => GrdColor::Cmyk([f(c, "Cyn ") / 100.0, f(c, "Mgnt") / 100.0, f(c, "Ylw ") / 100.0, f(c, "Blck") / 100.0].map(clamp01)),
|
||||
// L* is 0..=100; a*/b* are signed and bounded to the CIELAB gamut (-128..=127) so a hostile
|
||||
// value cannot hand a downstream colour converter an out-of-gamut number to overflow on.
|
||||
b"LbCl" => GrdColor::Lab([f(c, "Lmnc").clamp(0.0, 100.0), f(c, "A ").clamp(-128.0, 127.0), f(c, "B ").clamp(-128.0, 127.0)]),
|
||||
b"Grsc" => GrdColor::Gray(clamp01(f(c, "Gry ") / 100.0)),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -103,7 +139,7 @@ fn location(d: &Descriptor) -> f32 {
|
||||
(f(d, "Lctn") / 4096.0).clamp(0.0, 1.0)
|
||||
}
|
||||
fn midpoint(d: &Descriptor) -> f32 {
|
||||
(num(d, "Mdpn").unwrap_or(50.0) as f32 / 100.0).clamp(0.0, 1.0)
|
||||
finite(num(d, "Mdpn").unwrap_or(50.0) / 100.0).unwrap_or(0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
@@ -127,7 +163,7 @@ fn gradient(g: &Descriptor) -> GrdGradient {
|
||||
}
|
||||
for v in list(g, "Trns").iter().take(MAX_STOPS) {
|
||||
let Value::Descriptor(s) = v else { continue };
|
||||
out.opacity.push((location(s), (num(s, "Opct").unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
out.opacity.push((location(s), finite(num(s, "Opct").unwrap_or(100.0) / 100.0).unwrap_or(1.0).clamp(0.0, 1.0), midpoint(s)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
use crate::error::{PsdError, Result};
|
||||
|
||||
/// A name as UTF-16 code units, capped at `max` **without ever splitting a surrogate pair**.
|
||||
/// `encode_utf16().take(max)` would cut between the high and low half of a pair above U+FFFF and
|
||||
/// write ill-formed UTF-16 to disk, so whole scalar values are added until one would not fit.
|
||||
/// Both the `.aco` and `.ase` writers share this; keeping it in one place is deliberate, so the
|
||||
/// surrogate fix cannot drift between the two formats.
|
||||
pub(crate) fn utf16_units(name: &str, max: usize) -> Vec<u16> {
|
||||
let mut units = Vec::new();
|
||||
for c in name.chars() {
|
||||
if units.len() + c.len_utf16() > max {
|
||||
break;
|
||||
}
|
||||
let mut buf = [0u16; 2];
|
||||
units.extend_from_slice(c.encode_utf16(&mut buf));
|
||||
}
|
||||
units
|
||||
}
|
||||
|
||||
/// A cursor over a byte slice. Every read is bounds checked. Integers are big-endian (the PSD
|
||||
/// byte order) unless the reader was made with [`Reader::new_little`], which only the TIFF
|
||||
/// byte-order transcoder uses ([`crate::tiff`]).
|
||||
|
||||
@@ -250,15 +250,16 @@ pub(crate) fn write_section(out: &mut Vec<u8>, res: &[ImageResource]) -> Result<
|
||||
out.end_len(at, false)
|
||||
}
|
||||
|
||||
/// Builds the data for a 1057 version info resource.
|
||||
/// Builds the data for a 1057 version info resource, with the writer and reader names Photoshop
|
||||
/// 2026 writes ("Adobe Photoshop", "Adobe Photoshop 2026"), without a NUL terminator.
|
||||
pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource {
|
||||
ImageResource::new(
|
||||
ids::VERSION_INFO,
|
||||
VersionInfo {
|
||||
version: 1,
|
||||
has_real_merged_data,
|
||||
writer: UnicodeString::new_nul("Adobe Photoshop"),
|
||||
reader: UnicodeString::new_nul("Adobe Photoshop CS6"),
|
||||
writer: UnicodeString::new("Adobe Photoshop"),
|
||||
reader: UnicodeString::new("Adobe Photoshop 2026"),
|
||||
file_version: 1,
|
||||
}
|
||||
.to_bytes(),
|
||||
@@ -365,4 +366,17 @@ mod tests {
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte for byte what Photoshop 2026 writes (every Photoshop-authored oracle in the corpus):
|
||||
/// u32 count + UTF-16BE, no NUL terminator.
|
||||
#[test]
|
||||
fn version_info_matches_photoshop_bytes() {
|
||||
let mut want = vec![0, 0, 0, 1, 1];
|
||||
for s in ["Adobe Photoshop", "Adobe Photoshop 2026"] {
|
||||
want.extend_from_slice(&(s.len() as u32).to_be_bytes());
|
||||
want.extend(s.encode_utf16().flat_map(u16::to_be_bytes));
|
||||
}
|
||||
want.extend_from_slice(&[0, 0, 0, 1]);
|
||||
assert_eq!(version_info_resource(true).data, want);
|
||||
}
|
||||
}
|
||||
@@ -148,3 +148,402 @@ proptest! {
|
||||
prop_assert_eq!(p.to_bytes().unwrap(), b);
|
||||
}
|
||||
}
|
||||
|
||||
// The three standalone swatch/gradient parsers sit outside `PsdFile::from_bytes`, so their
|
||||
// round-trip invariants and no-panic guarantee get their own properties here. `write` covers only
|
||||
// what each format can express, so each property states the invariant that actually holds instead
|
||||
// of claiming byte equality everywhere.
|
||||
|
||||
/// A position or midpoint the writer can actually store: the file keeps 1/4096ths, so generating
|
||||
/// arbitrary `0.0..=1.0` values would make every property fail on quantisation, not on a bug.
|
||||
fn unit() -> impl Strategy<Value = f32> {
|
||||
(0.0f32..=1.0).prop_map(|v| (v * 4096.0).round() / 4096.0)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
|
||||
#[test]
|
||||
fn grd_roundtrip_keeps_stops_in_range(
|
||||
stops in proptest::collection::vec((unit(), unit(), any::<u8>()), 0..12),
|
||||
opacity in proptest::collection::vec((unit(), unit(), unit()), 0..12),
|
||||
) {
|
||||
use photocraft_psd::grd::{self, GrdColor, GrdGradient, GrdStop};
|
||||
let g = GrdGradient {
|
||||
name: "p".into(),
|
||||
noise: false,
|
||||
stops: stops.iter().map(|&(l, m, tone)| GrdStop {
|
||||
location: l,
|
||||
midpoint: m,
|
||||
color: GrdColor::Rgb([tone as f32 / 255.0, 0.0, 0.0]),
|
||||
}).collect(),
|
||||
opacity: opacity.clone(),
|
||||
};
|
||||
let bytes = grd::write(&[g]);
|
||||
let parsed = grd::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(parsed.len(), 1);
|
||||
let back = &parsed[0];
|
||||
prop_assert_eq!(back.stops.len(), stops.len());
|
||||
prop_assert_eq!(back.opacity.len(), opacity.len());
|
||||
for (a, b) in back.stops.iter().zip(&stops) {
|
||||
prop_assert!(a.location.is_finite() && (0.0..=1.0).contains(&a.location));
|
||||
prop_assert!((a.location - b.0).abs() < 1.0 / 4096.0);
|
||||
prop_assert!(a.midpoint.is_finite() && (0.0..=1.0).contains(&a.midpoint));
|
||||
prop_assert!((a.midpoint - b.1).abs() < 0.01);
|
||||
// The colour was written, so it must come back: a parser that zeroed every colour
|
||||
// would pass a property that only looked at positions.
|
||||
let GrdColor::Rgb([r, g, bl]) = a.color else {
|
||||
prop_assert!(false, "colour must survive the round trip");
|
||||
return Ok(());
|
||||
};
|
||||
prop_assert!((r - b.2 as f32 / 255.0).abs() < 1.0 / 255.0, "red channel {r} lost");
|
||||
prop_assert!(g.is_finite() && g.abs() < 1.0 / 255.0, "green channel {g} corrupted");
|
||||
prop_assert!(bl.is_finite() && bl.abs() < 1.0 / 255.0, "blue channel {bl} corrupted");
|
||||
}
|
||||
for (a, b) in back.opacity.iter().zip(&opacity) {
|
||||
prop_assert!(a.0.is_finite() && (0.0..=1.0).contains(&a.0));
|
||||
prop_assert!((0.0..=1.0).contains(&a.1));
|
||||
prop_assert!(a.2.is_finite() && (0.0..=1.0).contains(&a.2));
|
||||
prop_assert!((a.0 - b.0).abs() < 1.0 / 4096.0);
|
||||
prop_assert!((a.1 - b.1).abs() < 0.01);
|
||||
prop_assert!((a.2 - b.2).abs() < 0.01, "transparency midpoint not preserved");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_roundtrip_is_byte_stable_after_one_write(
|
||||
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>()), 0..10),
|
||||
) {
|
||||
use photocraft_psd::ase::{self, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
|
||||
let mk = |(name, tone): &(String, u8)| AseSwatch {
|
||||
name: name.clone(),
|
||||
color: AseColor::Rgb([*tone as f32 / 255.0, 0.0, 0.0]),
|
||||
kind: AseKind::Process,
|
||||
};
|
||||
let file = AseFile {
|
||||
entries: vec![
|
||||
AseEntry::Group { name: "g".into(), swatches: swatches.iter().take(3).map(mk).collect() },
|
||||
AseEntry::Group { name: "h".into(), swatches: swatches.iter().skip(3).map(mk).collect() },
|
||||
],
|
||||
};
|
||||
let bytes = ase::write(&file).unwrap();
|
||||
let back = ase::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(back.swatch_count(), swatches.len());
|
||||
prop_assert_eq!(ase::write(&back).unwrap(), bytes, "a second round trip is byte-stable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aco_roundtrip_is_byte_stable(
|
||||
swatches in proptest::collection::vec(("[ -~]{0,20}", any::<u8>(), any::<u16>()), 0..10),
|
||||
) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
let s: Vec<AcoSwatch> = swatches.iter().map(|(name, space, v)| AcoSwatch {
|
||||
name: name.clone(),
|
||||
color: match space % 3 {
|
||||
0 => AcoColor::Rgb([*v, 0, 0]),
|
||||
1 => AcoColor::Gray(*v),
|
||||
_ => AcoColor::Lab { l: *v, a: -12800, b: 12700 },
|
||||
},
|
||||
}).collect();
|
||||
let bytes = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(&back.swatches, &s);
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), bytes, "a second round trip is byte-stable");
|
||||
}
|
||||
|
||||
/// A name longer than the cap is truncated on the way in, so the file stays writable and the
|
||||
/// bytes settle after one round trip.
|
||||
#[test]
|
||||
fn aco_long_names_settle_after_one_truncation(len in 1024usize..4000) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
let s = vec![AcoSwatch { name: "x".repeat(len), color: AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&once).unwrap();
|
||||
prop_assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
|
||||
}
|
||||
|
||||
/// Pure junk rarely gets past the signature check, so most of each parser is never reached.
|
||||
/// These damage files that are already valid instead, which is where the interesting inputs
|
||||
/// live: well-formed structure with one byte corrupted.
|
||||
#[test]
|
||||
fn swatch_parsers_never_panic_on_damaged_valid_files(
|
||||
idx in 0usize..4000,
|
||||
bit in 0u32..8,
|
||||
mask in 0u8..=255,
|
||||
) {
|
||||
use photocraft_psd::{aco, ase, grd};
|
||||
let grd_bytes = {
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop, write};
|
||||
write(&[GrdGradient {
|
||||
name: "d".into(),
|
||||
noise: false,
|
||||
stops: vec![GrdStop { location: 0.25, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.5, 0.0]) }],
|
||||
opacity: vec![(0.5, 1.0, 0.5)],
|
||||
}])
|
||||
};
|
||||
let ase_bytes = {
|
||||
use photocraft_psd::ase::{write, AseColor, AseEntry, AseFile, AseKind, AseSwatch};
|
||||
let sw = || AseSwatch { name: "d".into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
write(&AseFile {
|
||||
entries: vec![AseEntry::Group { name: "g".into(), swatches: vec![sw()] }, AseEntry::Swatch(sw())],
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let aco_bytes = aco::write(&[aco::AcoSwatch { name: "d".into(), color: aco::AcoColor::Rgb([1, 2, 3]) }]).unwrap();
|
||||
|
||||
let damage = |mut b: Vec<u8>| -> Vec<u8> {
|
||||
if !b.is_empty() {
|
||||
let i = idx % b.len();
|
||||
b[i] ^= 1 << bit;
|
||||
b[i] = b[i].wrapping_add(mask);
|
||||
}
|
||||
b
|
||||
};
|
||||
let _ = grd::parse(&damage(grd_bytes));
|
||||
let _ = ase::parse(&damage(ase_bytes));
|
||||
let _ = aco::parse(&damage(aco_bytes));
|
||||
}
|
||||
|
||||
/// Names that are not ASCII, so the name-unit cap can land between the two halves of a
|
||||
/// surrogate pair. The cap counts UTF-16 code units, so that split is the boundary case.
|
||||
#[test]
|
||||
fn aco_non_ascii_names_are_capped_and_still_round_trip(len in 600usize..2600) {
|
||||
use photocraft_psd::aco::{self, AcoColor, AcoSwatch};
|
||||
// One leading ASCII character, then two-unit pairs. Without it the cap would always
|
||||
// fall on a pair boundary and the half-written case this property exists for would
|
||||
// never happen.
|
||||
let name = format!("a{}", "\u{1F600}".repeat(len / 2 + 1));
|
||||
let s = vec![AcoSwatch { name: name.clone(), color: AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&s).unwrap();
|
||||
let back = aco::parse(&once).unwrap();
|
||||
let got = back.swatches[0].name.encode_utf16().count();
|
||||
prop_assert!(got <= aco::MAX_NAME_UNITS, "name came back as {got} units");
|
||||
// The cap cuts on a character, so the result is a prefix of the input: whole characters
|
||||
// only, and never a lone surrogate.
|
||||
prop_assert!(name.starts_with(&back.swatches[0].name), "the capped name must be a prefix of the input");
|
||||
prop_assert_eq!(aco::write(&back.swatches).unwrap(), once);
|
||||
}
|
||||
|
||||
/// Arbitrary bytes must never panic in any of the three parsers, whatever the length.
|
||||
#[test]
|
||||
fn swatch_parsers_never_panic_on_arbitrary_bytes(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::grd::parse(&data);
|
||||
let _ = photocraft_psd::ase::parse(&data);
|
||||
let _ = photocraft_psd::aco::parse(&data);
|
||||
}
|
||||
}
|
||||
|
||||
// The five auxiliary binary parsers (image resources, metadata setting, patterns, filter-effects
|
||||
// caches and the per-block tagged-block views) parse structures that sit outside the whole-file
|
||||
// `PsdFile::from_bytes` path when read on their own. Their round-trip invariants and no-panic
|
||||
// guarantee get their own properties here, so normal CI covers them: the matching fuzz targets run
|
||||
// only on a manual trigger.
|
||||
|
||||
/// A small, valid pattern at a byte-aligned depth, generated the way the unit `pat()` helper builds
|
||||
/// one: `mode_channels(mode)` colour planes of `width × height × bytes-per-sample`, optional alpha.
|
||||
fn pattern_strategy() -> impl Strategy<Value = patterns::PsdPattern> {
|
||||
(prop_oneof![Just(1u32), Just(3u32), Just(4u32)], 1u32..8, 1u32..6, prop_oneof![Just(8u16), Just(16), Just(32)], any::<bool>(), any::<u64>()).prop_map(
|
||||
|(mode, width, height, depth, alpha, seed)| {
|
||||
let bpp = usize::from(depth / 8);
|
||||
let plane = |s: u64| (0..width as usize * height as usize * bpp).map(|i| (i as u64).wrapping_mul(31).wrapping_add(s) as u8).collect::<Vec<u8>>();
|
||||
let nc = patterns::mode_channels(mode);
|
||||
patterns::PsdPattern {
|
||||
mode,
|
||||
width,
|
||||
height,
|
||||
name: "p".into(),
|
||||
id: "0bd2d3ba-1234-11d4-8f8f-aabbccddeeff".into(),
|
||||
palette: None,
|
||||
depth,
|
||||
channels: (0..nc).map(|c| plane(seed.wrapping_add(c as u64 + 1))).collect(),
|
||||
alpha: alpha.then(|| plane(seed ^ 0xffff)),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(128))]
|
||||
|
||||
/// `shmd` is exact: Photoshop keeps each item's even padding inside its stored length, so a
|
||||
/// built block writes, parses and re-writes byte for byte.
|
||||
#[test]
|
||||
fn metadata_shmd_round_trip_is_exact(
|
||||
items in proptest::collection::vec((any::<[u8; 4]>(), proptest::collection::vec(any::<u8>(), 0..40)), 0..8),
|
||||
) {
|
||||
use photocraft_psd::metadata::{MetadataItem, parse_shmd, write_shmd};
|
||||
let items: Vec<MetadataItem> = items.into_iter().map(|(k, d)| MetadataItem::new(k, d)).collect();
|
||||
let bytes = write_shmd(&items);
|
||||
let back = parse_shmd(&bytes).unwrap();
|
||||
prop_assert_eq!(&back, &items);
|
||||
prop_assert_eq!(write_shmd(&back), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_shmd_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::metadata::parse_shmd(&data);
|
||||
}
|
||||
|
||||
/// A built pattern round-trips through both the `Patt`/`Pat2`/`Pat3` block and the `.pat` file,
|
||||
/// and re-serialising a parsed block is byte-stable.
|
||||
#[test]
|
||||
fn pattern_blocks_round_trip(ps in proptest::collection::vec(pattern_strategy(), 0..4)) {
|
||||
use photocraft_psd::patterns::{parse_pat_file, parse_pattern_block, write_pat_file, write_pattern_block};
|
||||
let blk = write_pattern_block(&ps).unwrap();
|
||||
prop_assert_eq!(parse_pattern_block(&blk).unwrap(), ps.clone());
|
||||
prop_assert_eq!(write_pattern_block(&parse_pattern_block(&blk).unwrap()).unwrap(), blk);
|
||||
let f = write_pat_file(&ps).unwrap();
|
||||
prop_assert_eq!(parse_pat_file(&f).unwrap(), ps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_parsers_never_panic(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
let _ = photocraft_psd::patterns::parse_pattern_block(&data);
|
||||
let _ = photocraft_psd::patterns::parse_pat_file(&data);
|
||||
}
|
||||
|
||||
/// A filter-effects cache with one or more items (each with cached slots and an optional mask)
|
||||
/// re-serialises and re-parses byte for byte, and the cached planes decode.
|
||||
#[test]
|
||||
fn filter_effects_round_trip(items in proptest::collection::vec(("[ -~]{0,8}", any::<bool>()), 0..4)) {
|
||||
use photocraft_psd::compression::Compression;
|
||||
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
|
||||
use photocraft_psd::layer::Rect;
|
||||
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
|
||||
let plane = |v: u8| EffectsPlane::encode(Compression::Rle, &[v; 16], 4, 4, 8).unwrap();
|
||||
let items: Vec<FilterEffectsItem> = items
|
||||
.iter()
|
||||
.map(|(id, mask)| {
|
||||
let mut slots = vec![None; 26];
|
||||
slots[0] = Some(plane(10));
|
||||
slots[25] = Some(plane(255));
|
||||
FilterEffectsItem { id: id.clone(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) }
|
||||
})
|
||||
.collect();
|
||||
let fx = FilterEffects { version: 3, items };
|
||||
let bytes = fx.to_bytes();
|
||||
let back = FilterEffects::parse(&bytes).unwrap();
|
||||
prop_assert_eq!(&back, &fx);
|
||||
prop_assert_eq!(back.to_bytes(), bytes);
|
||||
for it in &back.items {
|
||||
if let Some((_, m)) = it.decoded_mask() {
|
||||
prop_assert_eq!(m, vec![128; 16]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_effects_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
if let Ok(fx) = photocraft_psd::filter_effects::FilterEffects::parse(&data) {
|
||||
for it in &fx.items {
|
||||
let _ = it.decoded_mask();
|
||||
}
|
||||
let _ = fx.to_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolution info is 16 fixed-point bytes; a built value survives a byte round trip.
|
||||
#[test]
|
||||
fn resolution_info_round_trips(dpi in 1.0f64..=10_000.0) {
|
||||
use photocraft_psd::resources::ResolutionInfo;
|
||||
let ri = ResolutionInfo::from_dpi(dpi);
|
||||
prop_assert_eq!(ResolutionInfo::from_bytes(&ri.to_bytes()).unwrap(), ri);
|
||||
}
|
||||
|
||||
/// The typed view of an arbitrary resource id over arbitrary data never panics.
|
||||
#[test]
|
||||
fn resource_parsed_never_panics(id in any::<u16>(), data in proptest::collection::vec(any::<u8>(), 0..64)) {
|
||||
use photocraft_psd::resources::ImageResource;
|
||||
let r = ImageResource::new(id, data);
|
||||
let _ = r.parsed();
|
||||
let _ = r.xmp_str();
|
||||
let _ = photocraft_psd::resources::VersionInfo::from_bytes(&r.data);
|
||||
}
|
||||
|
||||
/// `parsed` (typed) and `check_structure` (strict inner re-parse) over an arbitrary key and
|
||||
/// arbitrary data never panic, only ever returning `Ok` / `Err` / `None`.
|
||||
#[test]
|
||||
fn tagged_block_views_never_panic(key in any::<[u8; 4]>(), data in proptest::collection::vec(any::<u8>(), 0..200)) {
|
||||
let b = TaggedBlock::new(key, data);
|
||||
let _ = b.parsed();
|
||||
let _ = b.check_structure();
|
||||
}
|
||||
|
||||
/// Each well-known typed key over arbitrary data: every typed branch is reached and none panics.
|
||||
#[test]
|
||||
fn tagged_known_keys_never_panic(ki in 0usize..13, data in proptest::collection::vec(any::<u8>(), 0..64)) {
|
||||
const KEYS: [&[u8; 4]; 13] =
|
||||
[b"luni", b"lsct", b"lsdk", b"lyid", b"lnsr", b"clbl", b"infx", b"knko", b"tsly", b"lmgm", b"vmgm", b"lspf", b"lclr"];
|
||||
let b = TaggedBlock::new(*KEYS[ki], data);
|
||||
let _ = b.parsed();
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// Pure random bytes rarely get past a header, so this damages a *valid* block one byte at a
|
||||
/// time -- well-formed structure with one byte corrupted, where the interesting inputs live --
|
||||
/// across the metadata, pattern and filter-effects parsers. None may panic.
|
||||
#[test]
|
||||
fn aux_parsers_never_panic_on_damaged_valid_blocks(idx in 0usize..4000, bit in 0u32..8, add in 0u8..=255) {
|
||||
use photocraft_psd::compression::Compression;
|
||||
use photocraft_psd::filter_effects::{EffectsPlane, FilterEffects, FilterEffectsItem};
|
||||
use photocraft_psd::layer::Rect;
|
||||
use photocraft_psd::metadata::{self, MetadataItem};
|
||||
use photocraft_psd::patterns::{self, PsdPattern};
|
||||
|
||||
let shmd = metadata::write_shmd(&[MetadataItem::new(*b"cmls", vec![1, 2, 3, 4]), MetadataItem::new(*b"cust", vec![9; 6])]);
|
||||
let pattern = PsdPattern {
|
||||
mode: 3,
|
||||
width: 4,
|
||||
height: 3,
|
||||
name: "d".into(),
|
||||
id: "0bd2d3ba".into(),
|
||||
palette: None,
|
||||
depth: 8,
|
||||
channels: vec![vec![7u8; 12], vec![8u8; 12], vec![9u8; 12]],
|
||||
alpha: None,
|
||||
};
|
||||
let pat_block = patterns::write_pattern_block(std::slice::from_ref(&pattern)).unwrap();
|
||||
let pat_file = patterns::write_pat_file(std::slice::from_ref(&pattern)).unwrap();
|
||||
let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 };
|
||||
let plane = EffectsPlane::encode(Compression::Rle, &[10u8; 16], 4, 4, 8).unwrap();
|
||||
let mut slots = vec![None; 26];
|
||||
slots[0] = Some(plane.clone());
|
||||
slots[25] = Some(plane.clone());
|
||||
let fx = FilterEffects {
|
||||
version: 3,
|
||||
items: vec![FilterEffectsItem { id: "x".into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: Some((rect, plane)) }],
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let damage = |mut b: Vec<u8>| -> Vec<u8> {
|
||||
if !b.is_empty() {
|
||||
let i = idx % b.len();
|
||||
b[i] ^= 1u8 << bit;
|
||||
b[i] = b[i].wrapping_add(add);
|
||||
}
|
||||
b
|
||||
};
|
||||
let _ = metadata::parse_shmd(&damage(shmd));
|
||||
let _ = patterns::parse_pattern_block(&damage(pat_block));
|
||||
let _ = patterns::parse_pat_file(&damage(pat_file));
|
||||
if let Ok(f) = FilterEffects::parse(&damage(fx)) {
|
||||
for it in &f.items {
|
||||
let _ = it.decoded_mask();
|
||||
}
|
||||
let _ = f.to_bytes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Every `check_structure` key over arbitrary data: the strict inner re-parse for placed layers,
|
||||
/// linked files, object effects and filter caches is entered and never panics.
|
||||
#[test]
|
||||
fn tagged_structural_keys_never_panic(ki in 0usize..8, data in proptest::collection::vec(any::<u8>(), 0..200)) {
|
||||
const KEYS: [&[u8; 4]; 8] = [b"PlLd", b"SoLd", b"SoLE", b"lnk2", b"lnk3", b"lnkD", b"lfx2", b"FEid"];
|
||||
let b = TaggedBlock::new(*KEYS[ki], data);
|
||||
let _ = b.check_structure();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
//! `.grd`, `.ase` and `.aco` hostile-input handling.
|
||||
//!
|
||||
//! These three parsers are standalone entry points that `PsdFile::from_bytes` never reaches
|
||||
//! (nothing in `file.rs` calls them), so the `parse.rs` fuzz target cannot exercise them. They are
|
||||
//! also user-reachable: `grd::parse` backs the gradient-preset import in
|
||||
//! `engine::preset_import_cmds`, and `ase`/`aco` back swatch import and export. Each module has its
|
||||
//! own `hostile_input` unit test, but those assert `let _ = parse(...)` on mutations: a panic would
|
||||
//! abort the test without saying which parser and which byte caused it.
|
||||
//!
|
||||
//! What this file adds:
|
||||
//! 1. every single-bit flip in a valid file, each wrapped in `catch_unwind` so a panic is
|
||||
//! reported with the exact byte and parser rather than as an opaque abort (§0.8: "parsers and
|
||||
//! decoders get mutation-fuzz tests run under `catch_unwind`");
|
||||
//! 2. the round-trip and cap invariants the fuzz targets assert, checked deterministically so CI
|
||||
//! covers them even though the `fuzz` job is workflow-dispatch only;
|
||||
//! 3. inputs sized to *exceed* each advertised cap, because a cap asserted only against a file
|
||||
//! that fits under it is a tautology — it cannot fail, so it proves nothing.
|
||||
//!
|
||||
//! The rule under test throughout: hostile input returns `Err`, never a panic and never an
|
||||
//! unbounded allocation.
|
||||
|
||||
use photocraft_psd::aco::{AcoColor, AcoSwatch};
|
||||
use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop};
|
||||
use photocraft_psd::{PsdError, aco, ase, grd};
|
||||
use std::panic::{RefUnwindSafe, UnwindSafe, catch_unwind};
|
||||
|
||||
fn sample_ase() -> Vec<u8> {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch as Sw, write};
|
||||
let sw = |name: &str| Sw { name: name.into(), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
write(&AseFile { entries: vec![AseEntry::Group { name: "Brand".into(), swatches: vec![sw("Red"), sw("Blue")] }, AseEntry::Swatch(sw("Loose"))] })
|
||||
.expect("writing a sample .ase")
|
||||
}
|
||||
|
||||
fn sample_aco_swatches() -> Vec<AcoSwatch> {
|
||||
vec![
|
||||
AcoSwatch { name: "Pure Red".into(), color: AcoColor::Rgb([65535, 0, 0]) },
|
||||
AcoSwatch { name: "Cyan ink".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) },
|
||||
AcoSwatch { name: "Lab".into(), color: AcoColor::Lab { l: 5321, a: -12800, b: 12700 } },
|
||||
AcoSwatch { name: "Gray".into(), color: AcoColor::Gray(5000) },
|
||||
AcoSwatch { name: "Book".into(), color: AcoColor::Other { space: 3, values: [1, 2, 3, 4] } },
|
||||
]
|
||||
}
|
||||
|
||||
fn sample_aco() -> Vec<u8> {
|
||||
aco::write(&sample_aco_swatches()).expect("writing a sample .aco")
|
||||
}
|
||||
|
||||
fn sample_grd() -> Vec<u8> {
|
||||
use photocraft_psd::grd::write;
|
||||
write(&[
|
||||
GrdGradient {
|
||||
name: "Sunset".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Background },
|
||||
],
|
||||
opacity: vec![(0.0, 1.0, 0.5), (1.0, 0.25, 0.5)],
|
||||
},
|
||||
GrdGradient { name: "Noisy".into(), noise: true, stops: Vec::new(), opacity: Vec::new() },
|
||||
])
|
||||
}
|
||||
|
||||
/// Flips every single bit of a valid file and requires that the parser returns or fails without
|
||||
/// panicking. A flipped length or count must never be trusted into an allocation.
|
||||
fn bitflip_sweep<T, F>(bytes: &[u8], name: &str, parse: F)
|
||||
where
|
||||
F: Fn(&[u8]) -> Result<T, PsdError> + RefUnwindSafe + UnwindSafe,
|
||||
{
|
||||
for byte in 0..bytes.len() {
|
||||
for bit in 0..8 {
|
||||
let mut b = bytes.to_vec();
|
||||
b[byte] ^= 1 << bit;
|
||||
let r = catch_unwind(|| parse(&b));
|
||||
assert!(r.is_ok(), "{name}: parse panicked after flipping bit {bit} of byte {byte} of {}", bytes.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ase_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_ase(), "ase", ase::parse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aco_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_aco(), "aco", aco::parse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_bitflips_never_panic() {
|
||||
bitflip_sweep(&sample_grd(), "grd", grd::parse);
|
||||
}
|
||||
|
||||
fn assert_finite(values: &[f32], what: &str) {
|
||||
for v in values {
|
||||
assert!(v.is_finite(), "{what} component {v} is not finite");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every number a parsed gradient carries must be finite and inside the ranges the module
|
||||
/// documents, so a caller never divides by or formats a NaN and never reads past a 0..=1 bound.
|
||||
fn assert_gradient_sane(g: &GrdGradient) {
|
||||
for s in &g.stops {
|
||||
assert!(s.location.is_finite() && (0.0..=1.0).contains(&s.location), "stop location {} out of range", s.location);
|
||||
assert!(s.midpoint.is_finite() && (0.0..=1.0).contains(&s.midpoint), "stop midpoint {} out of range", s.midpoint);
|
||||
match s.color {
|
||||
GrdColor::Rgb(v) => assert_finite(&v, "RGB"),
|
||||
GrdColor::Hsb(v) => assert_finite(&v, "HSB"),
|
||||
GrdColor::Cmyk(v) => assert_finite(&v, "CMYK"),
|
||||
GrdColor::Lab(v) => assert_finite(&v, "Lab"),
|
||||
GrdColor::Gray(v) => assert!(v.is_finite(), "gray {v} is not finite"),
|
||||
GrdColor::Foreground | GrdColor::Background => {}
|
||||
}
|
||||
}
|
||||
for &(l, o, m) in &g.opacity {
|
||||
assert!(l.is_finite() && (0.0..=1.0).contains(&l), "transparency location {l} out of range");
|
||||
assert!((0.0..=1.0).contains(&o), "opacity {o} outside 0..=1");
|
||||
assert!(m.is_finite() && (0.0..=1.0).contains(&m), "transparency midpoint {m} out of range");
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient file that parses must obey the caps the module advertises and keep every numeric
|
||||
/// field finite, so callers never divide by or format a NaN.
|
||||
#[test]
|
||||
fn grd_parsed_values_respect_caps_and_are_finite() {
|
||||
// No cap assertion here: the sample has two gradients and two stops, so a cap check would
|
||||
// hold for any writer and prove nothing. `grd_gradient_count_is_capped` and
|
||||
// `grd_stop_counts_are_capped` drive inputs over the caps instead.
|
||||
for g in &grd::parse(&sample_grd()).expect("sample .grd parses") {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// A file declaring more gradients than [`grd::MAX_GRADIENTS`] must be capped at the cap, not
|
||||
/// trusted: the cap is the only thing between a file's own list length and the allocation it asks
|
||||
/// for. The input deliberately exceeds the cap, otherwise the assertion below cannot fail.
|
||||
#[test]
|
||||
fn grd_gradient_count_is_capped() {
|
||||
let over = vec![GrdGradient { name: "g".into(), ..Default::default() }; grd::MAX_GRADIENTS + 1];
|
||||
let bytes = grd::write(&over);
|
||||
// Precondition: the file really must carry more gradients than the cap, or the assertion
|
||||
// below would hold for the wrong reason (the writer capping rather than the reader).
|
||||
// Precondition, by size: a file carrying one gradient more than the cap must be larger than
|
||||
// the same file at the cap. Counting `Grdn` windows would not work — the object nests — and
|
||||
// would keep passing even if the writer started capping, which is what this must catch.
|
||||
let at_cap = grd::write(&over[..grd::MAX_GRADIENTS]).len();
|
||||
assert!(bytes.len() > at_cap, "the written file is not larger than the capped one: {} vs {at_cap}", bytes.len());
|
||||
let f = grd::parse(&bytes).expect("a file over the gradient cap still parses");
|
||||
assert_eq!(f.len(), grd::MAX_GRADIENTS, "gradients must be capped at MAX_GRADIENTS, not taken as declared");
|
||||
}
|
||||
|
||||
/// Same for the per-gradient stop lists: colour stops and transparency stops are capped
|
||||
/// separately, so both are driven over their cap.
|
||||
#[test]
|
||||
fn grd_stop_counts_are_capped() {
|
||||
let stop = GrdStop { location: 0.5, midpoint: 0.5, color: GrdColor::Rgb([1.0, 0.0, 0.0]) };
|
||||
let over = GrdGradient { name: "wide".into(), noise: false, stops: vec![stop; grd::MAX_STOPS + 1], opacity: vec![(0.5, 1.0, 0.5); grd::MAX_STOPS + 1] };
|
||||
let bytes = grd::write(std::slice::from_ref(&over));
|
||||
// Precondition by size: a file carrying one stop more than the cap must be larger than the
|
||||
// same file at the cap. Counting `Lctn` windows would not work — it appears in both the colour
|
||||
// and the transparency list — and would keep passing even if the writer started capping,
|
||||
// which is exactly what this must catch. Each list is driven on its own, since they are
|
||||
// capped independently.
|
||||
let capped_stops = over.stops[..grd::MAX_STOPS].to_vec();
|
||||
let capped_opacity = over.opacity[..grd::MAX_STOPS].to_vec();
|
||||
let colour_only_over = GrdGradient { opacity: Vec::new(), ..over.clone() };
|
||||
let colour_only_capped = GrdGradient { stops: capped_stops, opacity: Vec::new(), ..over.clone() };
|
||||
assert!(grd::write(&[colour_only_over]).len() > grd::write(&[colour_only_capped]).len(), "colour stops over the cap are not larger than at the cap");
|
||||
let trans_only_over = GrdGradient { stops: Vec::new(), ..over.clone() };
|
||||
let trans_only_capped = GrdGradient { stops: Vec::new(), opacity: capped_opacity, ..over };
|
||||
assert!(grd::write(&[trans_only_over]).len() > grd::write(&[trans_only_capped]).len(), "transparency stops over the cap are not larger than at the cap");
|
||||
let f = grd::parse(&bytes).expect("a file over the stop cap still parses");
|
||||
assert_eq!(f[0].stops.len(), grd::MAX_STOPS, "colour stops must be capped at MAX_STOPS");
|
||||
assert_eq!(f[0].opacity.len(), grd::MAX_STOPS, "transparency stops must be capped at MAX_STOPS");
|
||||
}
|
||||
|
||||
/// `Opct` is a unit float (`UntF` + a 4-byte unit tag), not a `doub`, so patching it needs a
|
||||
/// different offset from the RGB components. Every numeric field now passes through `finite()`
|
||||
/// before any clamp, so this pins that a non-finite value is repaired first — saturated, or for a
|
||||
/// NaN replaced by the field default — rather than reaching `f32::clamp`, which propagates NaN and
|
||||
/// would hand it straight back out of the parser. Every non-finite shape is tried at both places.
|
||||
#[test]
|
||||
fn grd_non_finite_opacity_and_midpoint_do_not_survive() {
|
||||
// 3.0e38 is finite as both f64 and f32, so it is not caught by the finiteness check and must
|
||||
// be brought into range by the saturate-then-clamp path; it belongs here to keep that honest.
|
||||
let bad = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, 3.0e38];
|
||||
// `doub`: key + 4-byte OSType + 8-byte value. `UntF`: key + OSType + 4-byte unit tag + value.
|
||||
// `Opct` is the interesting one: a unit float at a different offset from the RGB components, so
|
||||
// both the `doub` and `UntF` shapes are patched. `Mdpn` is written as an integer here, which
|
||||
// cannot carry a non-finite value at all, so it is skipped rather than pretended to be covered;
|
||||
// a hostile file storing `Mdpn` as a double is still guarded by `finite()` and by the fuzz
|
||||
// target's range assertion.
|
||||
for key in ["Opct", "Rd ", "Grn ", "Bl "] {
|
||||
for v in bad {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
|
||||
let skip = match &b[at + 4..at + 8] {
|
||||
b"doub" => 8usize,
|
||||
b"UntF" => 12usize,
|
||||
other => panic!("{key} is stored as {other:?}, which this test cannot patch"),
|
||||
};
|
||||
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
|
||||
let f = grd::parse(&b).unwrap_or_else(|e| panic!("{key} = {v} still parses: {e:?}"));
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `num` returns the raw f64 and `finite()` is the single guard every number passes through. This
|
||||
/// pins that one policy end to end: only a NaN is truly absent and falls back to the documented
|
||||
/// default (opacity 100 %, midpoint centred); every other shape — including `+/-inf` and a value
|
||||
/// finite as `f64` but above `f32::MAX` — saturates and then clamps, so a large positive lands at
|
||||
/// 1.0 and a large negative at 0.0, monotonic, never a snap back to the default.
|
||||
/// `Mdpn` is written as a `long` by this crate's own writer, so no fixture can smuggle a
|
||||
/// non-finite value into it and the guard inside `midpoint()` would never be exercised. The file
|
||||
/// format allows a `double` there, so the descriptor is built by hand with `Mdpn` as a `doub`:
|
||||
/// otherwise removing that guard changes nothing any test can see.
|
||||
#[test]
|
||||
fn grd_midpoint_stored_as_a_double_is_still_sanitised() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
for v in [f64::NAN, f64::INFINITY, 1e300, 3.0e38] {
|
||||
// Driven through `Trns`, where the reader keeps the midpoint, rather than through `Clrs`,
|
||||
// where a colour stop also needs a colour this descriptor does not carry.
|
||||
let trns = Descriptor::new("TrnS").with("Mdpn", Value::Double(v));
|
||||
let grad = Descriptor::new("Grdn").with("Trns", Value::List(vec![Value::Descriptor(trns)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
|
||||
let parsed = grd::parse(&g).unwrap_or_else(|e| panic!("Mdpn = {v} still parses: {e:?}"));
|
||||
let Some((_, _, m)) = parsed[0].opacity.first() else { panic!("the transparency stop was dropped") };
|
||||
// Pin the exact value, not just the range. Only a NaN is truly absent, so only it falls
|
||||
// back to the centred default; every other shape saturates and then clamps, so a large
|
||||
// positive lands at 1.0 and a large negative at 0.0 — monotonic, never a snap to 0.5.
|
||||
let expected = if v.is_nan() {
|
||||
0.5
|
||||
} else if v > 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
assert_eq!(*m, expected, "a midpoint of {v} must be {expected}, got {m}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `.aco` stores CMYK as *ink* amounts in memory and the complement on disk: the file holds 0 for
|
||||
/// 100 % ink and the reader adds the back. Nothing pinned that, so flipping either side would
|
||||
/// silently invert every CMYK swatch.
|
||||
#[test]
|
||||
fn aco_cmyk_is_stored_inverted_and_reads_back_as_ink() {
|
||||
let swatches = vec![AcoSwatch { name: "k".into(), color: AcoColor::Cmyk([65535, 0, 0, 1234]) }];
|
||||
let bytes = aco::write(&swatches).expect("writing one CMYK swatch");
|
||||
|
||||
// Version 1 section: version u16, count u16, then a 10-byte record.
|
||||
let space = u16::from_be_bytes([bytes[4], bytes[5]]);
|
||||
let stored: Vec<u16> = (0..4).map(|i| u16::from_be_bytes([bytes[6 + i * 2], bytes[7 + i * 2]])).collect();
|
||||
assert_eq!(space, aco::space::CMYK, "the record must be tagged CMYK");
|
||||
assert_eq!(stored, vec![0, u16::MAX, u16::MAX, u16::MAX - 1234], "the file stores the complement of the ink amounts");
|
||||
assert_eq!(aco::parse(&bytes).expect("the file parses").swatches, swatches, "the ink amounts must survive the round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grd_unrepresentable_numbers_follow_the_documented_policy() {
|
||||
// The *second* transparency stop carries opacity 0.25; the first is already 1.0, so patching
|
||||
// it could not tell "fell back to the default" from "the patch had no effect".
|
||||
let patch = |key: &[u8], v: f64| {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).rposition(|w| w == key).expect("the sample stores the key");
|
||||
let skip = match &b[at + 4..at + 8] {
|
||||
b"doub" => 8usize,
|
||||
b"UntF" => 12usize,
|
||||
other => panic!("stored as {other:?}"),
|
||||
};
|
||||
b[at + skip..at + skip + 8].copy_from_slice(&v.to_be_bytes());
|
||||
grd::parse(&b).expect("still parses")
|
||||
};
|
||||
// Opacity. A NaN is the only truly absent value, so it alone falls back to the 100 % default.
|
||||
// Every other shape saturates then clamps, which is monotonic: a large positive stays opaque,
|
||||
// a large negative becomes transparent. Nothing snaps the wrong way as the number grows.
|
||||
for (v, expected) in [
|
||||
(f64::NAN, 1.0), // absent -> the field default (opaque)
|
||||
(f64::INFINITY, 1.0), // saturates to the top of the range
|
||||
(f64::NEG_INFINITY, 0.0), // saturates to the bottom
|
||||
(1e300, 1.0), // over f32::MAX -> saturates up
|
||||
(-1e300, 0.0), // under -f32::MAX -> saturates down
|
||||
(3.0e38, 1.0), // finite as f32 -> clamps up
|
||||
(-3.0e38, 0.0), // finite as f32 -> clamps down
|
||||
] {
|
||||
let got = patch(b"Opct", v)[0].opacity[1].1;
|
||||
assert_eq!(got, expected, "Opct = {v} must read back as {expected}, got {got}");
|
||||
}
|
||||
// A colour channel has its own default (zero) for the absent case, and the same monotonic
|
||||
// saturate-then-clamp for everything else. Pin the exact number: a value reaching the clamp
|
||||
// from above would come back as 1.0, which is in range and would slip past a range-only check.
|
||||
for (v, expected) in [
|
||||
(f64::NAN, 0.0), // absent -> the channel default
|
||||
(f64::INFINITY, 1.0), // saturates up, then clamps to 1.0
|
||||
(f64::NEG_INFINITY, 0.0), // saturates down, then clamps to 0.0
|
||||
(1e300, 1.0), // over f32::MAX -> saturates up
|
||||
(-1e300, 0.0), // under -f32::MAX -> saturates down
|
||||
(3.0e38, 1.0), // finite as f32 -> clamps up
|
||||
(-3.0e38, 0.0), // finite as f32 -> clamps down
|
||||
] {
|
||||
let f = patch(b"Bl ", v);
|
||||
let GrdColor::Rgb([_, _, b]) = f[0].stops[0].color else { panic!("expected an RGB stop") };
|
||||
assert_eq!(b, expected, "a blue component of {v} must be {expected}, got {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name long enough that the code-unit cap lands *between* the halves of a surrogate pair must
|
||||
/// not be cut in half: a lone surrogate is ill-formed UTF-16, and writing it would make the file
|
||||
/// unreadable to anything strict. Capping has to drop the whole character instead, and the file
|
||||
/// must then survive its own round trip unchanged.
|
||||
#[test]
|
||||
fn aco_and_ase_never_split_a_surrogate_pair() {
|
||||
// "a" plus 600 emoji: 1 + 1200 units, so a 1024-unit cap lands mid-pair.
|
||||
let name = format!("a{}", "\u{1F600}".repeat(600));
|
||||
assert!(name.encode_utf16().count() > ase::MAX_NAME_UNITS, "the name must be longer than the cap");
|
||||
|
||||
let swatches = vec![aco::AcoSwatch { name: name.clone(), color: aco::AcoColor::Gray(5000) }];
|
||||
let once = aco::write(&swatches).expect("writing a long non-ASCII name is capped, not refused");
|
||||
let back = aco::parse(&once).expect("the written file parses");
|
||||
// The exact expected name, not merely "it is valid UTF-16": the cap must drop the whole
|
||||
// character that does not fit, leaving "a" plus 511 complete pairs and nothing half-written.
|
||||
let expected = format!("a{}", "\u{1F600}".repeat(511));
|
||||
assert_eq!(back.swatches[0].name, expected, "the capped name must be whole characters only");
|
||||
assert_eq!(aco::write(&back.swatches).unwrap(), once, "the file must be stable across a round trip");
|
||||
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
|
||||
let file = AseFile { entries: vec![AseEntry::Swatch(AseSwatch { name, color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process })] };
|
||||
let a1 = write(&file).expect("writing a long non-ASCII name is capped, not refused");
|
||||
let ab = ase::parse(&a1).expect("the written file parses");
|
||||
let ase::AseEntry::Swatch(s) = &ab.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name, expected, "the capped name must be whole characters only");
|
||||
assert_eq!(write(&ab).unwrap(), a1, "the file must be stable across a round trip");
|
||||
}
|
||||
|
||||
/// Locations, midpoints and opacity are documented as 0..=1. A hostile file stores raw integers,
|
||||
/// so the values are driven well outside that range and must come back clamped.
|
||||
#[test]
|
||||
fn grd_out_of_range_numbers_are_clamped() {
|
||||
let g = GrdGradient {
|
||||
name: "out of range".into(),
|
||||
noise: false,
|
||||
stops: vec![
|
||||
GrdStop { location: 2.0, midpoint: 5.0, color: GrdColor::Rgb([1.0, 0.0, 0.0]) },
|
||||
GrdStop { location: -3.0, midpoint: -1.0, color: GrdColor::Rgb([0.0, 1.0, 0.0]) },
|
||||
],
|
||||
opacity: vec![(9.0, 5.0, 7.0), (-2.0, -1.0, 42.0)],
|
||||
};
|
||||
let f = grd::parse(&grd::write(&[g])).expect("out-of-range numbers still parse");
|
||||
assert_gradient_sane(&f[0]);
|
||||
// Range membership alone would be a tautology once the values are already in range: assert the
|
||||
// exact clamped result, so a clamp silently degrading into a pass-through is caught. Positive
|
||||
// over-range -> 1.0, negative -> 0.0.
|
||||
assert_eq!(f[0].stops[0].location, 1.0, "location 2.0 must clamp to 1.0");
|
||||
assert_eq!(f[0].stops[0].midpoint, 1.0, "midpoint 5.0 must clamp to 1.0");
|
||||
assert_eq!(f[0].stops[1].location, 0.0, "location -3.0 must clamp to 0.0");
|
||||
assert_eq!(f[0].stops[1].midpoint, 0.0, "midpoint -1.0 must clamp to 0.0");
|
||||
assert_eq!(f[0].opacity[0], (1.0, 1.0, 1.0), "all-positive over-range opacity must clamp to 1.0");
|
||||
assert_eq!(f[0].opacity[1], (0.0, 0.0, 1.0), "location/opacity clamp to 0.0, midpoint 42.0 to 1.0");
|
||||
}
|
||||
|
||||
/// `num` returns the raw f64 unfiltered; `finite()` is the single guard, and a NaN is not the only
|
||||
/// non-finite f32 it has to handle: a value that is finite as f64 and above `f32::MAX` (1e300)
|
||||
/// would become an infinity on a bare cast, so `finite()` saturates it. Every numeric field must
|
||||
/// come back finite, so both doors are checked.
|
||||
#[test]
|
||||
fn grd_huge_f64_does_not_overflow_to_infinity() {
|
||||
for key in ["Rd ", "Grn ", "Bl "] {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == key.as_bytes()).unwrap_or_else(|| panic!("the sample stores {key:?}"));
|
||||
assert_eq!(&b[at + 4..at + 8], b"doub", "{key} is stored as a double");
|
||||
b[at + 8..at + 16].copy_from_slice(&1e300f64.to_be_bytes());
|
||||
let f = grd::parse(&b).expect("a huge but finite component still parses");
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hostile file carrying a NaN must have it replaced, not handed on: `Rd ` in the sample is a
|
||||
/// double, so patching those eight bytes is enough to try to smuggle a NaN past the reader.
|
||||
#[test]
|
||||
fn grd_non_finite_numbers_do_not_survive_the_reader() {
|
||||
let mut b = sample_grd();
|
||||
let at = b.windows(4).position(|w| w == b"Rd ").expect("the sample stores an RGB component");
|
||||
assert_eq!(&b[at + 4..at + 8], b"doub", "an RGB component is stored as a double");
|
||||
b[at + 8..at + 16].copy_from_slice(&f64::NAN.to_be_bytes());
|
||||
let f = grd::parse(&b).expect("a NaN component still parses");
|
||||
assert!(!f.is_empty());
|
||||
for g in &f {
|
||||
assert_gradient_sane(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// An `.ase` that parses must stay within the swatch cap and re-serialize to the same number of
|
||||
/// colours, so an import never silently drops or invents entries.
|
||||
#[test]
|
||||
fn ase_round_trip_preserves_swatch_count() {
|
||||
use photocraft_psd::ase::write;
|
||||
let f = ase::parse(&sample_ase()).expect("sample .ase parses");
|
||||
// No cap assertion: three swatches cannot exceed a cap of 100 000. `ase_swatch_cap_is_enforced_
|
||||
// on_hostile_input` drives a file that does.
|
||||
let n = f.swatch_count();
|
||||
let out = write(&f).expect("re-writing a parsed .ase");
|
||||
let back = ase::parse(&out).expect("the rewritten .ase parses");
|
||||
assert_eq!(back.swatch_count(), n, "swatch count changed across a round trip");
|
||||
}
|
||||
|
||||
/// A file with more colour blocks than [`ase::MAX_SWATCHES`] must be refused. Each block here is
|
||||
/// the smallest legal colour block, so the file is small but the declared count is over the cap.
|
||||
#[test]
|
||||
fn ase_swatch_cap_is_enforced_on_hostile_input() {
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&0u16.to_be_bytes()); // name length 0
|
||||
body.extend_from_slice(b"Gray");
|
||||
body.extend_from_slice(&0f32.to_be_bytes()); // gray component
|
||||
body.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
|
||||
let mut block = Vec::new();
|
||||
block.extend_from_slice(&1u16.to_be_bytes()); // block type: colour entry
|
||||
block.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
block.extend_from_slice(&body);
|
||||
|
||||
let total = ase::MAX_SWATCHES + 1;
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // major
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // minor
|
||||
f.extend_from_slice(&(total as u32).to_be_bytes());
|
||||
for _ in 0..total {
|
||||
f.extend_from_slice(&block);
|
||||
}
|
||||
assert!(matches!(ase::parse(&f), Err(PsdError::LimitExceeded(_))), "a file over MAX_SWATCHES must be refused, not silently truncated");
|
||||
// The same file one block under the cap is accepted, so the cap is what refuses it.
|
||||
f.truncate(f.len() - block.len());
|
||||
let trimmed = total - 1;
|
||||
// signature (4) + major (2) + minor (2) puts the block count at offset 8.
|
||||
f[8..12].copy_from_slice(&(trimmed as u32).to_be_bytes());
|
||||
assert!(ase::parse(&f).is_ok(), "a file exactly at MAX_SWATCHES must still parse");
|
||||
}
|
||||
|
||||
/// A name longer than [`ase::MAX_NAME_UNITS`] is truncated on read rather than refused: the file
|
||||
/// is legal, only the name is long, and `read_name` is the only thing standing between the declared
|
||||
/// length and the string it builds.
|
||||
#[test]
|
||||
fn ase_over_long_name_is_truncated_on_read() {
|
||||
let units = ase::MAX_NAME_UNITS + 976; // 2000
|
||||
let mut body = Vec::new();
|
||||
body.extend_from_slice(&(units as u16).to_be_bytes()); // name length, no trailing null here
|
||||
for _ in 0..units {
|
||||
body.extend_from_slice(&(b'x' as u16).to_be_bytes());
|
||||
}
|
||||
body.extend_from_slice(b"Gray");
|
||||
body.extend_from_slice(&0f32.to_be_bytes());
|
||||
body.extend_from_slice(&2u16.to_be_bytes());
|
||||
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes());
|
||||
f.extend_from_slice(&0u16.to_be_bytes());
|
||||
f.extend_from_slice(&1u32.to_be_bytes()); // one block
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // colour entry
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
|
||||
let parsed = ase::parse(&f).expect("an over-long name is truncated, not refused");
|
||||
let ase::AseEntry::Swatch(s) = &parsed.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
|
||||
}
|
||||
|
||||
/// An `.aco` that parses must re-serialize to the same swatch count, with no name over the cap.
|
||||
#[test]
|
||||
fn aco_round_trip_preserves_swatch_count() {
|
||||
use photocraft_psd::aco::write;
|
||||
// No cap assertion here. `aco::MAX_SWATCHES` is `u16::MAX` against a `u16` on-disk count, so it
|
||||
// is unfalsifiable by construction, and the sample names are far shorter than the name cap;
|
||||
// `aco_over_long_name_is_truncated_on_read` is where that cap is actually driven.
|
||||
let f = aco::parse(&sample_aco()).expect("sample .aco parses");
|
||||
let out = write(&f.swatches).expect("re-writing parsed swatches");
|
||||
let back = aco::parse(&out).expect("the rewritten .aco parses");
|
||||
assert_eq!(back.swatches.len(), f.swatches.len(), "swatch count changed across a round trip");
|
||||
}
|
||||
|
||||
/// A version 2 section whose name declares more units than [`aco::MAX_NAME_UNITS`] must come back
|
||||
/// truncated. The count is written by hand because `write` will not emit one.
|
||||
#[test]
|
||||
fn aco_over_long_name_is_truncated_on_read() {
|
||||
let units = 2000usize;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&2u16.to_be_bytes()); // version 2 section
|
||||
f.extend_from_slice(&1u16.to_be_bytes()); // one swatch
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // colour space: RGB
|
||||
for v in [1u16, 2, 3, 0] {
|
||||
f.extend_from_slice(&v.to_be_bytes()); // the four stored values
|
||||
}
|
||||
// The count includes the terminating null, as `write` does and as the format requires.
|
||||
f.extend_from_slice(&((units + 1) as u32).to_be_bytes());
|
||||
for _ in 0..units {
|
||||
f.extend_from_slice(&(b'x' as u16).to_be_bytes());
|
||||
}
|
||||
f.extend_from_slice(&0u16.to_be_bytes()); // terminating null
|
||||
let parsed = aco::parse(&f).expect("an over-long name is truncated, not refused");
|
||||
assert_eq!(parsed.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS, "name must be capped at MAX_NAME_UNITS");
|
||||
}
|
||||
|
||||
/// `write` must cap too, or a long name from a caller is written whole. The declared length in
|
||||
/// the version 2 record counts the terminating null, so it must read back as MAX_NAME_UNITS + 1.
|
||||
#[test]
|
||||
fn aco_write_caps_the_name() {
|
||||
let swatches = vec![AcoSwatch { name: "x".repeat(5000), color: AcoColor::Gray(5000) }];
|
||||
let bytes = aco::write(&swatches).expect("an over-long name is capped on write, not refused");
|
||||
// Names go out as UTF-16 big-endian, so 'x' is the byte pair 00 78.
|
||||
let run = [0u8, b'x'];
|
||||
let mut needle = Vec::new();
|
||||
for _ in 0..8 {
|
||||
needle.extend_from_slice(&run);
|
||||
}
|
||||
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
|
||||
let declared = u32::from_be_bytes([bytes[at - 4], bytes[at - 3], bytes[at - 2], bytes[at - 1]]);
|
||||
assert_eq!(declared as usize, aco::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
|
||||
let back = aco::parse(&bytes).expect("the written file parses");
|
||||
assert_eq!(back.swatches[0].name.encode_utf16().count(), aco::MAX_NAME_UNITS);
|
||||
}
|
||||
|
||||
/// `write` must cap an over-long name too, and it must count the terminating null in the u16
|
||||
/// length field it writes. Without the cap that field would wrap, but only once the name passes
|
||||
/// 65 535 units — so the case that proves it uses 70 000, not merely "long".
|
||||
#[test]
|
||||
fn ase_write_caps_the_name() {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry, AseFile, AseKind, AseSwatch, write};
|
||||
let sw = AseSwatch { name: "x".repeat(70_000), color: AseColor::Rgb([1.0, 0.0, 0.0]), kind: AseKind::Process };
|
||||
let bytes = write(&AseFile { entries: vec![AseEntry::Swatch(sw)] }).expect("an over-long name is capped, not refused");
|
||||
let run = [0u8, b'x'];
|
||||
let mut needle = Vec::new();
|
||||
for _ in 0..8 {
|
||||
needle.extend_from_slice(&run);
|
||||
}
|
||||
let at = bytes.windows(needle.len()).position(|w| w == needle.as_slice()).expect("the name is written into the file");
|
||||
let declared = u16::from_be_bytes([bytes[at - 2], bytes[at - 1]]) as usize;
|
||||
assert_eq!(declared, ase::MAX_NAME_UNITS + 1, "write must declare at most MAX_NAME_UNITS units plus the null");
|
||||
let back = ase::parse(&bytes).expect("the written file parses");
|
||||
let ase::AseEntry::Swatch(s) = &back.entries[0] else { panic!("expected a loose swatch") };
|
||||
assert_eq!(s.name.encode_utf16().count(), ase::MAX_NAME_UNITS);
|
||||
}
|
||||
|
||||
/// Truncating a `.grd` anywhere must be an error: unlike `.aco` there is no optional second
|
||||
/// section, so a prefix can never be a complete file.
|
||||
#[test]
|
||||
fn truncate_grd_is_always_an_error() {
|
||||
let b = sample_grd();
|
||||
for cut in 0..b.len() {
|
||||
let r = catch_unwind(|| grd::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "grd: parse panicked on a {cut}/{}-byte prefix", b.len());
|
||||
assert!(r.unwrap().is_err(), "grd: truncation at {cut}/{} parsed successfully", b.len());
|
||||
}
|
||||
assert!(grd::parse(&b).is_ok(), "grd: the full file must parse");
|
||||
}
|
||||
|
||||
/// Truncating an `.ase` anywhere must be an error: the block count is fixed up front, so a prefix
|
||||
/// cannot contain the number of blocks the header claims.
|
||||
#[test]
|
||||
fn truncate_ase_is_always_an_error() {
|
||||
let b = sample_ase();
|
||||
for cut in 0..b.len() {
|
||||
let r = catch_unwind(|| ase::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "ase: parse panicked on a {cut}/{}-byte prefix", b.len());
|
||||
assert!(r.unwrap().is_err(), "ase: truncation at {cut}/{} parsed successfully", b.len());
|
||||
}
|
||||
assert!(ase::parse(&b).is_ok(), "ase: the full file must parse");
|
||||
}
|
||||
|
||||
/// Truncating an `.aco` inside its first section must be an error. A cut that lands exactly after a
|
||||
/// complete first section is *not* an error — the reader stops there, having read the version 1
|
||||
/// section, and ignores whatever follows — so the sweep stops at that boundary rather than
|
||||
/// claiming every prefix fails. The boundary is derived from the sample so adding a swatch
|
||||
/// cannot silently weaken the sweep.
|
||||
#[test]
|
||||
fn truncate_aco_inside_first_section_is_an_error() {
|
||||
let b = sample_aco();
|
||||
// version u16 + count u16, then one 10-byte colour record per swatch.
|
||||
let first_section = 4 + sample_aco_swatches().len() * 10;
|
||||
assert!(first_section <= b.len(), "the sample must contain a whole first section");
|
||||
for cut in 0..first_section {
|
||||
let r = catch_unwind(|| aco::parse(&b[..cut]));
|
||||
assert!(r.is_ok(), "aco: parse panicked on a {cut}/{first_section}-byte prefix");
|
||||
assert!(r.unwrap().is_err(), "aco: truncation inside the first section at {cut} parsed successfully");
|
||||
}
|
||||
match catch_unwind(|| aco::parse(&b[..first_section])) {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => panic!("aco: a complete first section must parse, got {e:?}"),
|
||||
Err(_) => panic!("aco: a complete first section panicked"),
|
||||
}
|
||||
assert!(aco::parse(&b).is_ok(), "aco: the full file must parse");
|
||||
}
|
||||
|
||||
/// Structural `.ase` cases the block reader has to survive: a group end with no start, a group
|
||||
/// started inside another, a block length that disagrees with what the body consumes, and an
|
||||
/// unknown colour model whose components are cut short. Each is a clean error or a clean parse,
|
||||
/// never a panic and never a read past the block.
|
||||
#[test]
|
||||
fn ase_structurally_hostile_blocks() {
|
||||
use photocraft_psd::ase::{AseColor, AseEntry};
|
||||
let header = |count: u32| {
|
||||
let mut f = b"ASEF".to_vec();
|
||||
f.extend_from_slice(&1u16.to_be_bytes());
|
||||
f.extend_from_slice(&0u16.to_be_bytes());
|
||||
f.extend_from_slice(&count.to_be_bytes());
|
||||
f
|
||||
};
|
||||
let block = |kind: u16, body: &[u8]| {
|
||||
let mut b = kind.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
b.extend_from_slice(body);
|
||||
b
|
||||
};
|
||||
let group_start = |name: &str| {
|
||||
// The name is a u16 code-unit count followed by the units themselves, not raw bytes.
|
||||
let units: Vec<u16> = name.encode_utf16().collect();
|
||||
let mut b = (units.len() as u16).to_be_bytes().to_vec();
|
||||
for u in units {
|
||||
b.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
block(0xC001, &b)
|
||||
};
|
||||
let group_end = || block(0xC002, &[]);
|
||||
let colour = |model: &[u8], floats: &[u8]| {
|
||||
let mut b = 0u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(model);
|
||||
b.extend_from_slice(floats);
|
||||
b.extend_from_slice(&2u16.to_be_bytes());
|
||||
block(0x0001, &b)
|
||||
};
|
||||
|
||||
// A group end with no start: documented as ignored, so this parses and yields nothing.
|
||||
let mut f = header(1);
|
||||
f.extend_from_slice(&group_end());
|
||||
let parsed = ase::parse(&f).expect("an unbalanced group end must parse");
|
||||
assert!(parsed.entries.is_empty(), "a stray group end must not invent an entry or an empty group");
|
||||
|
||||
// A group started inside another: the outer one closes, both groups are emitted.
|
||||
let mut f = header(3);
|
||||
f.extend_from_slice(&group_start("outer"));
|
||||
f.extend_from_slice(&group_start("inner"));
|
||||
f.extend_from_slice(&colour(b"Gray", &1f32.to_be_bytes()));
|
||||
let parsed = ase::parse(&f).expect("a nested group start must parse");
|
||||
assert_eq!(parsed.swatch_count(), 1, "the colour must land in exactly one group");
|
||||
// The message above is only true if the colour is in the *inner* group; check it.
|
||||
let inner_holds_it = parsed.entries.iter().any(|e| matches!(e, AseEntry::Group { name, swatches } if name == "inner" && swatches.len() == 1));
|
||||
assert!(inner_holds_it, "the colour must land in the inner group, not the outer one");
|
||||
let names: Vec<&str> = parsed
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
AseEntry::Group { name, .. } => Some(name.as_str()),
|
||||
AseEntry::Swatch(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, vec!["outer", "inner"], "both groups must be closed and emitted");
|
||||
|
||||
// A block length shorter than the body it describes: the colour's floats are cut off.
|
||||
// Gray carries exactly one component, so declaring the block one byte shorter leaves that
|
||||
// component half-read. It must be refused, not guessed at.
|
||||
let mut short = 0u16.to_be_bytes().to_vec();
|
||||
short.extend_from_slice(b"Gray");
|
||||
short.extend_from_slice(&0f32.to_be_bytes());
|
||||
short.extend_from_slice(&2u16.to_be_bytes()); // colour type: process
|
||||
let mut f = header(1);
|
||||
let mut b = 0x0001u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&((short.len() - 4) as u32).to_be_bytes());
|
||||
b.extend_from_slice(&short);
|
||||
f.extend_from_slice(&b);
|
||||
let r = catch_unwind(|| ase::parse(&f));
|
||||
assert!(r.is_ok(), "a truncated colour component must not panic");
|
||||
assert!(r.unwrap().is_err(), "a colour component cut short must be refused, not guessed at");
|
||||
|
||||
// A block that ends after the components, with no colour type at all, is *accepted*: some
|
||||
// writers omit it and the reader defaults it to process. Pinning that, because it is the one
|
||||
// place this parser is deliberately lenient.
|
||||
let mut f = header(1);
|
||||
let mut b = 0x0001u16.to_be_bytes().to_vec();
|
||||
b.extend_from_slice(&(short.len().saturating_sub(2) as u32).to_be_bytes());
|
||||
b.extend_from_slice(&short[..short.len() - 2]);
|
||||
f.extend_from_slice(&b);
|
||||
let parsed = ase::parse(&f).expect("a colour block with no colour type is accepted by design");
|
||||
assert_eq!(parsed.swatch_count(), 1, "it must still yield exactly one swatch");
|
||||
|
||||
// An unknown colour model keeps whatever bytes follow it verbatim.
|
||||
// The model tag is four bytes; give it four, then a known payload, so "kept verbatim" can be
|
||||
// checked rather than merely asserted.
|
||||
let mut f = header(1);
|
||||
let payload = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
f.extend_from_slice(&colour(b"XYZ ", &payload));
|
||||
let parsed = ase::parse(&f).expect("an unknown colour model is kept verbatim");
|
||||
assert_eq!(parsed.swatch_count(), 1, "an unknown model still yields one swatch");
|
||||
let AseEntry::Swatch(sw) = &parsed.entries[0] else { panic!("expected a loose swatch") };
|
||||
let AseColor::Other { model, data } = &sw.color else { panic!("an unknown model must be kept as Other, got {:?}", sw.color) };
|
||||
assert_eq!(model, b"XYZ ", "the model tag must be preserved verbatim");
|
||||
assert_eq!(data.as_slice(), &payload, "the component bytes must be preserved verbatim");
|
||||
}
|
||||
|
||||
/// A `.aco` whose version 1 and version 2 sections disagree about the number of swatches is the
|
||||
/// realistic hostile shape: Photoshop writes both, and a corrupted length lands in one of them.
|
||||
/// The version 2 names must still line up with the version 1 colours they belong to.
|
||||
#[test]
|
||||
fn aco_mismatched_section_counts_do_not_panic() {
|
||||
// version 1 with two colours and no names, then a version 2 section claiming five.
|
||||
let mut f = vec![0u8, 1, 0, 2];
|
||||
for _ in 0..2 {
|
||||
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
|
||||
}
|
||||
f.extend_from_slice(&[0, 2, 0, 5]);
|
||||
for _ in 0..5 {
|
||||
f.extend_from_slice(&[0, 0, 0, 1, 0, 2, 0, 3, 0, 0]);
|
||||
// An .aco record has no colour-type field, so a one-character name is exactly two units:
|
||||
// two u16 values, not one.
|
||||
f.extend_from_slice(&2u32.to_be_bytes());
|
||||
f.extend_from_slice(&[0, 78, 0, 0]); // 'N' and its null
|
||||
}
|
||||
let r = catch_unwind(|| aco::parse(&f));
|
||||
assert!(r.is_ok(), "mismatched section counts must not panic");
|
||||
let parsed = r.unwrap().expect("the version 2 section is the one with names");
|
||||
assert_eq!(parsed.swatches.len(), 5, "the named section is authoritative when present");
|
||||
for sw in &parsed.swatches {
|
||||
assert_eq!(sw.name, "N", "each version 2 record must keep its name beside its own colour");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep descriptor nesting is the one hostile shape that aborts rather than returning: a stack
|
||||
/// overflow is not a panic, so `catch_unwind` cannot catch it and it would take the process down.
|
||||
/// `descriptor` bounds it at `MAX_DEPTH` and its own unit test drives `Descriptor::from_bytes`
|
||||
/// directly; this drives the same limit through `grd::parse`, which is the path a user's gradient
|
||||
/// preset actually takes, and asserts the specific error rather than merely surviving.
|
||||
///
|
||||
/// The previous version of this test built `b"null"` repeated 2000 times, which is not a descriptor
|
||||
/// at all — no item counts, no `Objc` tags — so the parser rejected it on the first field and
|
||||
/// nothing ever recursed. It passed for the wrong reason.
|
||||
#[test]
|
||||
fn grd_deeply_nested_descriptor_hits_the_depth_limit() {
|
||||
use photocraft_psd::descriptor::{Descriptor, MAX_DEPTH, Value, VersionedDescriptor};
|
||||
let mut d = Descriptor::new("null");
|
||||
for _ in 0..(MAX_DEPTH + 5) {
|
||||
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
|
||||
}
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
|
||||
// This is the real assertion: nesting past the limit is refused, not recursed into.
|
||||
assert!(matches!(grd::parse(&g), Err(PsdError::LimitExceeded(_))), "nesting past MAX_DEPTH must be refused through the .grd path, not recursed into");
|
||||
|
||||
// The limit discriminates rather than being a blanket refusal: a shallow nesting is accepted
|
||||
// (rejected later, for holding no gradients — a different error), while a deep one is a depth
|
||||
// refusal. Each wrapper adds more than one unit of depth, so the exact crossover is an
|
||||
// implementation detail; what matters is that normal files pass and runaway ones are stopped.
|
||||
let build = |levels: usize| {
|
||||
let mut d = Descriptor::new("null");
|
||||
for _ in 0..levels {
|
||||
d = Descriptor::new("null").with("in ", Value::Descriptor(d));
|
||||
}
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
|
||||
g
|
||||
};
|
||||
assert!(!matches!(grd::parse(&build(4)), Err(PsdError::LimitExceeded(_))), "a shallow descriptor must not be refused for depth");
|
||||
assert!(
|
||||
matches!(grd::parse(&build(MAX_DEPTH + 1)), Err(PsdError::LimitExceeded(_))),
|
||||
"a descriptor past the depth limit must be a depth refusal, not an abort"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every colour model's components must come back finite and inside their documented range, not
|
||||
/// just RGB. The writer only emits RGB and foreground/background stops, so each model is built by
|
||||
/// hand with an out-of-range or non-representable value in every component, then driven through
|
||||
/// `grd::parse`. Without this, the clamps on HSB, CMYK, Gray and the L* channel — and the finite
|
||||
/// guard on the Lab a*/b* channels — have no test and can be removed unnoticed.
|
||||
#[test]
|
||||
fn grd_every_colour_model_is_clamped_and_finite() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
|
||||
|
||||
// Build a one-stop gradient whose colour is `class_id` with the given (key, value) components.
|
||||
let build = |class_id: &str, comps: &[(&str, f64)]| -> Vec<u8> {
|
||||
let mut colour = Descriptor::new(class_id);
|
||||
for (k, v) in comps {
|
||||
colour = colour.with(k, Value::Double(*v));
|
||||
}
|
||||
let stop = Descriptor::new("Clrt")
|
||||
.with("Clr ", Value::Descriptor(colour))
|
||||
.with("Type", Value::Enumerated { type_id: photocraft_psd::descriptor::Id::new("Clry"), value: photocraft_psd::descriptor::Id::new("UsrS") })
|
||||
.with("Lctn", Value::Integer(0))
|
||||
.with("Mdpn", Value::Integer(50));
|
||||
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
g
|
||||
};
|
||||
|
||||
let hostile = [1e300, 3.0e38, -3.0e38, f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
|
||||
for &x in &hostile {
|
||||
// The exact boundary each clamp must land on, so a weaker clamp (e.g. a*/b* to -100..=100
|
||||
// instead of -128..=127, or a unit range to 0..=0.5) is caught, not just an out-of-range
|
||||
// value. A NaN becomes 0.0 (the finite guard's fallback for `f`), a positive saturates to
|
||||
// the top of the range, a negative to the bottom.
|
||||
let unit = if x.is_nan() || x < 0.0 { 0.0f32 } else { 1.0 };
|
||||
let lab_l = if x.is_nan() || x < 0.0 { 0.0f32 } else { 100.0 };
|
||||
let lab_ab = if x.is_nan() {
|
||||
0.0f32
|
||||
} else if x > 0.0 {
|
||||
127.0
|
||||
} else {
|
||||
-128.0
|
||||
};
|
||||
// RGB components are 0..=255 before the /255; the result must land in 0..=1.
|
||||
let f = grd::parse(&build("RGBC", &[("Rd ", x), ("Grn ", x), ("Bl ", x)])).expect("RGB parses");
|
||||
let GrdColor::Rgb(rgb) = f[0].stops[0].color else { panic!("expected an RGB stop") };
|
||||
for v in rgb {
|
||||
assert_eq!(v, unit, "RGB component for input {x} must saturate to {unit}");
|
||||
}
|
||||
// HSB: hue 0..=360, saturation and brightness 0..=1.
|
||||
let f = grd::parse(&build("HSBC", &[("H ", x), ("Strt", x), ("Brgh", x)])).expect("HSB parses");
|
||||
if let GrdColor::Hsb([h, sat, br]) = f[0].stops[0].color {
|
||||
assert!(h.is_finite() && (0.0..=360.0).contains(&h), "hue {h} out of range for input {x}");
|
||||
assert_eq!(sat, unit, "saturation for input {x} must saturate to {unit}");
|
||||
assert_eq!(br, unit, "brightness for input {x} must saturate to {unit}");
|
||||
} else {
|
||||
panic!("expected an HSB stop");
|
||||
}
|
||||
// CMYK: every ink 0..=1.
|
||||
let f = grd::parse(&build("CMYC", &[("Cyn ", x), ("Mgnt", x), ("Ylw ", x), ("Blck", x)])).expect("CMYK parses");
|
||||
if let GrdColor::Cmyk(c) = f[0].stops[0].color {
|
||||
for v in c {
|
||||
assert_eq!(v, unit, "CMYK ink for input {x} must saturate to {unit}");
|
||||
}
|
||||
} else {
|
||||
panic!("expected a CMYK stop");
|
||||
}
|
||||
// Lab: L* 0..=100, a*/b* bounded to the CIELAB gamut -128..=127.
|
||||
let f = grd::parse(&build("LbCl", &[("Lmnc", x), ("A ", x), ("B ", x)])).expect("Lab parses");
|
||||
if let GrdColor::Lab([l, a, b]) = f[0].stops[0].color {
|
||||
assert_eq!(l, lab_l, "L* for input {x} must saturate to {lab_l}");
|
||||
assert_eq!(a, lab_ab, "a* for input {x} must saturate to the gamut edge {lab_ab}");
|
||||
assert_eq!(b, lab_ab, "b* for input {x} must saturate to the gamut edge {lab_ab}");
|
||||
} else {
|
||||
panic!("expected a Lab stop");
|
||||
}
|
||||
// Gray: 0..=1.
|
||||
let f = grd::parse(&build("Grsc", &[("Gry ", x)])).expect("Gray parses");
|
||||
if let GrdColor::Gray(g) = f[0].stops[0].color {
|
||||
assert_eq!(g, unit, "gray for input {x} must saturate to {unit}");
|
||||
} else {
|
||||
panic!("expected a Gray stop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue is circular, so an out-of-range hue must *wrap*, not clamp: 370 degrees is 10, not 360, and
|
||||
/// -10 is 350, not 0. Clamping would hand back a different colour; this pins the wrap so a
|
||||
/// regression to `clamp` is caught (both produce in-range values, so a range check alone would not).
|
||||
#[test]
|
||||
fn grd_hue_wraps_rather_than_clamping() {
|
||||
use photocraft_psd::descriptor::{Descriptor, Id, Value, VersionedDescriptor};
|
||||
let build = |hue: f64| {
|
||||
let colour = Descriptor::new("HSBC").with("H ", Value::Double(hue)).with("Strt", Value::Double(50.0)).with("Brgh", Value::Double(50.0));
|
||||
let stop = Descriptor::new("Clrt")
|
||||
.with("Clr ", Value::Descriptor(colour))
|
||||
.with("Type", Value::Enumerated { type_id: Id::new("Clry"), value: Id::new("UsrS") })
|
||||
.with("Lctn", Value::Integer(0))
|
||||
.with("Mdpn", Value::Integer(50));
|
||||
let grad = Descriptor::new("Grdn").with("Clrs", Value::List(vec![Value::Descriptor(stop)]));
|
||||
let outer = Descriptor::new("Grdn").with("Grad", Value::Descriptor(grad));
|
||||
let file = Descriptor::new("null").with("GrdL", Value::List(vec![Value::Descriptor(outer)]));
|
||||
let mut g = b"8BGR".to_vec();
|
||||
g.extend_from_slice(&5u16.to_be_bytes());
|
||||
g.extend_from_slice(&VersionedDescriptor::new(file).to_bytes());
|
||||
g
|
||||
};
|
||||
let hue_of = |bytes: &[u8]| -> f32 {
|
||||
let f = grd::parse(bytes).expect("parses");
|
||||
let GrdColor::Hsb([h, _, _]) = f[0].stops[0].color else { panic!("expected an HSB stop") };
|
||||
h
|
||||
};
|
||||
assert!((hue_of(&build(370.0)) - 10.0).abs() < 0.01, "370 degrees must wrap to 10, not clamp to 360");
|
||||
assert!((hue_of(&build(-10.0)) - 350.0).abs() < 0.01, "-10 degrees must wrap to 350, not clamp to 0");
|
||||
// And a value already in range is unchanged.
|
||||
assert!((hue_of(&build(123.0)) - 123.0).abs() < 0.01, "an in-range hue must be unchanged");
|
||||
// Exactly 360 is in range and legal, so it is kept, not wrapped to 0 (a different position for
|
||||
// anything that interpolates in HSB). Unconditional `rem_euclid(360)` would map it to 0, so this
|
||||
// pins the in-range guard that keeps the boundary intact.
|
||||
assert_eq!(hue_of(&build(360.0)), 360.0, "an in-range hue of exactly 360 must be preserved, not wrapped to 0");
|
||||
assert_eq!(hue_of(&build(0.0)), 0.0, "a hue of 0 must stay 0");
|
||||
}
|
||||
|
||||
/// A signature-only or empty file must be a clean error, never a panic and never an
|
||||
/// out-of-bounds read.
|
||||
#[test]
|
||||
fn empty_and_signature_only_inputs_error() {
|
||||
fn no_panic_then_err<T>(r: Result<Result<T, PsdError>, Box<dyn core::any::Any + Send>>, what: &str) {
|
||||
match r {
|
||||
Ok(Ok(_)) => panic!("{what}: an empty file must be a clean error, not parse"),
|
||||
Ok(Err(_)) => {}
|
||||
Err(_) => panic!("{what}: parse panicked on an empty file"),
|
||||
}
|
||||
}
|
||||
no_panic_then_err(catch_unwind(|| grd::parse(b"")), "grd");
|
||||
no_panic_then_err(catch_unwind(|| ase::parse(b"")), "ase");
|
||||
no_panic_then_err(catch_unwind(|| aco::parse(b"")), "aco");
|
||||
assert!(matches!(grd::parse(b"8BGR"), Err(PsdError::UnexpectedEof { .. })));
|
||||
assert!(matches!(grd::parse(b"8BG"), Err(PsdError::UnexpectedEof { .. })));
|
||||
assert!(ase::parse(b"ASEF").is_err());
|
||||
assert!(aco::parse(b"").is_err());
|
||||
}
|
||||
+34
-12
@@ -24,8 +24,10 @@ pub struct TySh {
|
||||
pub transform: Affine,
|
||||
pub text: Descriptor,
|
||||
pub warp: Option<Descriptor>,
|
||||
/// The four trailing i32 values (left, top, right, bottom).
|
||||
pub bounds: [i32; 4],
|
||||
/// The four trailing big-endian f32 values (left, top, right, bottom) after the warp
|
||||
/// descriptor. Photoshop writes zeros for unwarped type and the text-space bounds (the
|
||||
/// `bounds` descriptor's rectangle) for warped type.
|
||||
pub bounds: [f32; 4],
|
||||
}
|
||||
|
||||
/// Parses a `TySh` block.
|
||||
@@ -42,10 +44,10 @@ pub fn parse_tysh(data: &[u8]) -> Option<TySh> {
|
||||
None => (None, 0),
|
||||
};
|
||||
let tail = rest.get(wused..).unwrap_or(&[]);
|
||||
let mut bounds = [0i32; 4];
|
||||
let mut bounds = [0f32; 4];
|
||||
for (i, b) in bounds.iter_mut().enumerate() {
|
||||
if let Some(x) = tail.get(i * 4..i * 4 + 4) {
|
||||
*b = i32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
|
||||
*b = f32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
|
||||
}
|
||||
}
|
||||
Some(TySh { transform: Affine { m }, text: text.descriptor, warp, bounds })
|
||||
@@ -221,7 +223,11 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
|
||||
Some("AnSt" | "antiAliasStrong") => AntiAlias::Strong,
|
||||
Some("antiAliasPlatformGray") => AntiAlias::Windows,
|
||||
Some("antiAliasPlatformLCD") => AntiAlias::WindowsLcd,
|
||||
_ => AntiAlias::Smooth,
|
||||
Some(_) => AntiAlias::Smooth,
|
||||
// No `AntA`: the engine data's mode says it.
|
||||
None => {
|
||||
engine_data(&t.text).and_then(|e| e.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)).and_then(engine_antialias).unwrap_or(AntiAlias::Smooth)
|
||||
}
|
||||
};
|
||||
if let Some(w) = &t.warp {
|
||||
let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into());
|
||||
@@ -979,12 +985,13 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
|
||||
"AntiAlias",
|
||||
E::Int(match layer.antialias {
|
||||
AntiAlias::None => 0,
|
||||
AntiAlias::Sharp => 1,
|
||||
AntiAlias::Crisp => 2,
|
||||
AntiAlias::Strong => 3,
|
||||
AntiAlias::Smooth => 4,
|
||||
// EngineData has no platform modes; the `AntA` descriptor carries the exact value.
|
||||
AntiAlias::Windows | AntiAlias::WindowsLcd => 1,
|
||||
AntiAlias::Crisp => 1,
|
||||
AntiAlias::Strong => 2,
|
||||
AntiAlias::Smooth => 3,
|
||||
AntiAlias::Sharp => 4,
|
||||
// No Photoshop file confirms the platform modes' numbers here; the `AntA` descriptor
|
||||
// carries the exact mode, and Smooth is the closest.
|
||||
AntiAlias::Windows | AntiAlias::WindowsLcd => 3,
|
||||
}),
|
||||
);
|
||||
if dict.get("UseFractionalGlyphWidths").is_none() {
|
||||
@@ -1059,6 +1066,19 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
|
||||
e
|
||||
}
|
||||
|
||||
/// The anti-aliasing mode for EngineData's `AntiAlias` number, as Photoshop writes it: 0 None,
|
||||
/// 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Other numbers (platform modes) are left to `AntA`.
|
||||
fn engine_antialias(n: f64) -> Option<AntiAlias> {
|
||||
Some(match n as i64 {
|
||||
0 => AntiAlias::None,
|
||||
1 => AntiAlias::Crisp,
|
||||
2 => AntiAlias::Strong,
|
||||
3 => AntiAlias::Smooth,
|
||||
4 => AntiAlias::Sharp,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the `TySh` block for `layer`, patching its current `psd_raw` when present.
|
||||
/// `ink` is the layer's rendered bounds in text space (for `bounds`/`boundingBox`).
|
||||
pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8> {
|
||||
@@ -1123,6 +1143,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
|
||||
(None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => Some(old),
|
||||
_ => None,
|
||||
};
|
||||
let bounds = [l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32];
|
||||
// Photoshop's trailing bounds: zeros for unwarped type, the text-space bounds when warped.
|
||||
let warped = warp.as_ref().is_some_and(|w| enum_value(w, "warpStyle").is_some_and(|s| s != "warpNone"));
|
||||
let bounds = if warped { [l, t, r, b] } else { [0.0; 4] };
|
||||
write_tysh(&TySh { transform: layer.transform, text, warp, bounds })
|
||||
}
|
||||
@@ -1505,3 +1505,43 @@ fn translate_tysh_moves_only_the_translation() {
|
||||
assert_eq!(crate::psd::parse_tysh(&tysh).unwrap().bounds, back.bounds, "bounds are in text space");
|
||||
assert!(crate::psd::translate_tysh(&tysh[..40], 1.0, 1.0).is_none(), "a truncated block is refused");
|
||||
}
|
||||
|
||||
/// The four values after the `TySh` warp descriptor are big-endian f32, as Photoshop writes them:
|
||||
/// zeros for unwarped type, the text-space bounds for warped type.
|
||||
#[test]
|
||||
fn tysh_trailing_bounds_are_f32() {
|
||||
use photocraft_doc::text::TextWarp;
|
||||
let tail = |b: &[u8]| -> [f32; 4] {
|
||||
let t = b.get(b.len() - 16..).unwrap();
|
||||
std::array::from_fn(|i| f32::from_be_bytes(t[i * 4..i * 4 + 4].try_into().unwrap()))
|
||||
};
|
||||
let ink = [0.0, -34.316406, 288.90625, 15.058594];
|
||||
let plain = crate::psd::build_tysh(&point("Type", 20.0), 72.0, Some(ink));
|
||||
assert_eq!(tail(&plain), [0.0; 4]);
|
||||
let warped = TextLayer {
|
||||
warp: Some(TextWarp { style: "warpArc".into(), value: 50.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }),
|
||||
..point("Type", 20.0)
|
||||
};
|
||||
let bytes = crate::psd::build_tysh(&warped, 72.0, Some(ink));
|
||||
assert_eq!(tail(&bytes), ink);
|
||||
let parsed = crate::psd::parse_tysh(&bytes).unwrap();
|
||||
assert_eq!(parsed.bounds, ink);
|
||||
assert_eq!(crate::psd::write_tysh(&parsed), bytes);
|
||||
}
|
||||
|
||||
/// EngineData's `AntiAlias` numbers as Photoshop writes them, beside the `AntA` descriptor:
|
||||
/// 0 None, 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Read back from EngineData alone (no `AntA`),
|
||||
/// every mode survives.
|
||||
#[test]
|
||||
fn engine_data_antialias_numbers_match_photoshop() {
|
||||
use photocraft_doc::text::AntiAlias;
|
||||
for (mode, n) in [(AntiAlias::None, 0), (AntiAlias::Crisp, 1), (AntiAlias::Strong, 2), (AntiAlias::Smooth, 3), (AntiAlias::Sharp, 4)] {
|
||||
let t = TextLayer { antialias: mode, ..point("Type", 20.0) };
|
||||
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
|
||||
let e = crate::psd::engine_data(&tysh.text).unwrap();
|
||||
assert_eq!(e.path(&["EngineDict", "AntiAlias"]).and_then(crate::engine_data::Value::as_f64), Some(f64::from(n)), "{mode:?}");
|
||||
tysh.text.items.retain(|(k, _)| !k.is("AntA"));
|
||||
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap();
|
||||
assert_eq!(back.antialias, mode, "{n}");
|
||||
}
|
||||
}
|
||||
@@ -2779,7 +2779,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// zooms nor slides the image towards the pointer.
|
||||
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
|
||||
if nz != view.zoom {
|
||||
zoom_about(&mut view, &xf, p, nz, false, ppp);
|
||||
// Over the pasteboard the anchor is the image's nearest point (#3720).
|
||||
let at = wheel_anchor(&xf, p, view.doc_size);
|
||||
zoom_about(&mut view, &xf, at, nz, false, ppp);
|
||||
}
|
||||
}
|
||||
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
|
||||
@@ -3557,6 +3559,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under
|
||||
/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the
|
||||
/// centre of the view instead (#204).
|
||||
/// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image,
|
||||
/// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray
|
||||
/// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps
|
||||
/// the image in view. Clamped in document space, so a rotated or flipped view behaves the same.
|
||||
fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 {
|
||||
let [w, h] = doc_size;
|
||||
let d = xf.to_doc(p);
|
||||
if w == 0 || h == 0 || !(d[0].is_finite() && d[1].is_finite()) {
|
||||
return p;
|
||||
}
|
||||
let c = [d[0].clamp(0.0, f64::from(w)), d[1].clamp(0.0, f64::from(h))];
|
||||
if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) }
|
||||
}
|
||||
|
||||
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) {
|
||||
let before = xf.to_doc(p);
|
||||
view.zoom = new_zoom;
|
||||
@@ -6687,6 +6703,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_anchor_is_the_pointer_over_the_image_and_the_nearest_image_point_off_it() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
for (rotation, flip) in [(0.0, false), (30.0, false), (90.0, true)] {
|
||||
let xf = ViewXform { rect, zoom: 0.5, center: [400.0, 300.0], flip, rotation, aspect: 1.0 };
|
||||
let inside = xf.to_screen(100.0, 200.0);
|
||||
assert_eq!(wheel_anchor(&xf, inside, [800, 600]), inside);
|
||||
for (p, want) in [((-300.0, -200.0), [0.0, 0.0]), ((1200.0, 300.0), [800.0, 300.0]), ((400.0, 900.0), [400.0, 600.0])] {
|
||||
let a = xf.to_doc(wheel_anchor(&xf, xf.to_screen(p.0, p.1), [800, 600]));
|
||||
assert!((a[0] - want[0]).abs() < 0.01 && (a[1] - want[1]).abs() < 0.01, "rot {rotation}: {p:?} -> {a:?}");
|
||||
}
|
||||
}
|
||||
let p = pos2(5.0, 5.0);
|
||||
let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
assert_eq!(wheel_anchor(&xf, p, [0, 0]), p, "no document: the pointer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_about_centres_the_clicked_point_with_the_preference() {
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
|
||||
@@ -174,7 +174,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
|
||||
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
// Built-in labels only: alpha/spot names and "{layer} Mask" stay verbatim.
|
||||
let label = match row {
|
||||
Row::Composite | Row::Color(_) | Row::QuickMask => tl!(&name).to_owned(),
|
||||
Row::Alpha(_) | Row::LayerMask => name.clone(),
|
||||
};
|
||||
job.append(&label, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
let galley = painter.layout_job(job);
|
||||
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
painter.galley(text_pos, galley, t.text);
|
||||
|
||||
@@ -540,7 +540,8 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let mut inv = flag(f, "invert");
|
||||
if widgets::checkbox(ui, &mut inv, tl!("Invert")).changed() {
|
||||
// Selection invert (≠ Image › Adjustments › Invert → Đảo màu).
|
||||
if widgets::checkbox(ui, &mut inv, crate::i18n::tr_ctx(crate::i18n::current(), "colorRange", "Invert")).changed() {
|
||||
f.insert("invert".into(), json!(inv));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -820,4 +820,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1919: with Delete Cropped Pixels off, a second crop's handle dragged out toward the hidden
|
||||
/// pixels snaps exactly onto their edge (a layer edge), however far from the handle's centre
|
||||
/// the press grabbed it, in both modes, with and without Auto Center Preview: the committed
|
||||
/// crop has no transparent strip.
|
||||
#[test]
|
||||
fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() {
|
||||
// The image 200 × 100 cropped to [40, 20, 140, 70]: its pixels stay at [-40, -20, 160, 80].
|
||||
let hidden = [-40.0, -20.0, 160.0, 80.0];
|
||||
for classic in [false, true] {
|
||||
for auto in [true, false] {
|
||||
for (hx, hy) in [(1i8, 0i8), (0, -1), (-1, -1), (1, 1)] {
|
||||
// A corner also with the image's 2:1 ratio held, which both hidden corners keep.
|
||||
for (grab, ratio) in [(-4.0f32, false), (0.0, false), (4.0, false), (-4.0, true), (4.0, true)] {
|
||||
if ratio && (hx == 0 || hy == 0) {
|
||||
continue;
|
||||
}
|
||||
let what = format!("Classic Mode {classic}, Auto Center {auto}, handle ({hx}, {hy}), grabbed {grab} px off, ratio {ratio}");
|
||||
let mut app = app();
|
||||
app.ui.tool_options.crop_delete = false;
|
||||
app.ui.tool_options.crop_shield.classic_mode = classic;
|
||||
app.ui.tool_options.crop_shield.auto_center_preview = auto;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
app.ui.crop_rect = Some([40.0, 20.0, 140.0, 70.0]);
|
||||
app.crop.default_frame = false;
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
let v = &mut app.ui.views[0];
|
||||
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0);
|
||||
app.ui.extras.snap = true;
|
||||
crate::crop_ui::ensure_frame(&mut app);
|
||||
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0]));
|
||||
if ratio {
|
||||
app.ui.tool_options.crop_ratio = "original".into();
|
||||
}
|
||||
let grab = vec2(f32::from(hx), f32::from(hy)) * grab;
|
||||
let from = xf(&app).to_screen(50.0 + f32::from(hx) * 50.0, 25.0 + f32::from(hy) * 25.0) + grab;
|
||||
// Toward the hidden corner (or edge), a screen pixel at a time on the longer axis.
|
||||
let gap = vec2(f32::from(hx) * if hx < 0 { 40.0 } else { 60.0 }, f32::from(hy) * if hy < 0 { 20.0 } else { 30.0 });
|
||||
let dir = gap / gap.x.abs().max(gap.y.abs());
|
||||
let d = xf(&app).to_doc(from);
|
||||
tool_event(&mut app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, NONE);
|
||||
// Drag outward a screen pixel at a time; let go once the moving edges show
|
||||
// the snap to the hidden pixels' edges (as the user would).
|
||||
let snapped = |app: &PhotocraftApp| {
|
||||
let on = |vertical: bool, pos: f64| {
|
||||
app.prefs_rt
|
||||
.snap_lines
|
||||
.iter()
|
||||
.any(|l| l.vertical == vertical && l.pos == pos && l.kind == photocraft_engine::snap::SnapKind::LayerEdge)
|
||||
};
|
||||
(hx == 0 || on(true, if hx < 0 { hidden[0] } else { hidden[2] }))
|
||||
&& (hy == 0 || on(false, if hy < 0 { hidden[1] } else { hidden[3] }))
|
||||
};
|
||||
let mut end = None;
|
||||
for i in 1..=80 {
|
||||
let d = xf(&app).to_doc(from + dir * i as f32);
|
||||
tool_event(&mut app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, NONE);
|
||||
if snapped(&app) {
|
||||
end = Some(from + dir * i as f32);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let end = end.unwrap_or_else(|| panic!("{what}: never snapped to the hidden edge"));
|
||||
// Released where it stopped, read through the view as it is now.
|
||||
let end = xf(&app).to_doc(end);
|
||||
tool_event(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, NONE);
|
||||
let pick = |h: i8, lo: f64, hi: f64, a: f64, b: f64| match h {
|
||||
-1 => [a, hi],
|
||||
1 => [lo, b],
|
||||
_ => [lo, hi],
|
||||
};
|
||||
let [x0, x1] = pick(hx, 0.0, 100.0, hidden[0], hidden[2]);
|
||||
let [y0, y1] = pick(hy, 0.0, 50.0, hidden[1], hidden[3]);
|
||||
assert_eq!(app.ui.crop_rect, Some([x0, y0, x1, y1]), "{what}: the frame lands exactly on the hidden edge");
|
||||
crate::canvas::commit_crop(&mut app);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.doc.size.width, st.doc.size.height), ((x1 - x0) as u32, (y1 - y0) as u32), "{what}");
|
||||
let layer = st.doc.walk().into_iter().map(|(_, _, l)| l.id).next().unwrap();
|
||||
let r = photocraft_engine::snap::layer_rect(&st.doc, layer).unwrap();
|
||||
assert_eq!(r, [hidden[0] - x0, hidden[1] - y0, hidden[2] - x0, hidden[3] - y0], "{what}: no transparent strip");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,6 +765,10 @@ pub fn restore(app: &mut PhotocraftApp) {
|
||||
}
|
||||
let saved = app.session.prefs().panel_layout.clone();
|
||||
apply(app, &saved);
|
||||
// The floating Brush Settings window is transient, not a dock panel: remembering the
|
||||
// workspace must not leave it open on a fresh launch, especially with no document.
|
||||
// Explicit workspace switches still use `apply` and keep their stored panel visibility.
|
||||
app.ui.panels.brush_settings = false;
|
||||
if let Some(ws) = saved.get("workspace").and_then(Value::as_str) {
|
||||
app.ui.workspace = ws.to_string();
|
||||
}
|
||||
|
||||
@@ -1082,3 +1082,31 @@ fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() {
|
||||
drag(&mut h, paths.center(), paths.center() + vec2(0.0, 500.0));
|
||||
assert_eq!(h.state().ui.dock.order(), order);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() {
|
||||
let mut first = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
first.ui.panels.brush_settings = true;
|
||||
first.ui.panels.history = true;
|
||||
let saved = snapshot(&first);
|
||||
first.session.prefs.edit(|p| {
|
||||
p.workspace.remember_workspace_changes = true;
|
||||
p.panel_layout = saved.clone();
|
||||
});
|
||||
let persisted = first.session.prefs_to_json();
|
||||
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.load_prefs_json(&persisted).unwrap();
|
||||
let mut next = PhotocraftApp::new(session, crate::Services::default());
|
||||
restore(&mut next);
|
||||
assert!(!next.ui.panels.brush_settings, "a floating Brush Settings window should not reopen on startup");
|
||||
assert!(next.ui.panels.history, "remembered dock panel visibility is preserved");
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
crate::menus::invoke(&mut next, &ctx, "window.toggle.brushSettings", json!({})).unwrap();
|
||||
assert!(next.ui.panels.brush_settings, "Window > Brush Settings can still open it");
|
||||
|
||||
let mut workspace = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
apply(&mut workspace, &saved);
|
||||
assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout");
|
||||
}
|
||||
@@ -21,12 +21,14 @@ use serde_json::{Value, json};
|
||||
use crate::PhotocraftApp;
|
||||
use crate::file_open::display_name;
|
||||
|
||||
/// What a file dialog asks the user for.
|
||||
/// What a file or folder dialog asks the user for.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileDialogRequest {
|
||||
/// Files to read: several for File › Open, one for a command that reads a file (Place,
|
||||
/// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3).
|
||||
Open { multiple: bool, initial_dir: Option<String>, extensions: Option<Vec<String>> },
|
||||
/// Which folder to read from. Starts in `initial_dir` when available.
|
||||
Folder { initial_dir: Option<String> },
|
||||
/// Where to write, starting from `suggested` (a file name, or the document's own path).
|
||||
Save { suggested: String },
|
||||
}
|
||||
@@ -42,6 +44,8 @@ pub enum FileDialogAnswer {
|
||||
ContentsMany(Vec<(String, Vec<u8>)>),
|
||||
/// The path to save to (desktop), or the download's name (web).
|
||||
SaveTo(String),
|
||||
/// The selected folder's path (desktop).
|
||||
FolderTo(String),
|
||||
/// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message
|
||||
/// says what to install. Reported as an error rather than taken for Cancel.
|
||||
Unavailable(String),
|
||||
@@ -88,7 +92,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
|
||||
/// Show a file dialog; `then` continues with the user's choice on a later frame (see the
|
||||
/// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a
|
||||
/// module docs). Returns at once with `{"fileDialog": "open" | "folder" | "save"}`; `Err` without a
|
||||
/// dialog service ("cancelled", as before there were async dialogs) or while one is open.
|
||||
pub(crate) fn ask_file(
|
||||
&mut self,
|
||||
@@ -103,6 +107,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
let kind = match request {
|
||||
FileDialogRequest::Open { .. } => "open",
|
||||
FileDialogRequest::Folder { .. } => "folder",
|
||||
FileDialogRequest::Save { .. } => "save",
|
||||
};
|
||||
let then: Then = Box::new(move |app, answer| match answer {
|
||||
@@ -114,6 +119,16 @@ impl PhotocraftApp {
|
||||
Ok(json!({ "fileDialog": kind }))
|
||||
}
|
||||
|
||||
/// Ask the user to choose a folder, starting in the folder of the most recent file when known.
|
||||
/// `then` receives the selected path when the dialog is answered.
|
||||
pub(crate) fn ask_folder(&mut self, then: impl FnOnce(&mut Self, String) -> Result<Value, String> + 'static) -> Result<Value, String> {
|
||||
let initial_dir = last_used_dir(&self.ui.recent_files);
|
||||
self.ask_file(FileDialogRequest::Folder { initial_dir }, move |app, answer| match answer {
|
||||
FileDialogAnswer::FolderTo(path) => then(app, path),
|
||||
_ => Err(UNEXPECTED.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask where to save: `then` gets the chosen path, with a lowercase extension when
|
||||
/// Preferences ▸ File Handling › Lowercase Extension is on (its default).
|
||||
///
|
||||
@@ -193,6 +208,7 @@ impl PhotocraftApp {
|
||||
}
|
||||
FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]),
|
||||
FileDialogAnswer::ContentsMany(files) => app.open_contents(files),
|
||||
FileDialogAnswer::FolderTo(_) => return Err(UNEXPECTED.into()),
|
||||
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => return Err(UNEXPECTED.into()),
|
||||
}
|
||||
Ok(Value::Null)
|
||||
@@ -290,6 +306,7 @@ fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec<u8>), String> {
|
||||
let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?;
|
||||
Ok((path, bytes))
|
||||
}
|
||||
FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()),
|
||||
FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,30 @@ fn open_starts_in_the_last_used_folder() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_folder_uses_the_async_dialog_and_returns_the_selected_path() {
|
||||
let (mut app, open, _) = app();
|
||||
let ctx = egui::Context::default();
|
||||
app.ui.recent_files = vec!["/pics/cat.psd".into()];
|
||||
let selected = Rc::new(RefCell::new(None));
|
||||
let result = selected.clone();
|
||||
|
||||
assert_eq!(
|
||||
app.ask_folder(move |_, path| {
|
||||
*result.borrow_mut() = Some(path);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap(),
|
||||
json!({"fileDialog": "folder"})
|
||||
);
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { initial_dir: Some(dir) }, _)] if dir == "/pics"));
|
||||
|
||||
answer(&open, Some(FileDialogAnswer::FolderTo("/pics/frames".into())));
|
||||
app.poll_file_dialog(&ctx, None);
|
||||
assert_eq!(selected.borrow().as_deref(), Some("/pics/frames"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_picker_requests_only_the_given_extensions() {
|
||||
let (mut app, open, _) = app();
|
||||
|
||||
@@ -10,9 +10,17 @@ fn lang(code: &str) -> Lang {
|
||||
|
||||
#[test]
|
||||
fn requested_languages_resolve_regional_preferences() {
|
||||
for (tag, code) in
|
||||
[("en-US", "en"), ("zh-CN", "zh-hans"), ("ja-JP", "ja"), ("ko-KR", "ko"), ("ru-RU", "ru"), ("uk-UA", "uk"), ("fr-CA", "fr"), ("id-ID", "id")]
|
||||
{
|
||||
for (tag, code) in [
|
||||
("en-US", "en"),
|
||||
("zh-CN", "zh-hans"),
|
||||
("ja-JP", "ja"),
|
||||
("ko-KR", "ko"),
|
||||
("ru-RU", "ru"),
|
||||
("uk-UA", "uk"),
|
||||
("fr-CA", "fr"),
|
||||
("id-ID", "id"),
|
||||
("vi-VN", "vi"),
|
||||
] {
|
||||
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
|
||||
}
|
||||
assert_eq!(lang_from_tag("ko_KR.UTF-8"), Some(lang("ko")));
|
||||
|
||||
@@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 17] = [
|
||||
pub static LANGUAGES: [LangInfo; 18] = [
|
||||
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
@@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 17] = [
|
||||
// Dutch; `nl-NL` and `nl-BE` locales both resolve here.
|
||||
LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
|
||||
// Vietnamese; `vi-VN` resolves here.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -379,6 +381,7 @@ mod tests {
|
||||
const ZH: fn() -> Lang = || Lang::from_code("zh-hant").expect("zh-hant registered");
|
||||
const CS: fn() -> Lang = || Lang::from_code("cs").expect("cs registered");
|
||||
const ID: fn() -> Lang = || Lang::from_code("id").expect("id registered");
|
||||
const VI: fn() -> Lang = || Lang::from_code("vi").expect("vi registered");
|
||||
|
||||
#[test]
|
||||
fn indonesian_tags_resolve() {
|
||||
@@ -388,6 +391,17 @@ mod tests {
|
||||
assert_eq!(ID().name(), "Bahasa Indonesia");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vietnamese_tags_resolve() {
|
||||
for tag in ["vi", "vi-VN", "vi_VN", "vi_VN.UTF-8"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}");
|
||||
}
|
||||
assert_eq!(VI().name(), "Tiếng Việt");
|
||||
assert!(VI().complete_menus(), "Vietnamese translates every source key");
|
||||
assert_eq!(Lang::from_pref("vi"), VI());
|
||||
assert_eq!(Lang::from_pref("VI"), VI());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_map_to_languages() {
|
||||
assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA()));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -707,3 +707,62 @@ fn escape_with_targa_options_open_does_not_deselect() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options");
|
||||
}
|
||||
|
||||
/// How much of the 400 × 300 document is on screen: the visible document span on each axis,
|
||||
/// clipped to the image (0 when the image has left the view on that axis).
|
||||
fn visible_image(h: &Harness<'static, PhotocraftApp>) -> [f32; 2] {
|
||||
let r = h.state().last_canvas_rect;
|
||||
let (a, b) = (doc_at(h, r.min), doc_at(h, r.max));
|
||||
[(b[0].min(400.0) - a[0].max(0.0)).max(0.0), (b[1].min(300.0) - a[1].max(0.0)).max(0.0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_over_the_pasteboard_keeps_the_image_in_view() {
|
||||
// #3720: zooming in with the pointer on the gray pasteboard anchored on the pointer, so a few
|
||||
// notches pushed the image out of the window. The anchor is now the nearest point of the image.
|
||||
for (zoom_with_wheel, m) in [(true, Modifiers::NONE), (false, Modifiers::ALT)] {
|
||||
let mut h = harness();
|
||||
h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel);
|
||||
let r = h.state().last_canvas_rect;
|
||||
let p = pos2(r.left() + 20.0, r.top() + 20.0);
|
||||
let d = doc_at(&h, p);
|
||||
assert!(d[0] < 0.0 && d[1] < 0.0, "the pointer starts on the pasteboard: {d:?}");
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let z0 = zoom(&h);
|
||||
for _ in 0..20 {
|
||||
wheel(&h, 1.0, m);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.run_steps(20);
|
||||
assert!(zoom(&h) > z0 * 5.0, "the notches zoomed in: {z0} -> {}", zoom(&h));
|
||||
let v = visible_image(&h);
|
||||
assert!(v[0] > 0.0 && v[1] > 0.0, "zoom_with_wheel {zoom_with_wheel}: the image left the view {v:?}");
|
||||
// The image's corner nearest the pointer is the anchor: it stays where it was on screen.
|
||||
let c = doc_at(&h, pos2(r.left() + 20.0, r.top() + 20.0));
|
||||
assert!(c[0] < 0.0 && c[1] < 0.0, "still pasteboard under the pointer, the corner held: {c:?}");
|
||||
// A pinch from there behaves the same.
|
||||
h.event(egui::Event::Zoom(4.0));
|
||||
h.run_steps(3);
|
||||
let v = visible_image(&h);
|
||||
assert!(v[0] > 0.0 && v[1] > 0.0, "pinch: the image left the view {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() {
|
||||
let mut h = harness();
|
||||
let r = h.state().last_canvas_rect;
|
||||
let p = pos2(r.center().x - 150.0, r.center().y - 100.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(2);
|
||||
let d0 = doc_at(&h, p);
|
||||
assert!(d0[0] > 0.0 && d0[1] > 0.0);
|
||||
for _ in 0..5 {
|
||||
wheel(&h, 1.0, Modifiers::ALT);
|
||||
h.run_steps(3);
|
||||
}
|
||||
h.run_steps(20);
|
||||
let d1 = doc_at(&h, p);
|
||||
assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}");
|
||||
}
|
||||
@@ -383,6 +383,32 @@ fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() {
|
||||
assert!(sel.sample_channel(120, 110, 0) < 0.01 && sel.sample_channel(90, 110, 0) > 0.99, "subtracted, not centred");
|
||||
}
|
||||
|
||||
/// #208: `⇧⌥` held at the press intersects, the third selection mode after add (`⇧`) and subtract
|
||||
/// (`⌥`). Like those, the modifier at the press picks the mode; here the keys are released once the
|
||||
/// press has fixed the mode, so the drag stays a plain rectangle and only the overlap of the
|
||||
/// existing selection and the dragged rectangle survives. The three sample points below are each in
|
||||
/// exactly one of {overlap, first-only, drag-only}, so they pass only for intersect, not for
|
||||
/// replace, add or subtract.
|
||||
#[test]
|
||||
fn shift_alt_before_the_drag_intersects_the_selection() {
|
||||
let mut h = harness(Tool::RectMarquee);
|
||||
// A: the existing selection, 50..200 on both axes.
|
||||
h.state_mut().run("select.rect", json!({"x": 50, "y": 50, "width": 150, "height": 150})).unwrap();
|
||||
let both = Modifiers::SHIFT | Modifiers::ALT;
|
||||
mods(&mut h, both);
|
||||
press_at(&mut h, 120.0, 120.0, both);
|
||||
// Mode is fixed at the press; releasing the keys keeps intersect mode but leaves the drag a
|
||||
// plain rectangle (no square/centre constraint), so B is roughly 120..300 on both axes.
|
||||
mods(&mut h, Modifiers::NONE);
|
||||
move_to(&mut h, 300.0, 300.0);
|
||||
release_at(&mut h, 300.0, 300.0, Modifiers::NONE);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sel = st.doc.selection.as_ref().expect("a selection survives the intersect");
|
||||
assert!(sel.sample_channel(160, 160, 0) > 0.99, "the overlap of the two rectangles stays selected");
|
||||
assert!(sel.sample_channel(60, 60, 0) < 0.01, "a point only in the existing selection is dropped (not add)");
|
||||
assert!(sel.sample_channel(260, 260, 0) < 0.01, "a point only in the dragged rectangle is dropped (not replace)");
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-ui-egui marquee_drag_bench -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
||||
@@ -11,6 +11,52 @@ const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1.
|
||||
/// The tallest menus.
|
||||
const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"];
|
||||
|
||||
/// #3737: on Wayland egui knows the monitor's size but never where the window is (no
|
||||
/// `inner_rect`). A window taller than the monitor less its taskbar or panel runs under it, so
|
||||
/// Layer, View and Window (and every other long menu) must end above it and scroll to their last
|
||||
/// row, at 1× and 1.5×.
|
||||
#[test]
|
||||
fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() {
|
||||
for scale in [1.0, 1.5] {
|
||||
for top in ["Layer", "View", "Window", "Filter", "Image", "Edit"] {
|
||||
let mut h = harness((1366.0 * scale, 740.0 * scale, scale));
|
||||
let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default();
|
||||
v.monitor_size = Some(egui::vec2(1366.0, 768.0));
|
||||
v.inner_rect = None;
|
||||
v.outer_rect = None;
|
||||
v.maximized = Some(false);
|
||||
h.run_steps(3);
|
||||
let bottom = 768.0 - crate::work_area::TASKBAR;
|
||||
let what = format!("{scale}x {top}");
|
||||
open(&mut h, top);
|
||||
for r in popups(&h) {
|
||||
assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})");
|
||||
}
|
||||
check_bar_clear(&h, &what);
|
||||
let last = rows(&h, 0).len() - 1;
|
||||
let in_view = |h: &Harness<'static, PhotocraftApp>| {
|
||||
let nav = Nav::current(&h.ctx);
|
||||
nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5
|
||||
};
|
||||
let view = Nav::current(&h.ctx).views[0];
|
||||
h.hover_at(view.center());
|
||||
for _ in 0..80 {
|
||||
if in_view(&h) {
|
||||
break;
|
||||
}
|
||||
h.event(egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: egui::vec2(0.0, -60.0),
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert!(in_view(&h), "{what}: the wheel reaches the last row, above the panel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 48})).unwrap();
|
||||
|
||||
@@ -292,6 +292,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
|
||||
app.open_dialog_file()
|
||||
}
|
||||
}
|
||||
"file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => {
|
||||
app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string()))
|
||||
}
|
||||
"file.save" => {
|
||||
// Writes back only to a layered file; a flat one goes through Save As.
|
||||
let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?;
|
||||
|
||||
@@ -182,3 +182,31 @@ fn every_theme_shows_the_type_options() {
|
||||
assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_theme_shows_the_gradient_options() {
|
||||
// #3601: the Studio themes' options bar was only the hint "Drag to draw a gradient", with no
|
||||
// mode, style, blend mode or opacity control.
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
for theme in crate::theme::ThemeKind::ALL {
|
||||
let (mut app, _, _) = app();
|
||||
app.ui.tool = Tool::Gradient;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::options_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.run_steps(4);
|
||||
let combos: Vec<String> = h
|
||||
.query_all_by_role(egui::accesskit::Role::ComboBox)
|
||||
.map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value()))
|
||||
.collect();
|
||||
assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}");
|
||||
}
|
||||
}
|
||||
@@ -545,7 +545,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
|
||||
ui.set_width(260.0);
|
||||
ui.label(RichText::new(tl!("PRESET DETAILS")).size(11.0).color(t.text_faint));
|
||||
ui.add_space(4.0);
|
||||
let mut name = get_s(f, "name", tl!("Untitled-1"));
|
||||
// Keep the canonical English default in state; only the TextEdit shows a translation.
|
||||
let raw = get_s(f, "name", "Untitled-1");
|
||||
let mut name = if raw == "Untitled-1" { tl!("Untitled-1").to_string() } else { raw };
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(250.0).font(egui::FontId::proportional(15.0)));
|
||||
if r.changed() {
|
||||
f.insert("name".into(), json!(name));
|
||||
@@ -584,9 +586,11 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<Strin
|
||||
set_size(f, "height", h, &unit, ppi);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
small_label(ui, tl!("Orientation"));
|
||||
// Page orientation (≠ Type › Orientation → Hướng chữ).
|
||||
small_label(ui, crate::i18n::tr_ctx(crate::i18n::current(), "document", "Orientation"));
|
||||
let (w, h) = (get_f(f, "width", 1920.0), get_f(f, "height", 1080.0));
|
||||
for (icon, portrait) in [("rectangle-vertical", true), ("rectangle-horizontal", false)] {
|
||||
// Tooltip strings are looked up via `icons::button` → `tl!`; enum/state is aspect swap only.
|
||||
if icons::button(ui, icon, 24.0, (h > w) == portrait, if portrait { "Portrait" } else { "Landscape" }).clicked() && (h > w) != portrait {
|
||||
swap_size(f);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
for tool in crate::state::Tool::ALL {
|
||||
if let Some(s) = fuzzy_score(&q, &format!("{} {}", tl!(tool.label()), tool.label())) {
|
||||
let s = s + exact_bonus(&q, &[tl!(tool.label()).as_ref(), tool.label()]);
|
||||
hits.push((s + 2, format!("tool:{tool:?}"), tl!(tool.label()).into(), Some(format!("Tool {}", tool.key())), true));
|
||||
hits.push((
|
||||
s + 2,
|
||||
format!("tool:{tool:?}"),
|
||||
tl!(tool.label()).into(),
|
||||
Some(format!("{} {}", crate::i18n::tr(lang, "Tool"), tool.key())),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,9 +128,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
+ chips_height(t.pro, t.round_chips, t.round_chips && double)
|
||||
+ if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 }
|
||||
+ 6.0;
|
||||
// As tall as the tools, so the colour chips sit right under them (Photoshop); a column
|
||||
// taller than the window scrolls and keeps the chips at the bottom.
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("toolbar-columns")
|
||||
.auto_shrink([false, false])
|
||||
.auto_shrink([false, true])
|
||||
.max_height((ui.available_height() - footer).max(0.0))
|
||||
.min_scrolled_height(0.0)
|
||||
.show(ui, |ui| {
|
||||
@@ -1157,7 +1159,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous"));
|
||||
widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers"));
|
||||
}
|
||||
Tool::Gradient if t.pro => {
|
||||
Tool::Gradient => {
|
||||
// Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels).
|
||||
let mut classic = app.ui.tool_options.gradient_classic;
|
||||
if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) {
|
||||
@@ -1412,7 +1414,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
&[("commit", &crate::shortcuts::pretty("Enter"))],
|
||||
),
|
||||
),
|
||||
Tool::Gradient => hint(ui, tl!("Drag to draw a gradient")),
|
||||
Tool::PaintBucket => hint(ui, tl!("Click to fill similar colours")),
|
||||
// Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`.
|
||||
_ => {}
|
||||
@@ -1450,10 +1451,10 @@ fn label(ui: &mut egui::Ui, s: &str) {
|
||||
|
||||
/// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without
|
||||
/// hitting the small number field. The unique drag ID coalesces every frame into one undo step.
|
||||
fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32) -> widgets::PopupFieldResponse {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp =
|
||||
ui.add(egui::Label::new(RichText::new(text).color(t.text_dim)).sense(Sense::click_and_drag())).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32, max_w: f32) -> widgets::PopupFieldResponse {
|
||||
// Elide to the room left in the row (full text on hover): a longer translation shrinks here
|
||||
// instead of running over the fixed controls beside it (#3580).
|
||||
let resp = widgets::elided_label_sensed(ui, text, max_w, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||
let key = resp.id.with("percent-label-drag");
|
||||
if resp.drag_started() {
|
||||
let stamp = ui.ctx().cumulative_pass_nr();
|
||||
@@ -2115,7 +2116,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let mut o = l.opacity * 100.0;
|
||||
let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
// On a narrow dock the (scrubby) label yields its room to the controls (#2309).
|
||||
let scrub = if show_opacity_label { scrub_pct_label(ui, opacity_label, &mut o) } else { widgets::PopupFieldResponse::default() };
|
||||
let scrub = if show_opacity_label {
|
||||
scrub_pct_label(ui, opacity_label, &mut o, ui.available_width())
|
||||
} else {
|
||||
widgets::PopupFieldResponse::default()
|
||||
};
|
||||
if field.changed || scrub.changed {
|
||||
actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag)));
|
||||
}
|
||||
@@ -2126,7 +2131,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.add_space((theme::ROW_GAP - ui.spacing().item_spacing.y).max(0.0));
|
||||
ui.horizontal(|ui| {
|
||||
let lock_label = if t.pro { tl!("Lock:") } else { tl!("Lock") };
|
||||
let fill_label = if t.pro { tl!("Fill:") } else { tl!("Fill") };
|
||||
// Layer fill opacity (≠ Edit › Fill / path fill → Tô).
|
||||
let lang = crate::i18n::current();
|
||||
let fill_label = if t.pro { crate::i18n::tr_ctx(lang, "layers", "Fill:") } else { crate::i18n::tr_ctx(lang, "layers", "Fill") };
|
||||
// In a narrow panel with long translated labels, drop the "Lock:" text (the icons keep
|
||||
// their tooltips) rather than let the Fill label run over the lock icons.
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
@@ -2164,7 +2171,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let mut f = l.fill_opacity * 100.0;
|
||||
let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W);
|
||||
let scrub = scrub_pct_label(ui, fill_label, &mut f);
|
||||
let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width());
|
||||
if field.changed || scrub.changed {
|
||||
actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag)));
|
||||
}
|
||||
@@ -3176,7 +3183,7 @@ fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "opacity": o / 100.0}));
|
||||
}
|
||||
let mut f = layer.fill_opacity * 100.0;
|
||||
let r = widgets::slider_row(ui, tl!("Fill"), &mut f, 0.0..=100.0, "%", None);
|
||||
let r = widgets::slider_row(ui, crate::i18n::tr_ctx(crate::i18n::current(), "layers", "Fill"), &mut f, 0.0..=100.0, "%", None);
|
||||
if r.drag_stopped() || (r.changed() && !r.dragged()) {
|
||||
let _ = app.run("layer.setProps", json!({"layer": layer.id.0, "fill": f / 100.0}));
|
||||
}
|
||||
@@ -5229,8 +5236,12 @@ mod compact_visual_regressions {
|
||||
}
|
||||
|
||||
fn toolbar_harness(kind: theme::ThemeKind, scale: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
toolbar_harness_sized(kind, scale, 360.0)
|
||||
}
|
||||
|
||||
fn toolbar_harness_sized(kind: theme::ThemeKind, scale: f32, height: f32) -> egui_kittest::Harness<'static, PhotocraftApp> {
|
||||
let mut ready = false;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 360.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, height)).with_pixels_per_point(scale).build_ui_state(
|
||||
move |ui, app: &mut PhotocraftApp| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
@@ -5299,6 +5310,18 @@ mod compact_visual_regressions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A window taller than the tools keeps the colour chips right under the last tool, as
|
||||
/// Photoshop does, instead of at the bottom of the toolbar.
|
||||
#[test]
|
||||
fn tall_toolbar_keeps_the_colour_chips_under_the_tools() {
|
||||
for kind in theme::ThemeKind::ALL {
|
||||
let harness = toolbar_harness_sized(kind, 1.0, 1600.0);
|
||||
let last = harness.get_by_label("Zoom Tool (Z)").rect();
|
||||
let swap = harness.get_by_label("Swap colours (X)").rect();
|
||||
assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_compact_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
|
||||
@@ -261,7 +261,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2
|
||||
return p;
|
||||
}
|
||||
let (out, lines) = match &snap.gesture {
|
||||
Gesture::Point => live_crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)),
|
||||
Gesture::Point => crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)),
|
||||
Gesture::Guide => snap.targets.snap_point(p, tol),
|
||||
Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => {
|
||||
let d = [p[0] - snap.start[0], p[1] - snap.start[1]];
|
||||
@@ -275,18 +275,26 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2
|
||||
out
|
||||
}
|
||||
|
||||
/// Snap the proposed handle on the image, calculated from the original camera. Snapping the
|
||||
/// pointer in the moving camera feeds the previous frame's pan back into the resize and makes
|
||||
/// Snap a crop handle being dragged: the handle itself (the frame's edge or corner), not the
|
||||
/// pointer, which grabbed it up to the grab radius away (#1919: snapping the pointer left a strip
|
||||
/// as wide as that offset between the frame and the edge it showed as snapped to). With Auto
|
||||
/// Center Preview the handle is found on the image, calculated from the original camera: snapping
|
||||
/// the pointer in the moving camera feeds the previous frame's pan back into the resize and makes
|
||||
/// a stationary pointer oscillate near the canvas edges.
|
||||
fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
|
||||
fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec<SnapLine>)> {
|
||||
if app.ui.tool != Tool::Crop {
|
||||
return None;
|
||||
}
|
||||
let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view: Some(view) } = app.crop.drag? else { return None };
|
||||
let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view } = app.crop.drag? else { return None };
|
||||
let deg = crate::crop_ui::angle(app);
|
||||
let q = crate::crop_mode::to_press(app, Some(view), p);
|
||||
let g = crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio: crate::crop_ui::resize_ratio(app, rect, mods), alt: mods.alt };
|
||||
let (frame, _) = crate::crop_mode::resized_frame(g)?;
|
||||
let ratio = crate::crop_ui::resize_ratio(app, rect, mods);
|
||||
let q = view.map_or(p, |view| crate::crop_mode::to_press(app, Some(view), p));
|
||||
// The frame for the pointer at `q`, as `crop_ui` resizes it.
|
||||
let frame_at = |q: [f64; 2]| match view {
|
||||
Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f),
|
||||
None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)),
|
||||
};
|
||||
let frame = frame_at(q)?;
|
||||
let at = |r: [f64; 4]| {
|
||||
let c = crate::crop_ui::center(r);
|
||||
crate::crop_ui::turn([c[0] + f64::from(hx) * (r[2] - r[0]) / 2.0, c[1] + f64::from(hy) * (r[3] - r[1]) / 2.0], c, deg)
|
||||
@@ -304,35 +312,39 @@ fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, tar
|
||||
if deg == 0.0 {
|
||||
lines.retain(|line| if line.vertical { hx != 0 } else { hy != 0 });
|
||||
}
|
||||
// Auto Center Preview moves both screen edges. On the image the opposite edge stays put,
|
||||
// so the grabbed edge travels twice as far as the pointer (once with Alt).
|
||||
let scale = if mods.alt { 1.0 } else { 2.0 };
|
||||
if lines.is_empty() {
|
||||
return Some((p, lines));
|
||||
}
|
||||
// Auto Center Preview moves both screen edges. On the image the opposite edge stays put,
|
||||
// so the grabbed edge travels twice as far as the pointer (once with Alt). Without it the
|
||||
// handle follows the pointer.
|
||||
let scale = if view.is_some() && !mods.alt { 2.0 } else { 1.0 };
|
||||
// How much the frame's extent grows per unit of pointer travel: both sides with Auto Center
|
||||
// Preview or Alt, else the grabbed one.
|
||||
let grow = if view.is_some() || mods.alt { 2.0 } else { 1.0 };
|
||||
let original = at(rect);
|
||||
let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg);
|
||||
let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale];
|
||||
if deg == 0.0
|
||||
&& hx != 0
|
||||
&& hy != 0
|
||||
&& let Some(k) = g.ratio
|
||||
&& let Some(k) = ratio
|
||||
{
|
||||
// A corner with a ratio has one degree of freedom. Put the other axis on the same
|
||||
// ratio, rather than letting the raw pointer's unused axis override the snapped edge.
|
||||
if lines.iter().any(|line| line.vertical) {
|
||||
local[1] = f64::from(hy) * ((rect[2] - rect[0] + 2.0 * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / 2.0;
|
||||
local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow;
|
||||
} else {
|
||||
local[0] = f64::from(hx) * ((rect[3] - rect[1] + 2.0 * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / 2.0;
|
||||
local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow;
|
||||
}
|
||||
}
|
||||
let d = crate::crop_ui::turn(local, [0.0, 0.0], deg);
|
||||
let q = [start[0] + d[0], start[1] + d[1]];
|
||||
if let Some((snapped_frame, _)) = crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { q, ..g }) {
|
||||
if let Some(snapped_frame) = frame_at(q) {
|
||||
let h = at(snapped_frame);
|
||||
lines.retain(|line| (if line.vertical { h[0] } else { h[1] } - line.pos).abs() < 1e-3);
|
||||
}
|
||||
Some((crate::crop_mode::from_press(app, view, q), lines))
|
||||
Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines))
|
||||
}
|
||||
|
||||
/// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it).
|
||||
|
||||
@@ -58,11 +58,16 @@ impl PreviewTextures {
|
||||
if !s.is_finite() || s <= 0.0 {
|
||||
return (0, false);
|
||||
}
|
||||
// Nearest Neighbor is point sampling: downsampled mip levels have already averaged
|
||||
// neighbouring texels, so using one would blur a pixel-art transform preview.
|
||||
if nearest_interpolation {
|
||||
return (0, true);
|
||||
}
|
||||
if s < 0.5 {
|
||||
// log2(1/s) levels down, at most 12 (4096× smaller).
|
||||
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation);
|
||||
return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, false);
|
||||
}
|
||||
let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05);
|
||||
let crisp = zoom >= CRISP_ZOOM && scale <= 1.05;
|
||||
(0, crisp)
|
||||
}
|
||||
|
||||
@@ -203,6 +208,10 @@ mod tests {
|
||||
assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true));
|
||||
assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false));
|
||||
assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false));
|
||||
// Nearest Neighbor must use the full-resolution texels, even when zoomed far out.
|
||||
// A mipmap would average adjacent opaque/transparent pixels before sampling.
|
||||
assert_eq!(PreviewTextures::choose(0.25, 1.0, true), (0, true));
|
||||
assert_eq!(PreviewTextures::choose(1.0 / 4096.0, 1.0, true), (0, true));
|
||||
assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false));
|
||||
assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12);
|
||||
}
|
||||
|
||||
@@ -1251,9 +1251,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
}
|
||||
// Distort only moves corners (and the whole box from inside): no rotating, no edges.
|
||||
// Even in Distort mode, remember an outside press so a plain click can
|
||||
// accept the transform. A drag outside remains inert in that mode, and in
|
||||
// accept the transform. A drag outside remains inert in that mode, in Skew, and in
|
||||
// Content-Aware Scale, which only scales and moves.
|
||||
pv.gesture = (distort_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||||
pv.gesture = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot });
|
||||
}
|
||||
ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => {
|
||||
let g = pv.gesture;
|
||||
@@ -1268,15 +1268,20 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
return true;
|
||||
}
|
||||
let legacy = app.session.prefs().general.use_legacy_free_transform;
|
||||
let type_layer = moves_type(app, &t);
|
||||
if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut())
|
||||
&& distort_allows(t.mode, g.hit)
|
||||
&& mode_allows(t.mode, g.hit)
|
||||
&& (t.content_aware.is_none() || g.hit != Hit::Outside)
|
||||
{
|
||||
let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) };
|
||||
let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods));
|
||||
// Content-Aware Scale: ⌘ neither skews nor distorts.
|
||||
mods.command &= t.content_aware.is_none();
|
||||
apply_drag(s, g, [x, y], mods);
|
||||
if t.content_aware.is_some() {
|
||||
let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods));
|
||||
// Content-Aware Scale: ⌘ neither skews nor distorts.
|
||||
mods.command = false;
|
||||
apply_drag(s, g, [x, y], mods);
|
||||
} else {
|
||||
apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, mods));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1318,11 +1323,11 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) ->
|
||||
mods
|
||||
}
|
||||
|
||||
/// Skew / Distort / Perspective modes: a handle drag acts as if that gesture's keys were held
|
||||
/// (⌘-drag an edge skews, ⌘-drag a corner distorts, ⌘⌥⇧-drag a corner adds perspective).
|
||||
/// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a
|
||||
/// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]).
|
||||
fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers {
|
||||
match (mode, hit) {
|
||||
(TransformMode::Skew, Hit::Edge(_)) | (TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
|
||||
(TransformMode::Distort, Hit::Corner(_)) => mods.command = true,
|
||||
(TransformMode::Perspective, Hit::Corner(_)) => {
|
||||
mods.command = true;
|
||||
mods.alt = true;
|
||||
@@ -1333,9 +1338,67 @@ fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::
|
||||
mods
|
||||
}
|
||||
|
||||
/// In Distort mode only corner drags (and moving the box from inside) do anything.
|
||||
fn distort_allows(mode: TransformMode, h: Hit) -> bool {
|
||||
mode != TransformMode::Distort || !matches!(h, Hit::Outside | Hit::Edge(_))
|
||||
/// A handle drag in Edit › Transform `mode` (`Free` for Free Transform). `type_layer`: the box
|
||||
/// moves editable type, which only takes affine transforms.
|
||||
pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mode: TransformMode, type_layer: bool, mut mods: egui::Modifiers) {
|
||||
if !mode_allows(mode, g.hit) {
|
||||
return;
|
||||
}
|
||||
match (mode, g.hit) {
|
||||
// Skew, as in Photoshop (#3437): a corner moves alone along one of its two edges,
|
||||
// whichever the drag mostly follows; ⌥ moves the opposite corner the other way.
|
||||
(TransformMode::Skew, Hit::Corner(i)) if i < 4 => {
|
||||
let (d, q0) = ([p[0] - g.start[0], p[1] - g.start[1]], g.quad0);
|
||||
// Corners 0..3 = TL, TR, BR, BL: the neighbour along the top/bottom edge, and along
|
||||
// the left/right one.
|
||||
let along = |j: usize| {
|
||||
let e = [q0[i][0] - q0[j][0], q0[i][1] - q0[j][1]];
|
||||
let l = e[0].hypot(e[1]);
|
||||
let u = if l > 0.0 { [e[0] / l, e[1] / l] } else { [0.0, 0.0] };
|
||||
let t = d[0] * u[0] + d[1] * u[1];
|
||||
[t * u[0], t * u[1]]
|
||||
};
|
||||
let (hn, vn) = ([1, 0, 3, 2][i], [3, 2, 1, 0][i]);
|
||||
let (h, v) = (along(hn), along(vn));
|
||||
let (m, partner) = if h[0].hypot(h[1]) >= v[0].hypot(v[1]) { (h, hn) } else { (v, vn) };
|
||||
// Editable type stays a parallelogram: the corner's whole edge slides with it.
|
||||
let moved: &[usize] = if type_layer { &[i, partner] } else { &[i] };
|
||||
let mut q = q0;
|
||||
for k in 0..4 {
|
||||
let sign = if moved.contains(&k) {
|
||||
1.0
|
||||
} else if mods.alt && (if type_layer { true } else { k == (i + 2) % 4 }) {
|
||||
// ⌥: the opposite corner (on type, the opposite edge) the other way.
|
||||
-1.0
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if let (Some(dst), Some(src)) = (q.get_mut(k), q0.get(k)) {
|
||||
*dst = [src[0] + sign * m[0], src[1] + sign * m[1]];
|
||||
}
|
||||
}
|
||||
s.quad = keep_convex(q0, q);
|
||||
s.pivot = carry_pivot(q0, s.quad, g.pivot0);
|
||||
}
|
||||
// Skew: an edge slides only along itself (⌘⇧ in Free Transform).
|
||||
(TransformMode::Skew, Hit::Edge(_)) => {
|
||||
mods.command = true;
|
||||
mods.shift = true;
|
||||
apply_drag(s, g, p, mods);
|
||||
}
|
||||
_ => apply_drag(s, g, p, mode_mods(mode, g.hit, mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drag can do in each Edit › Transform mode: in Distort only the corners (and moving the
|
||||
/// box from inside); in Skew everything but rotating from outside the box (#3437). A click
|
||||
/// outside still commits.
|
||||
fn mode_allows(mode: TransformMode, h: Hit) -> bool {
|
||||
match mode {
|
||||
TransformMode::Distort => !matches!(h, Hit::Outside | Hit::Edge(_)),
|
||||
TransformMode::Skew => h != Hit::Outside,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The turn at each corner of a quad (cross products of consecutive edges): all the same sign
|
||||
@@ -1770,8 +1833,12 @@ pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64
|
||||
}
|
||||
|
||||
fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> {
|
||||
// Content-Aware Scale's box only scales: no rotate arrow.
|
||||
if !(p[0].is_finite() && p[1].is_finite()) || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Distort {
|
||||
// Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow.
|
||||
if !(p[0].is_finite() && p[1].is_finite())
|
||||
|| t.warp.is_some()
|
||||
|| t.content_aware.is_some()
|
||||
|| matches!(t.mode, TransformMode::Distort | TransformMode::Skew)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let rotating = pv.is_some_and(|pv| pv.gesture.is_some_and(|g| g.hit == Hit::Outside));
|
||||
@@ -1812,7 +1879,7 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option<Cur
|
||||
return Some(if w.mesh.as_ref().and_then(|m| surface_at(m, p)).is_some() { CursorIcon::Grab } else { CursorIcon::Default });
|
||||
}
|
||||
let h = hit(t, p, tol);
|
||||
if !distort_allows(t.mode, h) || (t.content_aware.is_some() && h == Hit::Outside) {
|
||||
if !mode_allows(t.mode, h) || (t.content_aware.is_some() && h == Hit::Outside) {
|
||||
return Some(CursorIcon::Default);
|
||||
}
|
||||
if rotate_cursor_dir(app, p).is_some() {
|
||||
@@ -2964,6 +3031,60 @@ mod tests {
|
||||
apply_drag(s, g, to, mods);
|
||||
}
|
||||
|
||||
/// A handle drag in Edit › Transform `mode`, as the canvas applies it (on a pixel layer).
|
||||
fn drag_in(mode: TransformMode, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
|
||||
drag_on(mode, false, from, to, mods)
|
||||
}
|
||||
|
||||
fn drag_on(mode: TransformMode, type_layer: bool, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] {
|
||||
let mut s = session();
|
||||
let g = Gesture { hit: hit(&s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot };
|
||||
apply_mode_drag(&mut s, g, to, mode, type_layer, mods);
|
||||
s.quad
|
||||
}
|
||||
|
||||
/// Skew on editable type, checked in Photoshop: a corner can't move alone (type stays a
|
||||
/// parallelogram), so it slides its whole edge: the top or bottom edge for a mostly
|
||||
/// horizontal drag, the left or right one for a mostly vertical drag. ⌥ moves the opposite
|
||||
/// edge the other way. Side handles are as on any layer.
|
||||
#[test]
|
||||
fn skew_mode_on_type_slides_the_corners_edge() {
|
||||
let (skew, none) = (TransformMode::Skew, egui::Modifiers::NONE);
|
||||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], none), [[10.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 41.0], [0.0, 50.0]]);
|
||||
assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], egui::Modifiers::ALT), [[10.0, 0.0], [110.0, 0.0], [90.0, 50.0], [-10.0, 50.0]]);
|
||||
assert_eq!(drag_on(skew, true, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||||
}
|
||||
|
||||
/// Edit › Transform › Skew, checked in Photoshop (#3437): a corner moves alone, horizontally or
|
||||
/// vertically (whichever way the drag mostly goes); the top and bottom handles slide only
|
||||
/// horizontally, the left and right ones only vertically; ⌥ moves the opposite corner or edge
|
||||
/// the other way. Nothing scales.
|
||||
#[test]
|
||||
fn skew_mode_moves_a_corner_or_an_edge_along_one_axis() {
|
||||
let skew = TransformMode::Skew;
|
||||
let none = egui::Modifiers::NONE;
|
||||
// Corner (top right), mostly horizontal, then mostly vertical: only that corner moves.
|
||||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], none), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||||
assert_eq!(drag_in(skew, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 50.0], [0.0, 50.0]]);
|
||||
// Top edge: horizontal only, both its corners.
|
||||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]);
|
||||
// Left edge: vertical only.
|
||||
assert_eq!(drag_in(skew, [0.0, 25.0], [6.0, 33.0], none), [[0.0, 8.0], [100.0, 0.0], [100.0, 50.0], [0.0, 58.0]]);
|
||||
// ⌥: the opposite corner, and the opposite edge, go the other way.
|
||||
let alt = egui::Modifiers::ALT;
|
||||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], alt), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [-10.0, 50.0]]);
|
||||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], alt), [[12.0, 0.0], [112.0, 0.0], [88.0, 50.0], [-12.0, 50.0]]);
|
||||
// ⇧ changes nothing in Skew mode.
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], shift), drag_in(skew, [100.0, 0.0], [110.0, 3.0], none));
|
||||
assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], shift), drag_in(skew, [50.0, 0.0], [62.0, 7.0], none));
|
||||
// A drag outside the box doesn't rotate it in Skew mode.
|
||||
assert_eq!(drag_in(skew, [130.0, 25.0], [120.0, 60.0], none), corners([0.0, 0.0, 100.0, 50.0]));
|
||||
// Free Transform is unchanged: a corner scales.
|
||||
assert_ne!(drag_in(TransformMode::Free, [100.0, 0.0], [110.0, 3.0], none)[2], [100.0, 50.0]);
|
||||
}
|
||||
|
||||
fn close(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool {
|
||||
a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
@@ -454,9 +454,19 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
|
||||
ui.add_enabled_ui(on, |ui| match kind {
|
||||
"align" => {
|
||||
let mut a = v.as_str().unwrap_or("Left").to_string();
|
||||
let opts = [tl!("Left"), tl!("Center"), tl!("Right"), "JustifyLeft", "JustifyCenter", "JustifyRight", "JustifyAll"]
|
||||
.map(|o| (o.to_string(), o));
|
||||
let opts: Vec<(String, &str)> = opts.to_vec();
|
||||
// English values for the engine; labels go through `dropdown`'s `tl!`.
|
||||
let opts: Vec<(String, &str)> = [
|
||||
("Left", "Left"),
|
||||
("Center", "Center"),
|
||||
("Right", "Right"),
|
||||
("JustifyLeft", "Justify last left"),
|
||||
("JustifyCenter", "Justify last centered"),
|
||||
("JustifyRight", "Justify last right"),
|
||||
("JustifyAll", "Justify all"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(v, l)| (v.to_string(), l))
|
||||
.collect();
|
||||
if crate::widgets::dropdown(ui, &format!("style-align-{id}"), &mut a, &opts, 110.0) {
|
||||
set = Some(json!({ "align": a }));
|
||||
}
|
||||
|
||||
@@ -1848,6 +1848,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
axis_sliders(app, ui, &c);
|
||||
row(ui, &mut |ui| {
|
||||
let k = shown_scale(app);
|
||||
// Hover tips stay English until wired through `tl!` in a multi-locale change
|
||||
// (`every_tl_literal_is_translated` requires every complete catalog).
|
||||
if let Some(v) = num_field(ui, "tT", "Font size", c.size_pt * k, 0.1..=1296.0, "pt", w) {
|
||||
app.ui.tool_options.type_size = v;
|
||||
apply(app, ui.ctx(), json!({"size": v, "metricsAsShown": true}));
|
||||
@@ -1904,18 +1906,19 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
});
|
||||
// Faux styles: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
|
||||
let caps = c.caps;
|
||||
// Tip strings are English keys; `toggle_cell` runs them through `tl!`.
|
||||
let toggles: [(&str, &str, bool, serde_json::Value); 6] = [
|
||||
("T", "Faux Bold", c.faux_bold, json!({"fauxBold": !c.faux_bold})),
|
||||
("T", "Faux Italic", c.faux_italic, json!({"fauxItalic": !c.faux_italic})),
|
||||
(
|
||||
"TT",
|
||||
tl!("All Caps"),
|
||||
"All Caps",
|
||||
caps == photocraft_doc::text::Caps::AllCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::AllCaps { "normal" } else { "allCaps" }}),
|
||||
),
|
||||
(
|
||||
"Tᴛ",
|
||||
tl!("Small Caps"),
|
||||
"Small Caps",
|
||||
caps == photocraft_doc::text::Caps::SmallCaps,
|
||||
json!({"caps": if caps == photocraft_doc::text::Caps::SmallCaps { "normal" } else { "smallCaps" }}),
|
||||
),
|
||||
|
||||
@@ -1193,7 +1193,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
let gesture = paint_gesture(ui, gesture_key);
|
||||
let coalesce = |field: &str| format!("{}:{}:{gesture}", key(field), gesture_key.value());
|
||||
// The shared collapsible section headers (#155).
|
||||
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
|
||||
// Prefs "Appearance" is theme chrome (Giao diện); shape fill/stroke is Diện mạo.
|
||||
if crate::props_layout::section(ui, "appearance", crate::i18n::tr_ctx(crate::i18n::current(), "shape", "Appearance")) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Fill")).color(t.text_dim).size(12.0));
|
||||
if let Some(change) = swatch(ui, sh.fill.as_ref(), app.session.tools.foreground, tl!("Set shape fill type")) {
|
||||
|
||||
@@ -459,6 +459,8 @@ fn wraps(id: &str) -> bool {
|
||||
| "type.pasteLoremIpsum"
|
||||
| "image.applyImage"
|
||||
| "image.calculations"
|
||||
| "select.saveSelection"
|
||||
| "select.loadSelection"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1118,8 +1120,16 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option<Result<Val
|
||||
"add",
|
||||
"subtract",
|
||||
];
|
||||
// The active layer's transparency, and its mask only when it has one.
|
||||
let layer = st.active_layer.and_then(|l| d.layer(l));
|
||||
let mut load = chans.clone();
|
||||
load.extend(["transparency", "mask", "selection"].map(String::from));
|
||||
if layer.is_some() {
|
||||
load.push("transparency".into());
|
||||
}
|
||||
if layer.is_some_and(|l| l.mask.is_some()) {
|
||||
load.push("mask".into());
|
||||
}
|
||||
load.push("selection".into());
|
||||
match id {
|
||||
"select.saveSelection" => dialog(
|
||||
app,
|
||||
|
||||
@@ -291,3 +291,55 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() {
|
||||
assert_eq!(direct["layerCount"], 1);
|
||||
assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_selection_opens_its_dialog_and_saves_on_confirm() {
|
||||
// #3713: Select › Save Selection… asks for the channel, name and operation first.
|
||||
let (mut app, ctx) = app_with(1);
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().expect("Save Selection opens a dialog");
|
||||
assert_eq!(app.session.active().unwrap().revision, revision, "saved before confirmation");
|
||||
assert!(app.session.active().unwrap().doc.channels.is_empty());
|
||||
let fields = &mut app.ui.dialog_mut(dialog).unwrap().fields;
|
||||
assert_eq!(fields["__command"], "select.saveSelection");
|
||||
assert_eq!(fields["channel"], "new");
|
||||
assert_eq!(fields["__choices"]["operation"], json!(["new", "replace", "add", "subtract", "intersect"]));
|
||||
fields.insert("name".into(), json!("Keep"));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
let names: Vec<_> = app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect();
|
||||
assert_eq!(names, ["Keep"]);
|
||||
// A second save lists the new channel as a destination.
|
||||
let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__choices"]["channel"], json!(["new", "Keep"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_selection_opens_its_dialog_and_loads_on_confirm() {
|
||||
// #3713: Select › Load Selection… used to fail with "missing `channel`".
|
||||
let (mut app, ctx) = app_with(1);
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("select.saveSelection", json!({"name": "Keep"})).unwrap();
|
||||
app.run("select.deselect", json!({})).unwrap();
|
||||
let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().expect("Load Selection opens a dialog");
|
||||
assert!(app.session.active().unwrap().doc.selection.is_none(), "loaded before confirmation");
|
||||
let fields = &app.ui.dialog_mut(dialog).unwrap().fields;
|
||||
assert_eq!(fields["__command"], "select.loadSelection");
|
||||
assert_eq!(fields["channel"], "Keep");
|
||||
assert_eq!(fields["invert"], false);
|
||||
assert_eq!(fields["operation"], "new");
|
||||
let channels = fields["__choices"]["channel"].as_array().unwrap();
|
||||
assert!(channels.contains(&json!("Keep")) && channels.contains(&json!("transparency")));
|
||||
// The layer has no mask, so its mask is not offered.
|
||||
assert!(!channels.contains(&json!("mask")), "{channels:?}");
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert!(app.session.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_selection_without_alpha_channels_offers_the_composite() {
|
||||
let (mut app, ctx) = app_with(1);
|
||||
let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().unwrap();
|
||||
assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB");
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
}
|
||||
@@ -246,6 +246,27 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive
|
||||
value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts::default()).0
|
||||
}
|
||||
|
||||
/// A single-line label for a row that also holds fixed-width controls: laid out no wider than
|
||||
/// `max_w`, cut with '…' when the text (often a longer translation) does not fit, with the full
|
||||
/// string on hover. Fixed chrome must never let a label overrun its row (#3580), so an *essential*
|
||||
/// label beside a fixed control should use this rather than a show/hide fit check; a *redundant*
|
||||
/// label (one whose control already carries the meaning, e.g. an icon row's caption) may be dropped.
|
||||
pub fn elided_label_sensed(ui: &mut Ui, text: &str, max_w: f32, sense: Sense) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let galley = crate::tab_strip::elided(ui, text, font, t.text_dim, max_w);
|
||||
let elided = galley.elided;
|
||||
let (rect, resp) = ui.allocate_exact_size(galley.size(), sense);
|
||||
ui.painter().galley(rect.min, galley, t.text_dim);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text));
|
||||
if elided { resp.on_hover_text(text) } else { resp }
|
||||
}
|
||||
|
||||
/// [`elided_label_sensed`] with hover only (a plain, non-interactive label).
|
||||
pub fn elided_label(ui: &mut Ui, text: &str, max_w: f32) -> Response {
|
||||
elided_label_sensed(ui, text, max_w, Sense::hover())
|
||||
}
|
||||
|
||||
/// Height of a [`value_field`]'s box.
|
||||
pub const VALUE_FIELD_H: f32 = 24.0;
|
||||
|
||||
@@ -1594,6 +1615,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #3580: the shared eliding label never lays out wider than the room it is given, so a longer
|
||||
/// translation shrinks with '…' instead of overrunning its row; a short label is untouched.
|
||||
#[test]
|
||||
fn elided_label_never_exceeds_its_room() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui(|ui| {
|
||||
let long = super::elided_label(ui, "Preenchimento:", 24.0); // pt-br Fill:, the longest catalog
|
||||
assert!(long.rect.width() <= 25.0, "elided to the room, got {}", long.rect.width());
|
||||
let short = super::elided_label(ui, "Fill", 200.0);
|
||||
assert!(short.rect.width() < 60.0, "a short label is not padded: {}", short.rect.width());
|
||||
});
|
||||
crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectrum_popup_style_matches_main_menu_and_preserves_studio() {
|
||||
for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! [`fit_window`] also fixes the cause at startup: a window that doesn't fit its monitor is
|
||||
//! maximized, which puts it in the work area (Photoshop opens maximized on small displays).
|
||||
|
||||
use egui::{Rect, Vec2, pos2};
|
||||
use egui::{Rect, Vec2, pos2, vec2};
|
||||
|
||||
/// Room kept free at the monitor's bottom edge for a taskbar or Dock egui can't see (the Windows
|
||||
/// taskbar is 40–48 pt tall; a bottom Dock about as much).
|
||||
@@ -22,9 +22,19 @@ const MIN_VISIBLE: f32 = 160.0;
|
||||
/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen`
|
||||
/// is true for a maximized or full-screen window (already inside the work area).
|
||||
pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, fills_screen: bool) -> Rect {
|
||||
let (Some(m), Some(inner)) = (monitor, inner) else { return content };
|
||||
let finite = m.x.is_finite() && m.y.is_finite() && inner.min.is_finite() && inner.max.is_finite();
|
||||
if fills_screen || !finite || m.x < 1.0 || m.y < 1.0 {
|
||||
let Some(m) = monitor else { return content };
|
||||
if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 {
|
||||
return content;
|
||||
}
|
||||
// #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has
|
||||
// no `inner_rect` there, only the monitor's size. A window bigger than the monitor less the
|
||||
// taskbar still runs under it wherever it is: at best its content starts at the monitor's top,
|
||||
// so at most that much of it can be seen.
|
||||
let Some(inner) = inner else {
|
||||
let room = vec2(m.x, m.y - TASKBAR);
|
||||
return trusted(content, Rect::from_min_size(content.min, room));
|
||||
};
|
||||
if !inner.min.is_finite() || !inner.max.is_finite() {
|
||||
return content;
|
||||
}
|
||||
// Only the monitor's size is known, not where it is on the desktop: assume monitors tile the
|
||||
@@ -36,7 +46,12 @@ pub fn visible_part(content: Rect, monitor: Option<Vec2>, inner: Option<Rect>, f
|
||||
// Desktop → window coordinates.
|
||||
let shift = content.min - inner.min;
|
||||
let local = usable.translate(shift);
|
||||
let visible = content.intersect(local);
|
||||
trusted(content, local)
|
||||
}
|
||||
|
||||
/// `content` cut to `usable`, unless that leaves too little to be a sensible guess.
|
||||
fn trusted(content: Rect, usable: Rect) -> Rect {
|
||||
let visible = content.intersect(usable);
|
||||
if !visible.is_positive() || visible.height() < MIN_VISIBLE.min(content.height()) || visible.width() < MIN_VISIBLE.min(content.width()) {
|
||||
return content;
|
||||
}
|
||||
@@ -68,11 +83,15 @@ pub fn fit_window(ctx: &egui::Context) -> bool {
|
||||
if ctx.data(|d| d.get_temp::<bool>(fitted_id())).is_some() {
|
||||
return false;
|
||||
}
|
||||
let content = ctx.content_rect();
|
||||
let (monitor, outer, maximized, fullscreen) = ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
(v.monitor_size, v.outer_rect, v.maximized, v.fullscreen)
|
||||
});
|
||||
let (Some(m), Some(outer)) = (monitor, outer) else { return false };
|
||||
// On Wayland the window's position, and so its outer rect, is never known (#3737): its content
|
||||
// size is the least the window takes up.
|
||||
let outer = outer.unwrap_or(content);
|
||||
let Some(m) = monitor else { return false };
|
||||
// macOS keeps new windows inside the screen's visible frame itself.
|
||||
if cfg!(target_os = "macos") {
|
||||
return false;
|
||||
@@ -117,7 +136,6 @@ mod tests {
|
||||
let (content, inner) = window(0.0, 0.0, 1440.0, 900.0);
|
||||
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area");
|
||||
assert_eq!(visible_part(content, None, Some(inner), false), content);
|
||||
assert_eq!(visible_part(content, Some(MONITOR), None, false), content);
|
||||
assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content);
|
||||
assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content);
|
||||
assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content);
|
||||
@@ -126,6 +144,20 @@ mod tests {
|
||||
assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_window_position_still_keeps_clear_of_the_taskbar() {
|
||||
// #3737, Wayland: only the monitor's size is known. A window taller than the monitor less
|
||||
// the taskbar can be seen at most down to there, wherever it is.
|
||||
let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0));
|
||||
assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
|
||||
let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0));
|
||||
assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR)));
|
||||
// One that fits, or a maximized one, is all visible.
|
||||
let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0));
|
||||
assert_eq!(visible_part(small, Some(MONITOR), None, false), small);
|
||||
assert_eq!(visible_part(content, Some(MONITOR), None, true), content);
|
||||
}
|
||||
|
||||
fn frame(ctx: &egui::Context, monitor: Option<Vec2>, outer: Option<Rect>, maximized: Option<bool>) -> Vec<egui::ViewportCommand> {
|
||||
let mut input = egui::RawInput::default();
|
||||
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
|
||||
@@ -158,6 +190,19 @@ mod tests {
|
||||
);
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!frame(&ctx, Some(MONITOR), big, Some(true)).contains(&egui::ViewportCommand::Maximized(true)));
|
||||
// Wayland: no outer rect, but a content area bigger than the monitor is maximized too (#3737).
|
||||
let ctx = egui::Context::default();
|
||||
let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0))), ..Default::default() };
|
||||
if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) {
|
||||
v.monitor_size = Some(MONITOR);
|
||||
v.maximized = Some(false);
|
||||
}
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
fit_window(ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default();
|
||||
assert_eq!(cmds.contains(&egui::ViewportCommand::Maximized(true)), !cfg!(target_os = "macos"));
|
||||
assert!(fits(vec2(1366.0, 720.0), MONITOR) && !fits(vec2(1366.0, 740.0), MONITOR) && !fits(vec2(1440.0, 700.0), MONITOR));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ fn harness() -> Option<(Harness, Writes)> {
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| {
|
||||
reply.send(match request {
|
||||
FileDialogRequest::Save { suggested } => Some(FileDialogAnswer::SaveTo(suggested)),
|
||||
FileDialogRequest::Folder { .. } => None,
|
||||
FileDialogRequest::Open { .. } => None,
|
||||
})
|
||||
})),
|
||||
|
||||
@@ -25,11 +25,14 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
|
||||
| Polski | `pl` | one / few (2–4, not 12–14) / many |
|
||||
| Ελληνικά | `el` | one / other |
|
||||
| Nederlands | `nl` | one / other |
|
||||
| Tiếng Việt | `vi` | one form |
|
||||
|
||||
Every non-English catalog covers the current menu labels, `tl!` literals, blend mode names,
|
||||
and generated preference labels. Tests enforce that coverage. This does not include every
|
||||
engine error or status message: those remain English, as do automation command IDs and
|
||||
user-provided document, layer and preset names.
|
||||
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
|
||||
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
|
||||
selectable, resolves regional tags such as `vi-VN`, and covers all current stable localization
|
||||
keys. `complete_menus` remains false while generated/dynamic long-tail labels are still under
|
||||
review. This does not include every engine error or status message: those remain English, as do
|
||||
automation command IDs and user-provided document, layer and preset names.
|
||||
|
||||
## Switch without restarting
|
||||
|
||||
@@ -41,7 +44,7 @@ documents, undo history and tools remain available throughout a language change.
|
||||
|
||||
The existing preference store saves the selection for future launches. `auto` follows the
|
||||
native system locale; an unsupported code follows the same fallback. Regional tags such as
|
||||
`fr-CA`, `ko-KR` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
|
||||
`fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
|
||||
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
|
||||
Brazilian Portuguese catalog.
|
||||
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
|
||||
| FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report |
|
||||
| FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import |
|
||||
| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist |
|
||||
| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks |
|
||||
| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 20 MCP tasks |
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@ The toolbar's foreground and background colour chips follow the `Tokens::round_c
|
||||
## Rules
|
||||
|
||||
- **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget.
|
||||
- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`.
|
||||
- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`, `elided_label`.
|
||||
- **A label never overflows its row.** Wrap by default. Beside a fixed-width control, or anywhere the row cannot reflow, use `widgets::elided_label(ui, text, max_w)`: a longer translation shrinks with `…` and shows the full string on hover instead of running over its neighbours (#3580). A *redundant* caption may be dropped on a narrow dock where its control already carries the meaning (e.g. the Layers lock icons); an *essential* one elides. This mirrors Photoshop: a fixed layout with elision and tooltips, not per-locale reflow.
|
||||
- **Icons:** Lucide SVGs in `assets/icons` (ISC licence), embedded via `icon_data.rs`. Regenerate that file when you add icons.
|
||||
- **Fonts:** Inter (UI) and JetBrains Mono (numbers), both OFL. Named families `medium` and `semibold` are available via `theme::medium()` and `theme::semibold()`.
|
||||
- **Photoshop layout grammar (Pro):**
|
||||
|
||||
@@ -9,7 +9,7 @@ Icon=ai.storyteller.photocraft
|
||||
Terminal=false
|
||||
# The native runner does not complete desktop startup-notification handshakes.
|
||||
StartupNotify=false
|
||||
StartupWMClass=photocraft
|
||||
StartupWMClass=ai.storyteller.photocraft
|
||||
Categories=Graphics;2DGraphics;RasterGraphics;Photography;
|
||||
Keywords=photo;image;editor;psd;layers;retouch;paint;
|
||||
MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi;
|
||||
@@ -7,5 +7,6 @@
|
||||
<String Id="WelcomeDlgTitle" Value="{\WixUI_Font_Bigger}Welcome to the [ProductName] [PhotocraftVersion] Setup Wizard" />
|
||||
<String Id="WelcomeDlgDescription" Value="The Setup Wizard will install [ProductName] version [PhotocraftVersion] on your computer. Click Next to continue or Cancel to exit the Setup Wizard." />
|
||||
<String Id="VerifyReadyDlgInstallTitle" Value="{\WixUI_Font_Title}Ready to install [ProductName] [PhotocraftVersion]" />
|
||||
<String Id="RequiresWindows10" Value="PhotoCraft needs Windows 10 or later. This version of Windows can't run it." />
|
||||
<String Id="ExitDialogTitle" Value="{\WixUI_Font_Bigger}Completed the [ProductName] [PhotocraftVersion] Setup Wizard" />
|
||||
</WixLocalization>
|
||||
@@ -31,6 +31,18 @@
|
||||
DowngradeErrorMessage="A newer version of PhotoCraft is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
|
||||
|
||||
<!-- Windows 10 or later. The Rust standard library has needed it since 1.78: on Windows 7 and
|
||||
8 the app fails at launch with "The procedure entry point ProcessPrng could not be located
|
||||
in bcryptprimitives.dll" (#2310), so the installer says so instead of installing it.
|
||||
`CurrentMajorVersionNumber` exists only from Windows 10 on, in the 32- and 64-bit
|
||||
registry views alike; MSI's own VersionNT has reported both 603 and 1000 there.
|
||||
`Installed` keeps repair and uninstall working on any Windows. -->
|
||||
<Property Id="WINDOWS10ORLATER" Secure="yes">
|
||||
<RegistrySearch Id="WindowsMajorVersion" Root="HKLM" Key="SOFTWARE\Microsoft\Windows NT\CurrentVersion"
|
||||
Name="CurrentMajorVersionNumber" Type="raw" />
|
||||
</Property>
|
||||
<Launch Condition="Installed OR WINDOWS10ORLATER" Message="!(loc.RequiresWindows10)" />
|
||||
|
||||
<!-- MSI caches this icon using its Id as the filename. Advertised shortcuts to an
|
||||
.exe need an .exe icon identifier (ICE50), even when the source is an ICO. -->
|
||||
<Icon Id="PhotocraftIcon.exe" SourceFile="$(var.IconPath)" />
|
||||
|
||||
@@ -192,4 +192,4 @@ id = "FILE-216-6"
|
||||
target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands"
|
||||
status = "partial"
|
||||
issue = 216
|
||||
note = "automation/tests/agent_tasks.rs: 10 MCP tasks"
|
||||
note = "automation/tests/agent_tasks.rs: 20 MCP tasks"
|
||||
@@ -579,6 +579,7 @@ mod tests {
|
||||
("el", "el.tsv"),
|
||||
("uk", "uk.tsv"),
|
||||
("nl", "nl.tsv"),
|
||||
("vi", "vi.tsv"),
|
||||
] {
|
||||
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
|
||||
let text = fs::read_to_string(&catalog_path).unwrap_or_default();
|
||||
|
||||
Reference in new issue
Block a user