mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Merge main into feat/ui-217-4-columns-unit
This commit is contained in:
commit
96581039a8
309 files changed
+20746
-1121
No files matched your search
+2
-1
@@ -42,11 +42,12 @@ next to them.
|
||||
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/spirit-level.svg` | Straighten tool glyph: a spirit level with its vial and bubble under a dotted arc | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/font-similar.svg`, `assets/icons/font-variable.svg` | Font menu filter glyphs: two waves (Show Similar Fonts) and a T over a slider (Show Variable Fonts), drawn on the 24 px icon grid | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/convert-point.svg` | Convert Point tool glyph: an angle with a square anchor on its apex, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
|
||||
Generated
+1
-1
@@ -5767,7 +5767,7 @@ dependencies = [
|
||||
"photocraft-psd",
|
||||
"photocraft-raster",
|
||||
"rayon",
|
||||
"skrifa 0.44.0",
|
||||
"skrifa 0.47.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -508,7 +508,7 @@ pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
conflict(sub, m, "automation_read_root", "bridge")?;
|
||||
conflict(sub, m, "automation_write_root", "bridge")?;
|
||||
}
|
||||
if a.bridge.is_some() && (a.automation.automation_read_root.is_some() || a.automation.automation_write_root.is_some()) {
|
||||
if a.bridge.is_some() && (!a.automation.automation_read_root.is_empty() || a.automation.automation_write_root.is_some()) {
|
||||
return Err(ServerOptionError::BridgeRoots.into_clap(sub));
|
||||
}
|
||||
if a.bridge.is_some() && (m.value_source("gpu") == Some(ValueSource::CommandLine) || m.value_source("no_gpu") == Some(ValueSource::CommandLine))
|
||||
|
||||
@@ -167,9 +167,9 @@ fn server_modes_reject_ignored_or_conflicting_options() {
|
||||
for flag in ["--control-token", "--control-token-file"] {
|
||||
server_usage_error(&[sub, flag, "unused"], if sub == "mcp" { "requires --bridge" } else { "requires --port" });
|
||||
}
|
||||
server_usage_error(&[sub, "--automation-write-root", ".", "--automation-write-root", "."], "cannot be used multiple times");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
server_usage_error(&[sub, flag, ".", flag, "."], "cannot be used multiple times");
|
||||
server_usage_error(&[sub, &format!("{flag}=")], &format!("a value is required for '{flag} "));
|
||||
server_usage_error(&[sub, &format!("{flag}=")], "root needs a non-empty directory");
|
||||
server_usage_error(&[sub, flag, "--automation-write-root", "."], &format!("a value is required for '{flag} "));
|
||||
}
|
||||
let transport = if sub == "mcp" { "--bridge" } else { "--port" };
|
||||
@@ -193,7 +193,7 @@ fn server_validation_does_not_consume_stdio_or_create_files() {
|
||||
let other = d.join("other.token");
|
||||
let token_flag = format!("--control-token-file={}", token.display());
|
||||
let other_flag = format!("--control-token-file={}", other.display());
|
||||
for tail in [&["stray"][..], &["--automation-read-root", ".", "--automation-read-root", "."], &["--port", "0"], &[&other_flag]] {
|
||||
for tail in [&["stray"][..], &["--automation-write-root", ".", "--automation-write-root", "."], &["--port", "0"], &[&other_flag]] {
|
||||
let mut child = bin()
|
||||
.current_dir(&d)
|
||||
.args(["serve", "--port", "0", &token_flag])
|
||||
@@ -1857,6 +1857,120 @@ fn batch_skips_deselect_with_nothing_selected() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_configured_read_root_must_be_a_valid_directory() {
|
||||
let d = tmp("invalid-read-roots");
|
||||
let good = d.join("good");
|
||||
std::fs::create_dir_all(&good).unwrap();
|
||||
let file = d.join("file");
|
||||
std::fs::write(&file, b"not a directory").unwrap();
|
||||
for sub in ["mcp", "serve"] {
|
||||
for invalid in [d.join("missing"), file.clone()] {
|
||||
for roots in [[&good, &invalid], [&invalid, &good]] {
|
||||
let o = bin()
|
||||
.arg(sub)
|
||||
.arg("--automation-read-root")
|
||||
.arg(roots[0])
|
||||
.arg(format!("--automation-read-root={}", roots[1].display()))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(o.status.code(), Some(1), "{sub}: {}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("automation read root"));
|
||||
assert!(o.stdout.is_empty(), "invalid roots must fail before the server starts");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_reads_roots_in_order_and_supports_absolute_paths_in_each_root() {
|
||||
let d = tmp("serve-read-roots");
|
||||
let first = d.join("first");
|
||||
let second = d.join("second");
|
||||
std::fs::create_dir_all(&first).unwrap();
|
||||
std::fs::create_dir_all(&second).unwrap();
|
||||
write_png(&first.join("shared.png"), 3, 2, 3);
|
||||
write_png(&second.join("shared.png"), 7, 4, 5);
|
||||
write_png(&second.join("fallback.png"), 6, 5, 7);
|
||||
let mut child = bin()
|
||||
.args(["serve", "--automation-read-root"])
|
||||
.arg(&first)
|
||||
.arg(format!("--automation-read-root={}", second.display()))
|
||||
.arg("--automation-read-root")
|
||||
.arg(&first)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
for (id, path) in [(1, "shared.png".into()), (2, "fallback.png".into()), (3, second.join("shared.png").to_string_lossy().into_owned())] {
|
||||
writeln!(stdin, "{}", json!({"id": id, "method": "doc.open", "params": {"path": path}})).unwrap();
|
||||
}
|
||||
drop(stdin);
|
||||
let output = child.wait_with_output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let replies: Vec<Value> = String::from_utf8(output.stdout).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 3);
|
||||
for (reply, (width, height)) in replies.iter().zip([(3, 2), (6, 5), (7, 4)]) {
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
assert_eq!(reply["result"]["width"], width, "{reply}");
|
||||
assert_eq!(reply["result"]["height"], height, "{reply}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_reads_roots_in_order_with_fallback() {
|
||||
let d = tmp("mcp-read-roots");
|
||||
let first = d.join("first");
|
||||
let second = d.join("second");
|
||||
std::fs::create_dir_all(&first).unwrap();
|
||||
std::fs::create_dir_all(&second).unwrap();
|
||||
write_png(&first.join("shared.png"), 3, 2, 3);
|
||||
write_png(&second.join("shared.png"), 7, 4, 5);
|
||||
write_png(&second.join("fallback.png"), 6, 5, 7);
|
||||
let mut child = bin()
|
||||
.arg("mcp")
|
||||
.arg(format!("--automation-read-root={}", first.display()))
|
||||
.arg("--automation-read-root")
|
||||
.arg(&second)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut send = |value: Value| {
|
||||
writeln!(stdin, "{value}").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
};
|
||||
let mut recv = |id: u64| -> Value {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(stdout.read_line(&mut line).unwrap() > 0, "server closed");
|
||||
let value: Value = serde_json::from_str(&line).unwrap();
|
||||
if value["id"] == id {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
};
|
||||
send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}));
|
||||
assert_eq!(recv(1)["result"]["serverInfo"]["name"], "photocraft");
|
||||
send(json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
for (id, path, width) in [(2, "shared.png", 3), (3, "fallback.png", 6)] {
|
||||
send(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"doc_open","arguments":{"path":path}}}));
|
||||
let reply = recv(id);
|
||||
assert_ne!(reply["result"]["isError"], true, "{reply}");
|
||||
let opened: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(opened["width"], width, "{reply}");
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
/// TIFF Options from the command line (#8827): `--tiff-compression`, `--tiff-transparency on|off`
|
||||
/// and `--tiff-pyramid`; a bad value is a usage error that writes nothing.
|
||||
#[test]
|
||||
|
||||
@@ -286,7 +286,7 @@ mod tests {
|
||||
assert_eq!(parsed.control, Some(50494));
|
||||
assert_eq!(parsed.auth.control_token.as_deref(), Some("abcd"));
|
||||
assert_eq!(parsed.auth.control_token_file, None);
|
||||
assert_eq!(parsed.automation.automation_read_root.as_deref(), Some(std::path::Path::new("/r")));
|
||||
assert_eq!(parsed.automation.automation_read_root, vec![std::path::PathBuf::from("/r")]);
|
||||
assert_eq!(parsed.automation.automation_write_root.as_deref(), Some(std::path::Path::new("/w")));
|
||||
assert!(parsed.safe_gpu && parsed.in_window_menus && parsed.new_instance);
|
||||
assert_eq!(parsed.present_mode, Some(PresentMode::AutoNoVsync));
|
||||
|
||||
@@ -703,9 +703,9 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
|
||||
let (bytes, save_path) = workspace.read_with_save_path(path).map_err(|error| error.to_string())?;
|
||||
let name = Path::new(path).file_name().and_then(|name| name.to_str()).unwrap_or(path).to_string();
|
||||
Ok((name, bytes))
|
||||
Ok(photocraft_ui_egui::AutomationReadResult { name, bytes, save_path })
|
||||
}) as photocraft_ui_egui::AutomationReadFn
|
||||
});
|
||||
let automation_write = automation.clone().map(|workspace| {
|
||||
|
||||
@@ -120,3 +120,49 @@ fn automation_roots_come_from_the_environment_too() {
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "{var}: stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_read_root_is_not_hidden_by_another_valid_root() {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-control-roots-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let good = dir.to_str().unwrap();
|
||||
let missing = dir.join("missing");
|
||||
let file = dir.join("not-a-directory");
|
||||
std::fs::write(&file, b"not a directory").unwrap();
|
||||
for invalid in [&missing, &file] {
|
||||
for roots in [[good, invalid.to_str().unwrap()], [invalid.to_str().unwrap(), good]] {
|
||||
let Run { status, stderr } = run_photocraft(&[
|
||||
"--control",
|
||||
"0",
|
||||
"--control-token",
|
||||
VALID_TOKEN,
|
||||
"--automation-read-root",
|
||||
roots[0],
|
||||
&format!("--automation-read-root={}", roots[1]),
|
||||
]);
|
||||
assert_eq!(status.code(), Some(2), "every configured read root must be valid: {stderr}");
|
||||
assert!(stderr.contains("cannot configure automation workspace"), "stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_missing_and_repeated_write_root_flags_are_usage_errors() {
|
||||
for flags in [
|
||||
vec!["--automation-read-root"],
|
||||
vec!["--automation-read-root", ""],
|
||||
vec!["--automation-read-root="],
|
||||
vec!["--automation-read-root", "--safe-gpu"],
|
||||
vec!["--automation-write-root"],
|
||||
vec!["--automation-write-root="],
|
||||
vec!["--automation-write-root", "work", "--automation-write-root=work"],
|
||||
vec!["--automation-write-root=", "--automation-write-root=work"],
|
||||
] {
|
||||
let args: Vec<&str> = ["--control", "0", "--control-token", VALID_TOKEN].into_iter().chain(flags).collect();
|
||||
let Run { status, stderr } = run_photocraft(&args);
|
||||
assert_eq!(status.code(), Some(2), "{args:?}: {stderr}");
|
||||
assert!(stderr.contains("--automation-"), "stderr: {stderr}");
|
||||
assert!(!stderr.contains("cannot configure automation workspace"), "parse errors must fail before opening roots: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="10" width="18" height="7" rx="2" />
|
||||
<path d="M8 12v3M16 12v3" />
|
||||
<circle cx="12" cy="13.5" r="1.1" />
|
||||
<path d="M8.5 8.4q3.5-2.6 7 0" stroke-dasharray="0.6 1.6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -316,9 +316,16 @@ sync_github_name = false
|
||||
real_name = "David Vanhoucke"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Luis-0508]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Kaedo17]
|
||||
sync_github_name = true
|
||||
|
||||
[people.rmsff]
|
||||
real_name = "Serikov Roman"
|
||||
sync_github_name = false
|
||||
|
||||
[people.tomgardenart]
|
||||
display_name = "Nostella"
|
||||
sync_github_name = false
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use clap::builder::{NonEmptyStringValueParser, OsStringValueParser, TypedValueParser};
|
||||
use clap::error::{ContextKind, ContextValue, ErrorKind};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{Arg, ArgMatches, Args, Command};
|
||||
@@ -34,18 +34,22 @@ pub struct ControlArgs {
|
||||
/// `photocraft-cli` access (#1551); the desktop app adds its own.
|
||||
#[derive(Args)]
|
||||
pub struct AutomationArgs {
|
||||
/// The folder automation may read from (none without it)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub automation_read_root: Option<PathBuf>,
|
||||
/// Ordered folders automation may read from; repeat for more roots (none without it)
|
||||
#[arg(long, value_name = "DIR", value_parser = root_path())]
|
||||
pub automation_read_root: Vec<PathBuf>,
|
||||
/// The folder automation may write to (none without it)
|
||||
#[arg(long, value_name = "DIR")]
|
||||
#[arg(long, value_name = "DIR", value_parser = root_path())]
|
||||
pub automation_write_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn root_path() -> impl TypedValueParser<Value = PathBuf> {
|
||||
OsStringValueParser::new().try_map(|value: OsString| if value.is_empty() { Err("root needs a non-empty directory") } else { Ok(PathBuf::from(value)) })
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
/// The roots, opened as capabilities.
|
||||
pub fn workspace(&self) -> Result<AuthorizedWorkspace, AutomationError> {
|
||||
AuthorizedWorkspace::new(self.automation_read_root.as_deref(), self.automation_write_root.as_deref())
|
||||
AuthorizedWorkspace::with_read_roots(self.automation_read_root.iter().map(PathBuf::as_path), self.automation_write_root.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,4 +124,40 @@ mod tests {
|
||||
assert_eq!(control_port(bad), Err(NotAPort), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
struct RootCli {
|
||||
#[command(flatten)]
|
||||
automation: AutomationArgs,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_read_roots_preserve_order_and_write_roots_stay_single() {
|
||||
use clap::Parser;
|
||||
let parsed = RootCli::try_parse_from(["test", "--automation-read-root", "first", "--automation-read-root=second"]).unwrap();
|
||||
assert_eq!(parsed.automation.automation_read_root, vec![PathBuf::from("first"), PathBuf::from("second")]);
|
||||
for args in [
|
||||
vec!["test", "--automation-write-root", "one", "--automation-write-root=two"],
|
||||
vec!["test", "--automation-write-root=one", "--automation-write-root", "one"],
|
||||
vec!["test", "--automation-read-root="],
|
||||
vec!["test", "--automation-read-root", "--automation-write-root=work"],
|
||||
vec!["test", "--automation-write-root="],
|
||||
] {
|
||||
assert!(RootCli::try_parse_from(args.clone()).is_err(), "accepted {args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_root_paths_are_preserved_without_lossy_conversion() {
|
||||
use clap::Parser;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let value = OsString::from_vec(b"library-\xff".to_vec());
|
||||
let parsed = RootCli::try_parse_from([OsString::from("test"), OsString::from("--automation-read-root"), value.clone()]).unwrap();
|
||||
assert_eq!(parsed.automation.automation_read_root, vec![PathBuf::from(value.clone())]);
|
||||
let mut inline = OsString::from("--automation-read-root=");
|
||||
inline.push(&value);
|
||||
let parsed = RootCli::try_parse_from([OsString::from("test"), inline]).unwrap();
|
||||
assert_eq!(parsed.automation.automation_read_root, vec![PathBuf::from(value)]);
|
||||
}
|
||||
}
|
||||
@@ -181,13 +181,13 @@ impl Headless {
|
||||
/// Open a file with custom import options and make it the active document.
|
||||
pub fn open_with_options(&mut self, path: &Path, opts: &photocraft_io::ImportOptions) -> Result<Value, AutomationError> {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
let mut o = match &self.filesystem {
|
||||
let (mut o, save_path) = match &self.filesystem {
|
||||
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
|
||||
Filesystem::TrustedLocal => files::open_with_options(path, opts)?,
|
||||
Filesystem::TrustedLocal => (files::open_with_options(path, opts)?, Some(requested.to_string())),
|
||||
Filesystem::Workspace(workspace) => {
|
||||
let bytes = workspace.read(requested)?;
|
||||
let (bytes, save_path) = workspace.read_with_save_path(requested)?;
|
||||
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or(requested);
|
||||
files::open_bytes_with_options(name, &bytes, opts)?
|
||||
(files::open_bytes_with_options(name, &bytes, opts)?, save_path)
|
||||
}
|
||||
};
|
||||
// Preferences › Raw Defaults › Resolution, as the app's File › Open applies it.
|
||||
@@ -197,7 +197,7 @@ impl Headless {
|
||||
o.document.name = untitled;
|
||||
None
|
||||
}
|
||||
None => Some(requested.to_string()),
|
||||
None => save_path,
|
||||
};
|
||||
let index = self.session.add_document(o.document, path.filter(|_| !o.source_read_only));
|
||||
if let Some(st) = self.session.active_mut() {
|
||||
|
||||
@@ -413,7 +413,7 @@ fn doc_place_reads_through_the_read_root_and_never_outside_it() {
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
assert_eq!(inspected["layers"].as_array().map(Vec::len), Some(2), "the placed layer is in the document: {inspected}");
|
||||
// A path outside the read root is refused the way the ambient command is (#2810).
|
||||
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside the automation root")] {
|
||||
for (path, reason) in [("../outside/logo.png", "parent traversal"), (absolute.as_str(), "must be inside an automation read root")] {
|
||||
let refused = h.handle("doc.place", json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(refused.contains(reason), "{refused}");
|
||||
let inspected = h.handle("doc.inspect", json!({})).unwrap();
|
||||
@@ -513,7 +513,7 @@ fn doc_replace_smart_object_reads_through_the_read_root_and_never_outside_it() {
|
||||
// Outside the read root, a bad layer, a missing path or a file that is no image: refused, nothing changed.
|
||||
for (params, reason) in [
|
||||
(json!({"path": "../outside/red.png"}), "parent traversal"),
|
||||
(json!({"path": absolute}), "must be inside the automation root"),
|
||||
(json!({"path": absolute}), "must be inside an automation read root"),
|
||||
(json!({"path": "logo.png", "layer": 999_999}), "no such layer"),
|
||||
(json!({}), "needs `path`"),
|
||||
] {
|
||||
@@ -660,7 +660,7 @@ fn doc_export_data_sets_refuse_collisions_and_import_reads_the_root() {
|
||||
assert_eq!(imported["imported"].as_u64(), Some(2), "{imported}");
|
||||
std::fs::write(base.join("outside.csv"), "Data Set,show\none,true\n").unwrap();
|
||||
let outside = h.handle("doc.importDataSets", json!({"path": base.join("outside.csv").to_string_lossy()})).unwrap_err().to_string();
|
||||
assert!(outside.contains("must be inside the automation root"), "{outside}");
|
||||
assert!(outside.contains("must be inside an automation read root"), "{outside}");
|
||||
// Release directory capabilities before cleanup on Windows.
|
||||
drop(h);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
|
||||
@@ -50,7 +50,8 @@ pub struct DocIndex {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OpenParams {
|
||||
/// Path beneath the configured automation read root: relative (forward slashes) or absolute.
|
||||
/// Path beneath the configured automation read roots: relative (forward slashes, searched
|
||||
/// in launch order) or absolute. Only a missing file tries the next root.
|
||||
pub path: String,
|
||||
/// Explicitly approve high-memory PSD/PSB imports that fit within the hard memory budget.
|
||||
#[serde(default)]
|
||||
@@ -179,6 +180,7 @@ pub struct SaveParams {
|
||||
/// The extension selects the format. Omit to write back to the document's own file, which
|
||||
/// works only for a PSD, PSB or .pcraft file kept in its own format; any other save needs
|
||||
/// `path`, so a flattened or converted copy never replaces the opened file.
|
||||
/// A document opened from a different read capability needs an explicit path first.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
|
||||
|
||||
@@ -33,7 +33,7 @@ struct RootCapability {
|
||||
/// before I/O.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct AuthorizedWorkspace {
|
||||
read: Option<RootCapability>,
|
||||
read: Vec<RootCapability>,
|
||||
write: Option<RootCapability>,
|
||||
}
|
||||
|
||||
@@ -41,34 +41,90 @@ impl AuthorizedWorkspace {
|
||||
/// Open the configured roots as capabilities. Either authority may be
|
||||
/// omitted; omitted authority fails closed.
|
||||
pub fn new(read_root: Option<&Path>, write_root: Option<&Path>) -> Result<Self, AutomationError> {
|
||||
Ok(Self { read: open_root(read_root, "read")?, write: open_root(write_root, "write")? })
|
||||
Self::with_read_roots(read_root, write_root)
|
||||
}
|
||||
|
||||
/// Read one regular file below the configured read root.
|
||||
/// Open ordered read capabilities and an independent, optional write capability.
|
||||
/// Every configured root must be valid; a write root does not also grant reads.
|
||||
pub fn with_read_roots<'a>(read_roots: impl IntoIterator<Item = &'a Path>, write_root: Option<&Path>) -> Result<Self, AutomationError> {
|
||||
let mut read = Vec::new();
|
||||
for path in read_roots {
|
||||
let root = open_root(path, "read", &read)?;
|
||||
read.push(root);
|
||||
}
|
||||
let write = write_root.map(|path| open_root(path, "write", &read)).transpose()?;
|
||||
Ok(Self { read, write })
|
||||
}
|
||||
|
||||
/// Read the first matching regular file, trying roots in their configured order.
|
||||
/// Only an open that reports NotFound tries the next root. Once opened, errors
|
||||
/// (including a non-regular file or a failed bounded read) never select another file.
|
||||
pub fn read(&self, path: &str) -> Result<Vec<u8>, AutomationError> {
|
||||
let relative = request_path(path, self.read.as_ref())?;
|
||||
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
|
||||
let mut file = root.dir.open(&relative).map_err(|e| file_error("read", path, e))?;
|
||||
self.read_with_save_path(path).map(|(bytes, _)| bytes)
|
||||
}
|
||||
|
||||
/// Read bytes and derive a conservative implicit-save request from the same
|
||||
/// selected capability. A different read root must never redirect an implicit
|
||||
/// save to a same-named file in the write root. Aliases and nested roots need an
|
||||
/// explicit Save As; this is not canonical desktop document identity.
|
||||
pub fn read_with_save_path(&self, path: &str) -> Result<(Vec<u8>, Option<String>), AutomationError> {
|
||||
let (mut file, root) = self.open_read(path)?;
|
||||
let metadata = file.metadata().map_err(|e| file_error("read", path, e))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
|
||||
}
|
||||
read_to_observed_length(&mut file, metadata.len())
|
||||
// Retain main's bounded, growth-checked reads while keeping save provenance.
|
||||
let bytes = read_to_observed_length(&mut file, metadata.len())?;
|
||||
let save_path = self.write.as_ref().filter(|write| Arc::ptr_eq(&root.dir, &write.dir)).map(|_| path.to_owned());
|
||||
Ok((bytes, save_path))
|
||||
}
|
||||
|
||||
fn open_read(&self, path: &str) -> Result<(cap_std::fs::File, &RootCapability), AutomationError> {
|
||||
if self.read.is_empty() {
|
||||
return Err(AutomationError::BadRequest(format!("{DENIED}: read authority is absent")));
|
||||
}
|
||||
let mut not_found = None;
|
||||
for root in &self.read {
|
||||
let Some(relative) = scoped_path(path, root)? else { continue };
|
||||
match root.dir.open(&relative) {
|
||||
Ok(file) => return Ok((file, root)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => not_found = Some(error),
|
||||
Err(error) => return Err(file_error("read", path, error)),
|
||||
}
|
||||
}
|
||||
match not_found {
|
||||
Some(error) => Err(file_error("read", path, error)),
|
||||
None => Err(path_error(path, "absolute paths must be inside an automation read root")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load one `.pcraft` document in directory form — the native on-disk layout — below the
|
||||
/// read root. `read` serves regular files only (the zip form), so a regular file at the
|
||||
/// first matching read root. Directory opens never establish an implicit save target.
|
||||
/// `read` serves regular files only (the zip form), so a regular file at the
|
||||
/// path is reported as `Ok(None)` for the caller to fall back to.
|
||||
pub fn open_pcraft(&self, path: &str) -> Result<Option<photocraft_doc::Document>, AutomationError> {
|
||||
let relative = request_path(path, self.read.as_ref())?;
|
||||
let root = self.read.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: read authority is absent")))?;
|
||||
let dir = match root.dir.open_dir(&relative) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotADirectory => return Ok(None),
|
||||
Err(e) => return Err(file_error("read", path, e)),
|
||||
};
|
||||
let source = DirHandle { dir };
|
||||
Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?))
|
||||
if self.read.is_empty() {
|
||||
return Err(AutomationError::BadRequest(format!("{DENIED}: read authority is absent")));
|
||||
}
|
||||
let mut not_found = None;
|
||||
for root in &self.read {
|
||||
let Some(relative) = scoped_path(path, root)? else { continue };
|
||||
let dir = match root.dir.open_dir(&relative) {
|
||||
Ok(dir) => dir,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
not_found = Some(error);
|
||||
continue;
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotADirectory => return Ok(None),
|
||||
Err(error) => return Err(file_error("read", path, error)),
|
||||
};
|
||||
let source = DirHandle { dir };
|
||||
return Ok(Some(photocraft_format::load_bytes_source(&source, &photocraft_format::LoadOptions::default())?));
|
||||
}
|
||||
match not_found {
|
||||
Some(error) => Err(file_error("read", path, error)),
|
||||
None => Err(path_error(path, "absolute paths must be inside an automation read root")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
@@ -216,8 +272,7 @@ fn read_to_observed_length(reader: &mut impl Read, observed_length: u64) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapability>, AutomationError> {
|
||||
let Some(path) = path else { return Ok(None) };
|
||||
fn open_root(path: &Path, authority: &str, opened: &[RootCapability]) -> Result<RootCapability, AutomationError> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(AutomationError::BadRequest(format!("automation {authority} root is empty")));
|
||||
}
|
||||
@@ -228,27 +283,42 @@ fn open_root(path: Option<&Path>, authority: &str) -> Result<Option<RootCapabili
|
||||
} else {
|
||||
std::path::absolute(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?
|
||||
};
|
||||
// Reuse the held handle for the same launch spelling, not a second open of it: a root
|
||||
// symlink could otherwise change between the two opens. Only a shared handle lets an
|
||||
// implicit save write where the bytes were read (`read_with_save_path`); aliases do not.
|
||||
if let Some(root) = opened.iter().find(|root| root.configured_path == configured_path) {
|
||||
return Ok(root.clone());
|
||||
}
|
||||
// Resolve trusted configuration once, before opening that exact directory as a capability.
|
||||
let absolute =
|
||||
std::fs::canonicalize(path).map_err(|e| AutomationError::Io(format!("cannot resolve automation {authority} root `{}`: {e}", path.display())))?;
|
||||
let dir = Dir::open_ambient_dir(&absolute, ambient_authority())
|
||||
.map_err(|e| AutomationError::Io(format!("cannot open automation {authority} root `{}`: {e}", path.display())))?;
|
||||
Ok(Some(RootCapability { dir: Arc::new(dir), path: absolute, configured_path }))
|
||||
Ok(RootCapability { dir: Arc::new(dir), path: absolute, configured_path })
|
||||
}
|
||||
|
||||
/// Both root spellings refer to the capability opened at startup. Do not resolve an untrusted
|
||||
/// absolute request using ambient filesystem access: strip a known root first and let cap-std
|
||||
/// resolve the remaining name. New targets and nested export directories need not exist yet.
|
||||
fn request_path(raw: &str, root: Option<&RootCapability>) -> Result<PathBuf, AutomationError> {
|
||||
let Some(root) = root else { return relative_path(raw) };
|
||||
scoped_path(raw, root)?.ok_or_else(|| path_error(raw, "absolute paths must be inside the automation root"))
|
||||
}
|
||||
|
||||
/// An absolute prefix mismatch is a noncandidate, whereas a malformed path
|
||||
/// beneath a matching root is an error. Never turn either into a relative lookup.
|
||||
fn scoped_path(raw: &str, root: &RootCapability) -> Result<Option<PathBuf>, AutomationError> {
|
||||
let path = Path::new(raw);
|
||||
let Some(root) = root.filter(|_| path.is_absolute()) else { return relative_path(raw) };
|
||||
if !path.is_absolute() {
|
||||
return relative_path(raw).map(Some);
|
||||
}
|
||||
// Prefer the trusted launch spelling: it can itself contain `..`. Only that
|
||||
// exact granted prefix is exempt; traversal in the remaining request still fails below.
|
||||
let request = path_beneath_root(path, &root.configured_path)
|
||||
.or_else(|| path_beneath_root(path, &root.path))
|
||||
.ok_or_else(|| path_error(raw, "absolute paths must be inside the automation root"))?;
|
||||
let Some(request) = path_beneath_root(path, &root.configured_path).or_else(|| path_beneath_root(path, &root.path)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rest = request.map(|c| c.as_os_str().to_str().ok_or_else(|| path_error(raw, "path is not UTF-8"))).collect::<Result<Vec<_>, _>>()?;
|
||||
relative_path(&rest.join("/"))
|
||||
relative_path(&rest.join("/")).map(Some)
|
||||
}
|
||||
|
||||
/// Strip a granted root prefix without resolving the client path with ambient authority.
|
||||
|
||||
@@ -749,6 +749,7 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
|
||||
| "path.stroke"
|
||||
| "path.toShape"
|
||||
| "type.attachToPath"
|
||||
| "type.setPathPlacement"
|
||||
| "type.fillPath"
|
||||
| "type.detachFromPath"
|
||||
| "select.toWorkPath"
|
||||
@@ -1075,6 +1076,149 @@ fn absolute_paths_are_checked_against_the_authority_s_own_root() {
|
||||
assert!(none.read(&target.to_string_lossy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_read_roots_keep_write_authority_and_save_provenance_separate() {
|
||||
let (library, work, _) = roots("multiple-roots");
|
||||
std::fs::write(library.join("photo.txt"), b"library").unwrap();
|
||||
std::fs::write(work.join("draft.txt"), b"draft").unwrap();
|
||||
std::fs::write(library.join("same.txt"), b"first").unwrap();
|
||||
std::fs::write(work.join("same.txt"), b"second").unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path(), library.as_path()], Some(&work)).unwrap();
|
||||
assert_eq!(workspace.read_with_save_path("photo.txt").unwrap(), (b"library".to_vec(), None));
|
||||
assert_eq!(workspace.read_with_save_path("draft.txt").unwrap(), (b"draft".to_vec(), Some("draft.txt".into())));
|
||||
assert_eq!(workspace.read("same.txt").unwrap(), b"first");
|
||||
let absolute = work.join("same.txt").to_string_lossy().into_owned();
|
||||
assert_eq!(workspace.read_with_save_path(&absolute).unwrap(), (b"second".to_vec(), Some(absolute)));
|
||||
let reverse = AuthorizedWorkspace::with_read_roots([work.as_path(), library.as_path()], Some(&work)).unwrap();
|
||||
assert_eq!(reverse.read("same.txt").unwrap(), b"second");
|
||||
// Identical configured spellings share the held handle, including the write grant.
|
||||
assert!(Arc::ptr_eq(&workspace.read[0].dir, &workspace.read[2].dir));
|
||||
assert!(Arc::ptr_eq(&workspace.read[1].dir, &workspace.write.as_ref().unwrap().dir));
|
||||
workspace.write("result.txt", b"output").unwrap();
|
||||
assert_eq!(std::fs::read(work.join("result.txt")).unwrap(), b"output");
|
||||
assert!(!library.join("result.txt").exists());
|
||||
assert!(workspace.write(&library.join("photo.txt").to_string_lossy(), b"blocked").is_err());
|
||||
assert_eq!(std::fs::read(library.join("photo.txt")).unwrap(), b"library");
|
||||
let error = workspace.read("missing.txt").unwrap_err().to_string();
|
||||
assert!(error.contains("NotFound"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_save_follows_read_provenance_across_ordered_roots() {
|
||||
let (library, work, _) = roots("stream-provenance");
|
||||
std::fs::write(library.join("same.png"), b"library").unwrap();
|
||||
std::fs::write(work.join("same.png"), b"work").unwrap();
|
||||
std::fs::write(work.join("draft.png"), b"draft").unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], Some(&work)).unwrap();
|
||||
// Read from the library root: no implicit save, so a streamed save needs an explicit target.
|
||||
assert_eq!(workspace.read_with_save_path("same.png").unwrap(), (b"library".to_vec(), None));
|
||||
// Read from the root that shares the write handle: the streamed save replaces that file only.
|
||||
let (_, save_path) = workspace.read_with_save_path("draft.png").unwrap();
|
||||
let save_path = save_path.unwrap();
|
||||
workspace.write_stream(&save_path, &mut |w| w.write_all(b"streamed")).unwrap();
|
||||
assert_eq!(std::fs::read(work.join("draft.png")).unwrap(), b"streamed");
|
||||
assert!(!library.join("draft.png").exists());
|
||||
// An absolute library path never reaches the write root, streamed or not.
|
||||
assert!(workspace.write_stream(&library.join("same.png").to_string_lossy(), &mut |w| w.write_all(b"blocked")).is_err());
|
||||
assert_eq!(std::fs::read(library.join("same.png")).unwrap(), b"library");
|
||||
assert_eq!(std::fs::read(work.join("same.png")).unwrap(), b"work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_read_root_is_validated_and_write_roots_never_imply_reads() {
|
||||
let (library, work, _) = roots("root-validation");
|
||||
std::fs::write(work.join("file.txt"), b"work").unwrap();
|
||||
for invalid in [library.join("missing"), work.join("file.txt"), PathBuf::new()] {
|
||||
assert!(AuthorizedWorkspace::with_read_roots([library.as_path(), invalid.as_path(), work.as_path()], Some(&work)).is_err());
|
||||
}
|
||||
let write_only = AuthorizedWorkspace::with_read_roots(std::iter::empty(), Some(&work)).unwrap();
|
||||
assert!(write_only.read("file.txt").is_err());
|
||||
assert!(write_only.read(&work.join("file.txt").to_string_lossy()).is_err());
|
||||
let read_only = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], None).unwrap();
|
||||
assert_eq!(read_only.read_with_save_path("file.txt").unwrap(), (b"work".to_vec(), None));
|
||||
assert!(read_only.write("file.txt", b"blocked").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_earlier_directory_or_invalid_path_never_falls_back_to_a_later_file() {
|
||||
let (library, work, _) = roots("root-errors");
|
||||
std::fs::create_dir(library.join("same.txt")).unwrap();
|
||||
std::fs::write(work.join("same.txt"), b"later").unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], Some(&work)).unwrap();
|
||||
assert!(workspace.read("same.txt").is_err());
|
||||
for invalid in ["", "../same.txt", "a/../same.txt", "a\\same.txt", "a//same.txt", "NUL", "same.txt:stream"] {
|
||||
assert!(workspace.read(invalid).is_err(), "accepted {invalid:?}");
|
||||
assert!(workspace.write(invalid, b"blocked").is_err(), "wrote {invalid:?}");
|
||||
}
|
||||
let traversal = library.join("../outside/same.txt");
|
||||
assert!(workspace.read(&traversal.to_string_lossy()).is_err());
|
||||
assert_eq!(std::fs::read(work.join("same.txt")).unwrap(), b"later");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn multiple_roots_do_not_allow_link_escapes_or_alias_save_back() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let (library, work, _) = roots("multi-root-links");
|
||||
std::fs::write(work.join("photo.txt"), b"work").unwrap();
|
||||
symlink(&work, library.join("escape")).unwrap();
|
||||
std::fs::create_dir(work.join("escape")).unwrap();
|
||||
std::fs::write(work.join("escape/photo.txt"), b"later").unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], Some(&work)).unwrap();
|
||||
// Being separately granted does not allow following a link out of the selected root.
|
||||
assert!(workspace.read("escape/photo.txt").is_err());
|
||||
assert_eq!(workspace.read("photo.txt").unwrap(), b"work");
|
||||
let alias = library.join("alias");
|
||||
symlink(&work, &alias).unwrap();
|
||||
let aliased = AuthorizedWorkspace::new(Some(&alias), Some(&work)).unwrap();
|
||||
assert_eq!(aliased.read_with_save_path("photo.txt").unwrap(), (b"work".to_vec(), None));
|
||||
let nested = AuthorizedWorkspace::new(Some(&work.join("escape")), Some(&work)).unwrap();
|
||||
assert_eq!(nested.read_with_save_path("photo.txt").unwrap(), (b"later".to_vec(), None));
|
||||
symlink(&library, work.join("escape-write")).unwrap();
|
||||
assert!(workspace.write("escape-write/output.txt", b"blocked").is_err());
|
||||
assert!(!library.join("output.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn same_root_leaf_link_save_replaces_the_link_without_writing_its_target() {
|
||||
let (inside, _, workspace) = roots("leaf-link");
|
||||
std::fs::write(inside.join("target.txt"), b"target").unwrap();
|
||||
std::os::unix::fs::symlink("target.txt", inside.join("link.png")).unwrap();
|
||||
let (bytes, save_path) = workspace.read_with_save_path("link.png").unwrap();
|
||||
assert_eq!(bytes, b"target");
|
||||
workspace.write(&save_path.unwrap(), b"replacement").unwrap();
|
||||
assert_eq!(std::fs::read(inside.join("target.txt")).unwrap(), b"target");
|
||||
assert_eq!(std::fs::read(inside.join("link.png")).unwrap(), b"replacement");
|
||||
assert!(!std::fs::symlink_metadata(inside.join("link.png")).unwrap().file_type().is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_pcraft_uses_ordered_roots_without_falling_back_after_selection() {
|
||||
let (library, work, _) = roots("pcraft-ordered");
|
||||
let save = |root: &Path, name: &str| {
|
||||
let doc = photocraft_doc::Document::new(name, photocraft_doc::Size::new(4, 4), photocraft_doc::ColorMode::Rgb, photocraft_doc::SampleType::U8);
|
||||
photocraft_format::PcraftWriter::new().save_dir(&doc, &root.join("native.pcraft"), &photocraft_format::SaveOptions::default()).unwrap();
|
||||
};
|
||||
save(&work, "Later");
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], Some(&work)).unwrap();
|
||||
assert_eq!(workspace.open_pcraft("native.pcraft").unwrap().unwrap().name, "Later");
|
||||
save(&library, "First");
|
||||
assert_eq!(workspace.open_pcraft("native.pcraft").unwrap().unwrap().name, "First");
|
||||
assert_eq!(workspace.open_pcraft(&work.join("native.pcraft").to_string_lossy()).unwrap().unwrap().name, "Later");
|
||||
std::fs::remove_dir_all(library.join("native.pcraft")).unwrap();
|
||||
std::fs::write(library.join("native.pcraft"), b"zip placeholder").unwrap();
|
||||
assert!(workspace.open_pcraft("native.pcraft").unwrap().is_none());
|
||||
std::fs::remove_file(library.join("native.pcraft")).unwrap();
|
||||
std::fs::create_dir(library.join("native.pcraft")).unwrap();
|
||||
assert!(workspace.open_pcraft("native.pcraft").is_err());
|
||||
for invalid in ["../native.pcraft", "a//native.pcraft", "a\\native.pcraft"] {
|
||||
assert!(workspace.open_pcraft(invalid).is_err());
|
||||
}
|
||||
let write_only = AuthorizedWorkspace::with_read_roots(std::iter::empty(), Some(&work)).unwrap();
|
||||
assert!(write_only.open_pcraft("native.pcraft").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csh_import_requires_inline_data_in_automation() {
|
||||
let id = "shape.presets.importCsh";
|
||||
|
||||
@@ -15,5 +15,6 @@ mod native_full_composite;
|
||||
mod native_save_name;
|
||||
mod openraster;
|
||||
mod raw_defaults;
|
||||
mod read_roots;
|
||||
mod renderer_save;
|
||||
mod smart_filter_authorization;
|
||||
@@ -749,6 +749,49 @@ async fn save_without_path_never_flattens_over_the_opened_file() {
|
||||
cleanup(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ordered_read_roots_preserve_save_identity_over_mcp() {
|
||||
let base = tmp("ordered-read-roots");
|
||||
let library = base.join("library");
|
||||
let work = base.join("work");
|
||||
for (root, width, background) in [(&library, 11, "white"), (&work, 7, "black")] {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
let mut fixture = photocraft_automation::Headless::new();
|
||||
fixture.command_run("file.new", json!({"width": width, "height": 5, "background": background})).unwrap();
|
||||
fixture.command_run("layer.new.layer", json!({"name": "Source marker"})).unwrap();
|
||||
let bytes = photocraft_automation::files::save_bytes(&fixture.session.active().unwrap().doc, "same.pcraft", None, &Default::default()).unwrap().0;
|
||||
std::fs::write(root.join("same.pcraft"), bytes).unwrap();
|
||||
}
|
||||
let original_library = std::fs::read(library.join("same.pcraft")).unwrap();
|
||||
let original_work = std::fs::read(work.join("same.pcraft")).unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([library.as_path(), work.as_path()], Some(work.as_path())).unwrap();
|
||||
let client = connect(PhotocraftMcp::headless_with_workspace(workspace)).await;
|
||||
|
||||
assert_eq!(json_of(&call(&client, "doc_open", json!({"path": "same.pcraft"})).await)["width"], 11);
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Library edit"}})).await);
|
||||
let before = json_of(&call(&client, "session_list", json!({})).await);
|
||||
assert!(before["documents"][0]["path"].is_null());
|
||||
assert_eq!(before["documents"][0]["dirty"], true);
|
||||
let denied = call(&client, "doc_save", json!({})).await;
|
||||
assert_eq!(denied.is_error, Some(true));
|
||||
assert!(text(&denied).contains("pass `path`"), "{}", text(&denied));
|
||||
assert_eq!(json_of(&call(&client, "session_list", json!({})).await), before);
|
||||
assert_eq!(std::fs::read(library.join("same.pcraft")).unwrap(), original_library);
|
||||
assert_eq!(std::fs::read(work.join("same.pcraft")).unwrap(), original_work);
|
||||
|
||||
json_of(&call(&client, "doc_save", json!({"path": "same.pcraft"})).await);
|
||||
json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "Work edit"}})).await);
|
||||
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "same.pcraft");
|
||||
let reopened = json_of(&call(&client, "doc_open", json!({"path": work.join("same.pcraft").to_string_lossy()})).await);
|
||||
assert_eq!(reopened["width"], 11);
|
||||
assert_eq!(json_of(&call(&client, "doc_inspect", json!({})).await)["layers"][0]["name"], "Work edit");
|
||||
assert_eq!(json_of(&call(&client, "session_list", json!({})).await)["documents"][0]["dirty"], false);
|
||||
assert_eq!(std::fs::read(library.join("same.pcraft")).unwrap(), original_library);
|
||||
assert_ne!(std::fs::read(work.join("same.pcraft")).unwrap(), original_work);
|
||||
client.cancel().await.unwrap();
|
||||
cleanup(&base);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects() {
|
||||
let base = tmp("filesystem-policy");
|
||||
@@ -814,7 +857,7 @@ async fn doc_place_is_capability_scoped() {
|
||||
assert_eq!(placed, 3, "the absolute path beneath the root places as well");
|
||||
let refused = call(&client, "doc_place", json!({"path": outside.join("outside.png").to_string_lossy()})).await;
|
||||
assert_eq!(refused.is_error, Some(true));
|
||||
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
|
||||
assert!(text(&refused).contains("must be inside an automation read root"), "{}", text(&refused));
|
||||
let after = json_of(&call(&client, "doc_inspect", json!({})).await)["layers"].as_array().unwrap().len();
|
||||
assert_eq!(after, placed, "nothing outside the root was placed");
|
||||
// The ambient command keeps its refusal: the capability-scoped method is the only way in.
|
||||
@@ -847,7 +890,7 @@ async fn doc_replace_smart_object_is_capability_scoped() {
|
||||
assert_ne!(replaced.is_error, Some(true), "{}", text(&replaced));
|
||||
let refused = call(&client, "doc_replace_smart_object", json!({"path": outside.join("outside.png").to_string_lossy(), "layer": layer})).await;
|
||||
assert_eq!(refused.is_error, Some(true));
|
||||
assert!(text(&refused).contains("must be inside the automation root"), "{}", text(&refused));
|
||||
assert!(text(&refused).contains("must be inside an automation read root"), "{}", text(&refused));
|
||||
let ambient = call(&client, "command_run", json!({"id": "layer.smartObjects.replaceContents", "params": {"path": "inside.png", "layer": layer}})).await;
|
||||
assert_eq!(ambient.is_error, Some(true));
|
||||
assert!(text(&ambient).contains("ambient filesystem paths"), "{}", text(&ambient));
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Real-document regressions for ordered read roots and independent write authority.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_automation::{AuthorizedWorkspace, Headless, files};
|
||||
use photocraft_engine::actions_cmds::Action;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Roots {
|
||||
base: PathBuf,
|
||||
library: PathBuf,
|
||||
work: PathBuf,
|
||||
outside: PathBuf,
|
||||
}
|
||||
|
||||
impl Roots {
|
||||
fn new(name: &str) -> Self {
|
||||
let base = std::env::temp_dir().join(format!("pc-read-roots-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let roots = Self { library: base.join("library"), work: base.join("work"), outside: base.join("outside"), base };
|
||||
for root in [&roots.library, &roots.work, &roots.outside] {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn workspace(&self) -> AuthorizedWorkspace {
|
||||
AuthorizedWorkspace::with_read_roots([self.library.as_path(), self.work.as_path()], Some(self.work.as_path())).unwrap()
|
||||
}
|
||||
|
||||
fn headless(&self) -> Headless {
|
||||
Headless::with_workspace(self.workspace())
|
||||
}
|
||||
|
||||
fn seed(&self) -> (Vec<u8>, Vec<u8>) {
|
||||
let library = layered_bytes(11, "white");
|
||||
let work = layered_bytes(7, "black");
|
||||
std::fs::write(self.library.join("same.pcraft"), &library).unwrap();
|
||||
std::fs::write(self.work.join("same.pcraft"), &work).unwrap();
|
||||
(library, work)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Roots {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.base);
|
||||
}
|
||||
}
|
||||
|
||||
fn layered_bytes(width: u32, background: &str) -> Vec<u8> {
|
||||
let mut headless = Headless::new();
|
||||
headless.command_run("file.new", json!({"width": width, "height": 5, "background": background})).unwrap();
|
||||
headless.command_run("layer.new.layer", json!({"name": "Source marker"})).unwrap();
|
||||
let doc = &headless.session.active().unwrap().doc;
|
||||
files::save_bytes(doc, "seed.pcraft", None, &Default::default()).unwrap().0
|
||||
}
|
||||
|
||||
fn snapshot(headless: &mut Headless) -> (Value, Value, Vec<u8>) {
|
||||
(headless.session_list(), headless.inspect(None).unwrap(), headless.render_png(None, 32).unwrap())
|
||||
}
|
||||
|
||||
fn assert_source(headless: &Headless, width: u32, rgba: [u8; 4]) {
|
||||
let state = headless.session.active().unwrap();
|
||||
assert_eq!(state.doc.size.width, width);
|
||||
assert_eq!(state.doc.size.height, 5);
|
||||
let preview = photocraft_compose::thumbnail(&state.doc, 32);
|
||||
assert!(!preview.pixels.is_empty());
|
||||
assert!(preview.pixels.as_chunks::<4>().0.iter().all(|pixel| pixel == &rgba), "opened the wrong source pixels");
|
||||
}
|
||||
|
||||
fn edit(headless: &mut Headless, name: &str) {
|
||||
headless.command_run("layer.new.layer", json!({"name": name})).unwrap();
|
||||
assert!(headless.session.active().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_first_open_cannot_overwrite_same_named_work_until_explicit_save_as() {
|
||||
let roots = Roots::new("save-identity");
|
||||
let (library, work) = roots.seed();
|
||||
let mut headless = roots.headless();
|
||||
headless.open(Path::new("same.pcraft")).unwrap();
|
||||
assert_source(&headless, 11, [255; 4]);
|
||||
assert!(headless.session.active().unwrap().path.is_none());
|
||||
edit(&mut headless, "Library edit");
|
||||
|
||||
let before = snapshot(&mut headless);
|
||||
let error = headless.save(None, None, None, &Default::default()).unwrap_err().to_string();
|
||||
assert!(error.contains("pass `path`"), "{error}");
|
||||
assert_eq!(snapshot(&mut headless), before, "denial must preserve pixels, history, selection, path and dirty revision");
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
|
||||
// Naming a target is the explicit transition from a library document to a work file.
|
||||
headless.save(None, Some(Path::new("same.pcraft")), None, &Default::default()).unwrap();
|
||||
assert_eq!(headless.session.active().unwrap().path.as_deref(), Some("same.pcraft"));
|
||||
assert!(!headless.session.active().unwrap().is_dirty());
|
||||
assert_ne!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
|
||||
edit(&mut headless, "Later work edit");
|
||||
assert_eq!(headless.save(None, None, None, &Default::default()).unwrap()["path"], "same.pcraft");
|
||||
assert!(!headless.session.active().unwrap().is_dirty());
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
|
||||
let mut reopened = roots.headless();
|
||||
reopened.open(&roots.work.join("same.pcraft")).unwrap();
|
||||
assert_source(&reopened, 11, [255; 4]);
|
||||
assert_eq!(reopened.inspect(None).unwrap()["layers"][0]["name"], "Later work edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_selects_the_second_read_root_and_allows_its_own_implicit_save() {
|
||||
let roots = Roots::new("absolute-work");
|
||||
let (library, work) = roots.seed();
|
||||
let mut headless = roots.headless();
|
||||
let path = roots.work.join("same.pcraft");
|
||||
headless.open(&path).unwrap();
|
||||
assert_source(&headless, 7, [0, 0, 0, 255]);
|
||||
assert_eq!(headless.session.active().unwrap().path.as_deref(), path.to_str());
|
||||
edit(&mut headless, "Absolute work edit");
|
||||
assert_eq!(headless.save(None, None, None, &Default::default()).unwrap()["path"], path.to_str().unwrap());
|
||||
assert!(!headless.session.active().unwrap().is_dirty());
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_ne!(std::fs::read(&path).unwrap(), work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_library_file_falls_through_to_a_writable_work_document() {
|
||||
let roots = Roots::new("relative-work");
|
||||
let work = layered_bytes(7, "black");
|
||||
std::fs::write(roots.work.join("unique.pcraft"), &work).unwrap();
|
||||
let mut headless = roots.headless();
|
||||
headless.open(Path::new("unique.pcraft")).unwrap();
|
||||
assert_source(&headless, 7, [0, 0, 0, 255]);
|
||||
assert_eq!(headless.session.active().unwrap().path.as_deref(), Some("unique.pcraft"));
|
||||
edit(&mut headless, "Relative work edit");
|
||||
headless.save(None, None, None, &Default::default()).unwrap();
|
||||
assert_ne!(std::fs::read(roots.work.join("unique.pcraft")).unwrap(), work);
|
||||
assert!(!roots.library.join("unique.pcraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_roots_can_reopen_exported_work_without_acquiring_write_authority() {
|
||||
let roots = Roots::new("readonly-reopen");
|
||||
let (library, _) = roots.seed();
|
||||
{
|
||||
let mut writer = roots.headless();
|
||||
writer.open(Path::new("same.pcraft")).unwrap();
|
||||
edit(&mut writer, "Exported for review");
|
||||
writer.save(None, Some(Path::new("review.pcraft")), None, &Default::default()).unwrap();
|
||||
}
|
||||
let exported = std::fs::read(roots.work.join("review.pcraft")).unwrap();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([roots.library.as_path(), roots.work.as_path()], None).unwrap();
|
||||
let mut reader = Headless::with_workspace(workspace);
|
||||
for path in [PathBuf::from("review.pcraft"), roots.work.join("review.pcraft")] {
|
||||
reader.open(&path).unwrap();
|
||||
assert_source(&reader, 11, [255; 4]);
|
||||
assert_eq!(reader.inspect(None).unwrap()["layers"][0]["name"], "Exported for review");
|
||||
assert!(reader.session.active().unwrap().path.is_none());
|
||||
edit(&mut reader, "Unsaved review edit");
|
||||
let before = snapshot(&mut reader);
|
||||
assert!(reader.save(None, None, None, &Default::default()).is_err());
|
||||
let error = reader.save(None, Some(Path::new("review.pcraft")), None, &Default::default()).unwrap_err().to_string();
|
||||
assert!(error.contains("write authority is absent"), "{error}");
|
||||
assert_eq!(snapshot(&mut reader), before);
|
||||
}
|
||||
assert_eq!(std::fs::read(roots.work.join("review.pcraft")).unwrap(), exported);
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_root_does_not_implicitly_grant_read_access_to_saved_work() {
|
||||
let roots = Roots::new("write-is-not-read");
|
||||
let _ = roots.seed();
|
||||
let workspace = AuthorizedWorkspace::with_read_roots([roots.library.as_path()], Some(roots.work.as_path())).unwrap();
|
||||
let mut headless = Headless::with_workspace(workspace);
|
||||
headless.open(Path::new("same.pcraft")).unwrap();
|
||||
headless.save(None, Some(Path::new("written.pcraft")), None, &Default::default()).unwrap();
|
||||
let before = snapshot(&mut headless);
|
||||
for path in [PathBuf::from("written.pcraft"), roots.work.join("written.pcraft")] {
|
||||
assert!(headless.open(&path).is_err());
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
}
|
||||
assert!(roots.work.join("written.pcraft").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_export_does_not_arm_implicit_save_for_a_library_document() {
|
||||
let roots = Roots::new("flat-export");
|
||||
let (library, work) = roots.seed();
|
||||
let mut headless = roots.headless();
|
||||
headless.open(Path::new("same.pcraft")).unwrap();
|
||||
edit(&mut headless, "Still unsaved");
|
||||
let before = snapshot(&mut headless);
|
||||
headless.save(None, Some(Path::new("preview.png")), None, &Default::default()).unwrap();
|
||||
assert_eq!(snapshot(&mut headless), before, "a flat export must not clear dirty or attach a save path");
|
||||
let exported = std::fs::read(roots.work.join("preview.png")).unwrap();
|
||||
assert_eq!(photocraft_codecs::decode(&exported).unwrap().dimensions(), (11, 5));
|
||||
assert!(headless.save(None, None, None, &Default::default()).is_err());
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
assert_eq!(std::fs::read(roots.work.join("preview.png")).unwrap(), exported);
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_absolute_paths_and_traversal_cannot_be_used_as_save_targets() {
|
||||
let roots = Roots::new("rejected-writes");
|
||||
let (library, work) = roots.seed();
|
||||
let canary = layered_bytes(3, "black");
|
||||
std::fs::write(roots.outside.join("canary.pcraft"), &canary).unwrap();
|
||||
let mut headless = roots.headless();
|
||||
headless.open(&roots.library.join("same.pcraft")).unwrap();
|
||||
assert!(headless.session.active().unwrap().path.is_none());
|
||||
edit(&mut headless, "Must remain unsaved");
|
||||
let before = snapshot(&mut headless);
|
||||
for path in [
|
||||
roots.library.join("same.pcraft"),
|
||||
PathBuf::from("../library/same.pcraft"),
|
||||
PathBuf::from("../outside/canary.pcraft"),
|
||||
roots.work.join("..").join("outside").join("canary.pcraft"),
|
||||
] {
|
||||
let error = headless.save(None, Some(&path), None, &Default::default()).unwrap_err().to_string();
|
||||
assert!(error.contains("automation path rejected"), "{path:?}: {error}");
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
}
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
assert_eq!(std::fs::read(roots.outside.join("canary.pcraft")).unwrap(), canary);
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn a_work_directory_symlink_cannot_write_into_another_read_root() {
|
||||
let roots = Roots::new("symlink-write");
|
||||
let (library, work) = roots.seed();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&roots.library, roots.work.join("library-link")).unwrap();
|
||||
#[cfg(windows)]
|
||||
if let Err(error) = std::os::windows::fs::symlink_dir(&roots.library, roots.work.join("library-link")) {
|
||||
if error.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
eprintln!("symlink regression requires Windows symlink permission: {error}");
|
||||
return;
|
||||
}
|
||||
panic!("create test symlink: {error}");
|
||||
}
|
||||
let mut headless = roots.headless();
|
||||
headless.open(Path::new("same.pcraft")).unwrap();
|
||||
edit(&mut headless, "Cannot follow the write link");
|
||||
let before = snapshot(&mut headless);
|
||||
for path in ["library-link/same.pcraft", "library-link/new.pcraft"] {
|
||||
assert!(headless.save(None, Some(Path::new(path)), None, &Default::default()).is_err());
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
}
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
assert!(!roots.library.join("new.pcraft").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_first_match_is_an_error_instead_of_falling_through_to_a_valid_document() {
|
||||
let roots = Roots::new("corrupt-first");
|
||||
let (_, work) = roots.seed();
|
||||
let corrupt = b"this is not a native document";
|
||||
std::fs::write(roots.library.join("same.pcraft"), corrupt).unwrap();
|
||||
let mut headless = roots.headless();
|
||||
// The later file is valid, and a failed open must leave this active document intact.
|
||||
headless.open(&roots.work.join("same.pcraft")).unwrap();
|
||||
assert_source(&headless, 7, [0, 0, 0, 255]);
|
||||
edit(&mut headless, "Existing edit");
|
||||
let before = snapshot(&mut headless);
|
||||
assert!(headless.open(Path::new("same.pcraft")).is_err());
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), corrupt);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_roots_do_not_grant_ambient_commands_or_nested_action_file_access() {
|
||||
let roots = Roots::new("ambient-commands");
|
||||
let (library, work) = roots.seed();
|
||||
let mut headless = roots.headless();
|
||||
headless.open(Path::new("same.pcraft")).unwrap();
|
||||
let before = snapshot(&mut headless);
|
||||
let target = roots.work.join("ambient.psd");
|
||||
let error = headless.command_run("file.saveACopy", json!({"path": target})).unwrap_err().to_string();
|
||||
assert!(error.contains("ambient filesystem paths"), "{error}");
|
||||
// A writable destination still cannot be reached through an ambient engine command.
|
||||
headless.session.actions.list.push(Action::new("Write work", vec![("file.saveACopy".into(), json!({"path": target}))]));
|
||||
headless.session.actions.list.push(Action::new("Nested write", vec![("actions.play".into(), json!({"action": "Write work"}))]));
|
||||
let played = headless.command_run("actions.play", json!({"action": "Nested write"})).unwrap();
|
||||
assert!(played["failed"].to_string().contains("ambient filesystem paths"), "{played}");
|
||||
assert_eq!(snapshot(&mut headless), before);
|
||||
assert!(!target.exists());
|
||||
assert_eq!(std::fs::read(roots.library.join("same.pcraft")).unwrap(), library);
|
||||
assert_eq!(std::fs::read(roots.work.join("same.pcraft")).unwrap(), work);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ use super::bevel::{BevelPaint, TextureCtx, bevel_geom, bevel_maps, satin_map};
|
||||
use super::distance::{INSIDE_EPS, Metric, dist_inside_by, dist_outside_by, local_coverage, stroke_outside_exact};
|
||||
use super::paint::{dissolves_in, glow_map, paint_color, paint_fx, paint_glow, paint_pattern, rgb, shadow_map};
|
||||
use super::{
|
||||
BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement, PreparedPatterns, Rect,
|
||||
StrokePosition, psblend,
|
||||
Bevel, BevelStyle, BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement,
|
||||
PreparedPatterns, Rect, StrokePosition, psblend,
|
||||
};
|
||||
|
||||
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
|
||||
@@ -56,6 +56,37 @@ pub(crate) fn build_maps_prepared(
|
||||
Effect::OuterGlow(g) => vec![glow_map(&shape, g, false, (rect.x0, rect.y0))],
|
||||
Effect::InnerGlow(g) => vec![glow_map(&shape, g, true, (rect.x0, rect.y0))],
|
||||
Effect::Satin(s) => vec![satin_map(&shape, s)],
|
||||
// Stroke Emboss (#8639): an Inner Bevel of the layer's outline with its Stroke effects
|
||||
// (the stroked shape's outer edge, inside the stroke too), shading only the strokes,
|
||||
// over the finished layer, as Photoshop's corpus files show it. Several strokes emboss
|
||||
// as their union (a 4 px stroke over a 10 px one shades the whole 10 px band). Without
|
||||
// a stroke it has nothing to emboss.
|
||||
Effect::BevelEmboss(b) if b.style == BevelStyle::StrokeEmboss => {
|
||||
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(st) } else { None }).collect();
|
||||
let band = strokes.iter().map(|st| stroke_band(&shape, st)).reduce(|mut acc, m| {
|
||||
for (a, s) in acc.v.iter_mut().zip(&m.v) {
|
||||
*a = a.max(*s);
|
||||
}
|
||||
acc
|
||||
});
|
||||
// Strokes inside the layer shade with the layer, as an Inner Bevel does; one
|
||||
// reaching outside shades over the finished layer and its backdrop.
|
||||
bevel_paint[i] = if strokes_inside(&strokes) { BevelPaint::Inner } else { BevelPaint::Both };
|
||||
match band {
|
||||
Some(band) => {
|
||||
// The shape keeps its own (anti-aliased) edge; the stroke adds what lies outside it.
|
||||
let outline = Map { w, h, v: shape.v.iter().zip(&band.v).map(|(a, s)| if *a > INSIDE_EPS { *a } else { a.max(*s) }).collect() };
|
||||
let mut maps = bevel_maps(&outline, &Bevel { style: BevelStyle::InnerBevel, ..(*b).clone() }, light, tex, patterns).0;
|
||||
for m in &mut maps {
|
||||
for (v, s) in m.v.iter_mut().zip(&band.v) {
|
||||
*v *= s;
|
||||
}
|
||||
}
|
||||
maps
|
||||
}
|
||||
None => vec![Map::new(w, h, 0.0), Map::new(w, h, 0.0)],
|
||||
}
|
||||
}
|
||||
Effect::BevelEmboss(b) => {
|
||||
let (maps, paint) = bevel_maps(&shape, b, light, tex, patterns);
|
||||
bevel_paint[i] = paint;
|
||||
@@ -148,16 +179,48 @@ pub fn premerges_layer(layer: &Layer) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage of `st`'s band around `shape` (row-major over the same region): its inside part as
|
||||
/// far in as the inside width, its outside part as far out as the outside width, as the
|
||||
/// compositor paints them. The shape Stroke Emboss bevels (#8639).
|
||||
fn stroke_band(shape: &Map, st: &photocraft_doc::StrokeFx) -> Map {
|
||||
let (in_w, out_w) = stroke_widths(st);
|
||||
let din = (in_w > 0.0).then(|| dist_inside_by(shape, Metric::Chamfer));
|
||||
let metric = if stroke_outside_exact(out_w) { Metric::Euclidean } else { Metric::Chamfer };
|
||||
let dout = (out_w > 0.0).then(|| dist_outside_by(shape, metric));
|
||||
let mut band = Map::new(shape.w, shape.h, 0.0);
|
||||
for (i, (b, a)) in band.v.iter_mut().zip(&shape.v).enumerate() {
|
||||
*b = if *a > INSIDE_EPS {
|
||||
din.as_ref().and_then(|d| d.get(i)).map_or(0.0, |d| (in_w + 0.5 - d).clamp(0.0, 1.0))
|
||||
} else {
|
||||
dout.as_ref().and_then(|d| d.get(i)).map_or(0.0, |d| (out_w + 0.5 - d).clamp(0.0, 1.0))
|
||||
};
|
||||
}
|
||||
band
|
||||
}
|
||||
|
||||
/// `true` if there are strokes and none reaches outside the layer: Stroke Emboss then shades with
|
||||
/// the layer, as an Inner Bevel does.
|
||||
fn strokes_inside(strokes: &[&photocraft_doc::StrokeFx]) -> bool {
|
||||
!strokes.is_empty() && strokes.iter().all(|st| stroke_widths(st).1 <= 0.0)
|
||||
}
|
||||
|
||||
/// `true` if an enabled effect painted inside the layer (with its content) blends in a mode
|
||||
/// other than Normal.
|
||||
fn blends_inside(layer: &Layer) -> bool {
|
||||
pub(super) fn blends_inside(layer: &Layer) -> bool {
|
||||
let blends = |m: BlendMode| m != BlendMode::Normal;
|
||||
// Stroke Emboss with its strokes wholly inside shades with the layer (`BevelPaint::Inner` in
|
||||
// `build_maps_prepared`), as an Inner Bevel does; one reaching outside shades over the
|
||||
// finished layer.
|
||||
let strokes: Vec<&photocraft_doc::StrokeFx> =
|
||||
layer.effects.items.iter().filter(|e| e.enabled()).filter_map(|e| if let Effect::Stroke(st) = e { Some(st) } else { None }).collect();
|
||||
let stroke_inside = strokes_inside(&strokes);
|
||||
layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e {
|
||||
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => blends(common.blend),
|
||||
Effect::Satin(s) => blends(s.common.blend),
|
||||
Effect::InnerGlow(g) => blends(g.common.blend),
|
||||
Effect::InnerShadow(s) => blends(s.common.blend),
|
||||
Effect::Stroke(s) => stroke_widths(s).0 > 0.0 && blends(s.common.blend),
|
||||
Effect::BevelEmboss(b) if b.style == BevelStyle::StrokeEmboss => stroke_inside && (blends(b.highlight.blend) || blends(b.shadow.blend)),
|
||||
Effect::BevelEmboss(b) => bevel_geom(b).paint == BevelPaint::Inner && (blends(b.highlight.blend) || blends(b.shadow.blend)),
|
||||
Effect::DropShadow(_) | Effect::OuterGlow(_) => false,
|
||||
})
|
||||
|
||||
@@ -1047,3 +1047,69 @@ fn shifting_by_hostile_distances_does_not_overflow() {
|
||||
assert_eq!(s.v.len(), 144);
|
||||
}
|
||||
}
|
||||
|
||||
/// #8639 review: a Stroke Emboss whose stroke lies wholly inside shades with the layer, so its
|
||||
/// non-Normal highlight or shadow counts as an interior blend, as an Inner Bevel's does; with the
|
||||
/// stroke reaching outside, or no stroke, it doesn't.
|
||||
#[test]
|
||||
fn an_inside_stroke_emboss_counts_as_an_interior_blend() {
|
||||
let stroke = |position: StrokePosition| {
|
||||
Effect::Stroke(photocraft_doc::StrokeFx {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 4.0,
|
||||
position,
|
||||
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::BLACK),
|
||||
overprint: false,
|
||||
})
|
||||
};
|
||||
let layer = |effects: Vec<Effect>| {
|
||||
let mut l =
|
||||
photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)));
|
||||
l.effects.items = effects;
|
||||
l
|
||||
};
|
||||
let emboss = Effect::BevelEmboss(bevel_of(BevelStyle::StrokeEmboss, BevelTechnique::Smooth));
|
||||
assert!(super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Inside), emboss.clone()])));
|
||||
assert!(!super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Outside), emboss.clone()])));
|
||||
assert!(!super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Center), emboss.clone()])));
|
||||
assert!(!super::composite::blends_inside(&layer(vec![emboss])));
|
||||
}
|
||||
|
||||
/// #8639 review, measured in the reference application: Stroke Emboss bevels the union of every
|
||||
/// enabled Stroke (a 4 px stroke listed over a 10 px one shades the whole 10 px band), and a
|
||||
/// stroke at 0 % opacity still has its band embossed over the backdrop.
|
||||
#[test]
|
||||
fn stroke_emboss_bevels_every_stroke_even_a_transparent_one() {
|
||||
let stroke = |size: f32, position: StrokePosition, opacity: f32| {
|
||||
Effect::Stroke(photocraft_doc::StrokeFx {
|
||||
common: photocraft_doc::FxCommon::new(BlendMode::Normal, opacity),
|
||||
size,
|
||||
position,
|
||||
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::BLACK),
|
||||
overprint: false,
|
||||
})
|
||||
};
|
||||
let emboss = Effect::BevelEmboss(bevel_of(BevelStyle::StrokeEmboss, BevelTechnique::Smooth));
|
||||
let maps = |effects: Vec<Effect>| {
|
||||
let mut l =
|
||||
photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)));
|
||||
l.effects.items = effects;
|
||||
let n = l.effects.items.len();
|
||||
let m = build_maps(&l, square(60, 20, 40).v, Rect::new(0, 0, 60, 60), &GlobalLight::default(), &no_tex());
|
||||
(m.per[n - 1].clone(), m.bevel_paint[n - 1])
|
||||
};
|
||||
// 7 px above the shape: inside the 10 px band only; the bevel (light from the top) lights it.
|
||||
let lit = |m: &[Map]| m[0].v[13 * 60 + 30];
|
||||
let (two, paint) = maps(vec![stroke(4.0, StrokePosition::Outside, 1.0), stroke(10.0, StrokePosition::Outside, 1.0), emboss.clone()]);
|
||||
assert!(lit(&two) > 0.1, "{}", lit(&two));
|
||||
assert_eq!(paint, BevelPaint::Both);
|
||||
let (thin, _) = maps(vec![stroke(4.0, StrokePosition::Outside, 1.0), emboss.clone()]);
|
||||
assert_eq!(lit(&thin), 0.0);
|
||||
let (clear, _) = maps(vec![stroke(10.0, StrokePosition::Outside, 0.0), emboss.clone()]);
|
||||
assert!(lit(&clear) > 0.1, "{}", lit(&clear));
|
||||
// Inside only when every stroke is.
|
||||
let (_, inside) = maps(vec![stroke(4.0, StrokePosition::Inside, 1.0), stroke(6.0, StrokePosition::Inside, 1.0), emboss.clone()]);
|
||||
assert_eq!(inside, BevelPaint::Inner);
|
||||
let (_, mixed) = maps(vec![stroke(4.0, StrokePosition::Inside, 1.0), stroke(6.0, StrokePosition::Outside, 1.0), emboss]);
|
||||
assert_eq!(mixed, BevelPaint::Both);
|
||||
}
|
||||
+21
-1
@@ -240,7 +240,18 @@ pub enum TextShape {
|
||||
/// (text-space pixels, before the layer transform) by arc length and turned to follow the
|
||||
/// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip`
|
||||
/// puts the text on the other side of the path.
|
||||
Path { path: super::vector::Subpath, start: f32, flip: bool },
|
||||
Path {
|
||||
path: super::vector::Subpath,
|
||||
start: f32,
|
||||
/// Unwrapped arc-length end offset in text-space pixels, measured in the current
|
||||
/// travel direction (`flip` reverses that direction). It may exceed the path length
|
||||
/// on closed paths: for a 100 px loop, start 80 and end 120 cross the seam by 20 px.
|
||||
/// End equal to start is empty; start plus the path length permits one full lap.
|
||||
/// `None` preserves legacy unbounded clipping and alignment around the start anchor.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
end: Option<f32>,
|
||||
flip: bool,
|
||||
},
|
||||
/// Area type: paragraph text that wraps inside the closed subpaths of `path` (text-space
|
||||
/// pixels, before the layer transform), combined with their path operations and fill rule.
|
||||
/// Text that doesn't fit is hidden, as in a text box.
|
||||
@@ -286,6 +297,15 @@ pub struct TextWarp {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Ensures `TextShape::Path` serializes backward-compatibly without an `"end"` field when `end` is `None`.
|
||||
#[test]
|
||||
fn path_end_is_backward_compatible() {
|
||||
let shape = super::TextShape::Path { path: Default::default(), start: 2.0, end: None, flip: false };
|
||||
let value = serde_json::to_value(&shape).unwrap();
|
||||
assert!(value.get("Path").unwrap().get("end").is_none());
|
||||
assert_eq!(serde_json::from_value::<super::TextShape>(value).unwrap(), shape);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::TextLayer;
|
||||
|
||||
|
||||
@@ -1255,7 +1255,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
(
|
||||
"colorLookup",
|
||||
"Color Lookup…",
|
||||
r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":text,"dataEncoding":"text|base64"="text","fileName":text?,"lutFormat":"cube|3dl|look"?} (file: LUT path; data: file text or encoded bytes; lutFormat overrides format detection)"##,
|
||||
r##"{"lut":"none|warm|cool|tealOrange|bleachBypass|fadedFilm|dayForNight|monoContrast|crossProcess"="none","file":text,"interpolation":"trilinear|tetrahedral"="trilinear","dither":bool=false,"data":text,"dataEncoding":"text|base64"="text","fileName":text,"lutFormat":"auto|cube|3dl|look"="auto"} (file: LUT path; data: file text or encoded bytes; lutFormat overrides format detection, auto detects it)"##,
|
||||
),
|
||||
];
|
||||
for &(kind, label, params) in ADJ {
|
||||
|
||||
@@ -102,7 +102,7 @@ pub fn selective_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adj
|
||||
|
||||
/// Color Lookup from params: `"lut"` (a built-in look id or `"none"`), `"file"` (a .cube / .3dl /
|
||||
/// .look path, native only) or `"data"` (file text, or base64 with `"dataEncoding":"base64"`).
|
||||
/// `"lutFormat":"cube|3dl|look"` can supply the format when the file name doesn't identify it.
|
||||
/// `"lutFormat":"cube|3dl|look"` (`"auto"`: detect) can supply the format when the file name doesn't identify it.
|
||||
/// `"interpolation":"trilinear|tetrahedral"`, `"dither":bool`. The table is embedded in the
|
||||
/// layer, as Photoshop does. Unspecified fields keep `base`'s values; a key of the wrong type is an
|
||||
/// error.
|
||||
@@ -119,11 +119,11 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
|
||||
return Err(bad(CMD, "`dataEncoding` must be \"text\" or \"base64\""));
|
||||
}
|
||||
let format = match opt_str(CMD, p, "lutFormat")? {
|
||||
None => None,
|
||||
None | Some("auto") => None,
|
||||
Some("cube") => Some(photocraft_cms::lutfile::Format::Cube),
|
||||
Some("3dl") => Some(photocraft_cms::lutfile::Format::ThreeDl),
|
||||
Some("look") => Some(photocraft_cms::lutfile::Format::Look),
|
||||
Some(_) => return Err(bad(CMD, "`lutFormat` must be \"cube\", \"3dl\" or \"look\"")),
|
||||
Some(_) => return Err(bad(CMD, "`lutFormat` must be \"auto\", \"cube\", \"3dl\" or \"look\"")),
|
||||
};
|
||||
// The parsed table, its label and (for a file) the file as loaded, which a PSD save embeds.
|
||||
let mut loaded: Option<(photocraft_cms::lutfile::LutFile, String, Option<LutSource>)> = None;
|
||||
@@ -139,8 +139,8 @@ pub fn lookup_from_params(p: &Value, base: Option<&Adjustment>) -> Result<Adjust
|
||||
loaded = Some((f, label, None));
|
||||
}
|
||||
}
|
||||
let file_name = opt_str(CMD, p, "fileName")?;
|
||||
if let Some(text) = opt_str(CMD, p, "data")? {
|
||||
let file_name = opt_str(CMD, p, "fileName")?.filter(|s| !s.is_empty());
|
||||
if let Some(text) = opt_str(CMD, p, "data")?.filter(|s| !s.is_empty()) {
|
||||
let file_name = file_name.unwrap_or("lut.cube");
|
||||
let bytes = if encoding == "base64" {
|
||||
photocraft_paint::tile::b64_decode(text).ok_or_else(|| bad(CMD, "`data` is not valid base64"))?
|
||||
|
||||
@@ -266,7 +266,7 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
st.doc = before;
|
||||
let failed = std::mem::replace(&mut st.doc, before);
|
||||
st.active_layer = prior.active;
|
||||
st.selected_layers = prior.selected;
|
||||
st.layer_anchor = anchor;
|
||||
@@ -279,7 +279,9 @@ pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Sess
|
||||
if was_clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = None;
|
||||
// This revision's damage; `Session::dispatch` reports the command's whole span (nothing,
|
||||
// when the inner commands had changed pixels and the restore took them back).
|
||||
st.last_damage = crate::layer_multi_cmds::step_damage(&failed, &st.doc);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -723,6 +723,8 @@ fn moving_an_artboard_left_of_or_above_the_origin_grows_the_canvas_there() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving or making a board past the canvas origin re-bases the document; the Color Sampler
|
||||
/// points and the painting symmetry shift with it and stay on the same pixels.
|
||||
#[test]
|
||||
fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_document() {
|
||||
// #9302: the re-based document moved everything but the Color Sampler points and the
|
||||
@@ -737,9 +739,10 @@ fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_docume
|
||||
s.execute("layer.select", json!({"layer": gid.0})).unwrap();
|
||||
s.execute(cmd, p).unwrap();
|
||||
// The board lands at (0, 0) after the document shifted right 50 and down 20; the sampler
|
||||
// is pinned to the canvas, not the board, so it shifts with the document only.
|
||||
// is pinned to the canvas, not the board, so it shifts with the document only. Sampler
|
||||
// points sit on pixel centres (#11725): one placed at 15, 15 is stored as 15.5, 15.5.
|
||||
assert_eq!(board_rect(&s, gid.0), Rect::new(0, 0, 40, 40), "{cmd}");
|
||||
assert_eq!(samplers(&s), vec![[65.0, 35.0]], "{cmd}");
|
||||
assert_eq!(samplers(&s), vec![[65.5, 35.5]], "{cmd}");
|
||||
assert_eq!(center(&s), Some([120.0, 40.0]), "{cmd}: the symmetry stays on its pixels");
|
||||
}
|
||||
// A new board left of the canvas shifts the photo right by its overhang.
|
||||
@@ -747,7 +750,7 @@ fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_docume
|
||||
add_square(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);
|
||||
s.execute("view.colorSamplers.add", json!({"x": 15, "y": 15})).unwrap();
|
||||
s.execute("layer.new.artboard", json!({"rect": [-200, 0, 100, 80]})).unwrap();
|
||||
assert_eq!(samplers(&s), vec![[215.0, 15.0]]);
|
||||
assert_eq!(samplers(&s), vec![[215.5, 15.5]]);
|
||||
assert!(red_at(&s, 215, 15));
|
||||
// A board made from layers (or a group) left of the canvas shifts by their overhang.
|
||||
for cmd in ["layer.new.artboardFromLayers", "layer.new.artboardFromGroup"] {
|
||||
@@ -759,7 +762,7 @@ fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_docume
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
}
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
assert_eq!(samplers(&s), vec![[45.0, 15.0]], "{cmd}");
|
||||
assert_eq!(samplers(&s), vec![[45.5, 15.5]], "{cmd}");
|
||||
assert!(red_at(&s, 45, 15), "{cmd}");
|
||||
}
|
||||
// The "+" button left of a board at the origin shifts by the board and the gap.
|
||||
@@ -770,13 +773,15 @@ fn a_board_past_the_origin_moves_the_color_samplers_and_symmetry_with_the_docume
|
||||
s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "left"})).unwrap();
|
||||
let by = 40 + GAP;
|
||||
assert_eq!(board_rect(&s, gid.0), Rect::new(by, 0, by + 40, 40));
|
||||
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.0, 5.0]]);
|
||||
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.5, 5.5]]);
|
||||
assert!(red_at(&s, by + 5, 5));
|
||||
// A board right of the canvas shifts nothing.
|
||||
s.execute("layer.artboard.addBeside", json!({"layer": gid.0, "side": "right"})).unwrap();
|
||||
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.0, 5.0]]);
|
||||
assert_eq!(samplers(&s), vec![[f64::from(by) + 5.5, 5.5]]);
|
||||
}
|
||||
|
||||
/// Duplicating a board whose document has boards past the origin (one or several selected
|
||||
/// layers) re-bases it; the Color Sampler points shift with the document.
|
||||
#[test]
|
||||
fn duplicating_a_board_beside_one_past_the_origin_moves_the_color_samplers_with_the_document() {
|
||||
// #9302 review: Layer › Duplicate puts the copy right of its board, but `fit_canvas` still
|
||||
@@ -799,7 +804,7 @@ fn duplicating_a_board_beside_one_past_the_origin_moves_the_color_samplers_with_
|
||||
s.execute("layer.duplicate", json!({"layer": gid.0})).unwrap();
|
||||
}
|
||||
assert_eq!(board_rect(&s, gid.0), Rect::new(0, 10, 40, 50), "multi {multi}: the document shifted right 40");
|
||||
assert_eq!(s.active().unwrap().color_samplers, vec![[105.0, 15.0]], "multi {multi}");
|
||||
assert_eq!(s.active().unwrap().color_samplers, vec![[105.5, 15.5]], "multi {multi}");
|
||||
assert!(red_at(&s, 105, 15), "multi {multi}: the sampler is still on the square");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ mod steps_layer;
|
||||
mod steps_layer_style;
|
||||
mod steps_lookup;
|
||||
mod steps_make;
|
||||
mod steps_measurement;
|
||||
mod steps_path;
|
||||
mod steps_perspective_warp;
|
||||
mod steps_type_path;
|
||||
@@ -187,6 +188,9 @@ mod layer_props_tests;
|
||||
#[cfg(test)]
|
||||
mod stylize_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod measurement_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_tests;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::steps_artboard::{artboard_edit_from, artboard_edit_to, artboard_from,
|
||||
use super::steps_channels::{match_color_from, match_color_to, merge_spot_from, merge_spot_to, split_from, split_to, trap_from, trap_to};
|
||||
use super::steps_content_aware::{caf_from, caf_to};
|
||||
use super::steps_direct_adjustments::{gradient_from, gradient_to, replace_from, replace_to};
|
||||
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to};
|
||||
use super::steps_distort::{PINCH, POLAR, SPHERIZE, TWIRL, ZIGZAG, displace_from, displace_to, keys_from, keys_to, shear_from, shear_to, wave_from, wave_to};
|
||||
use super::steps_doc::{
|
||||
align_from, align_to, canvas_size_from, canvas_size_to, close_from, close_to, conditional_from, conditional_to, convert_mode_from, convert_mode_to,
|
||||
crop_from, crop_to, export_from, export_to, image_size_from, image_size_to, merge_visible_from, merge_visible_to, open_from, open_to, paste_from, paste_to,
|
||||
@@ -568,6 +568,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
&[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))]
|
||||
),
|
||||
// Distort (#10605): every key in recorded order, Photoshop's limits and defaults.
|
||||
Event { code: "", name: "displace", label: "Displace", ids: &["filter.distort.displace"], from: displace_from, to: displace_to },
|
||||
Event { code: "Shr ", name: "shear", label: "Shear", ids: &["filter.distort.shear"], from: shear_from, to: shear_to },
|
||||
Event {
|
||||
code: "Pnch",
|
||||
@@ -896,6 +897,7 @@ pub(super) const EVENTS: &[Event] = &[
|
||||
name: "set",
|
||||
label: "Set",
|
||||
ids: &[
|
||||
super::steps_measurement::SET_SCALE,
|
||||
"select.all",
|
||||
"select.deselect",
|
||||
"select.reselect",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! #10723: Image › Analysis › Set Measurement Scale › Custom steps, recorded as a Set of the
|
||||
//! document's `measurementScale` property (`docs/atn.md`, "Recorded through the menus").
|
||||
|
||||
use super::*;
|
||||
|
||||
const SET_SCALE: &str = "image.analysis.setMeasurementScale";
|
||||
const SOURCE: &str = "_atnMeasurementScaleDescriptor";
|
||||
|
||||
/// The `T ` object of the measured step, built here independently of the converter.
|
||||
fn scale(class: &str, pixels: i32, logical: f64, units: &str) -> Descriptor {
|
||||
Descriptor::new(class)
|
||||
.with("pixelLength", D::Integer(pixels))
|
||||
.with("logicalLength", D::Double(logical))
|
||||
.with("logicalUnits", D::Text(UnicodeString::new_nul(units)))
|
||||
}
|
||||
|
||||
/// The measured Set: `null` is the `measurementScale` property of the target document.
|
||||
fn recorded(t: Descriptor) -> Descriptor {
|
||||
let target = D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
|
||||
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]);
|
||||
Descriptor::new("null").with("null", target).with("T ", D::Descriptor(t))
|
||||
}
|
||||
|
||||
fn set_item(d: Descriptor) -> AtnItem {
|
||||
AtnItem { event: AtnEvent::Code(*b"setd"), dictionary: b"Set".to_vec(), descriptor: Some(d), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Load `items` as action "A" through `actions.load`.
|
||||
fn load(items: Vec<AtnItem>) -> (Session, Value) {
|
||||
let file = AtnFile {
|
||||
name: UnicodeString::new_nul("Analysis"),
|
||||
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
|
||||
..Default::default()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&atn::write(&file).unwrap())})).unwrap();
|
||||
(s, r)
|
||||
}
|
||||
|
||||
/// Action "A" written by `actions.save` and parsed back.
|
||||
fn saved(s: &mut Session) -> Vec<AtnItem> {
|
||||
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap().actions.remove(0).items
|
||||
}
|
||||
|
||||
/// The recorded step loads as a custom scale and saves back byte for byte, whatever class its
|
||||
/// `T ` object was recorded with and in whatever key order.
|
||||
#[test]
|
||||
fn a_recorded_scale_step_loads_and_saves_back_unchanged() {
|
||||
let mut reordered = scale("measurementScale", 50, 2.5, "cm");
|
||||
reordered.items.reverse();
|
||||
for (t, pixels, logical, units) in
|
||||
[(scale("measurementScale", 120, 3.0, "mm"), 120, 3.0, "mm"), (scale("null", 100, 25.4, "inches"), 100, 25.4, "inches"), (reordered, 50, 2.5, "cm")]
|
||||
{
|
||||
let d = recorded(t);
|
||||
let (mut s, r) = load(vec![set_item(d.clone())]);
|
||||
assert_eq!(r["unsupported"], json!([]), "{r}");
|
||||
let (id, p) = &s.actions.list[0].steps[0];
|
||||
assert_eq!(id, SET_SCALE);
|
||||
assert_eq!(
|
||||
(&p["preset"], p["pixelLength"].as_f64(), p["logicalLength"].as_f64(), &p["units"]),
|
||||
(&json!("custom"), Some(f64::from(pixels)), Some(logical), &json!(units)),
|
||||
"{p}"
|
||||
);
|
||||
let back = saved(&mut s);
|
||||
assert_eq!((&back[0].event, back[0].descriptor.as_ref().map(Descriptor::to_bytes)), (&AtnEvent::Code(*b"setd"), Some(d.to_bytes())));
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded step sets the document's scale, as one undoable edit.
|
||||
#[test]
|
||||
fn a_loaded_scale_step_plays_and_undoes() {
|
||||
let (mut s, _) = load(vec![set_item(recorded(scale("measurementScale", 120, 3.0, "mm")))]);
|
||||
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
|
||||
assert_eq!(played["ran"], 1, "{played}");
|
||||
let read = s.execute(SET_SCALE, json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(&read["pixelLength"], &read["logicalLength"], &read["units"], &read["default"]),
|
||||
(&json!(120.0), &json!(3.0), &json!("mm"), &json!(false)),
|
||||
"{read}"
|
||||
);
|
||||
s.execute("history.undo", json!({})).unwrap();
|
||||
assert_eq!(s.execute(SET_SCALE, json!({})).unwrap()["default"], json!(true));
|
||||
}
|
||||
|
||||
/// The step PhotoCraft writes for a native custom scale is the measured form, with `T ` of
|
||||
/// class `measurementScale`, and it loads back without a stored descriptor.
|
||||
#[test]
|
||||
fn a_native_scale_saves_the_measured_form() {
|
||||
let p = json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": "mm"});
|
||||
let d = export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor).unwrap();
|
||||
assert_eq!(d, recorded(scale("measurementScale", 120, 3.0, "mm")));
|
||||
let (s, _) = load(vec![set_item(d)]);
|
||||
assert_eq!(s.actions.list[0].steps[0].1, p);
|
||||
// `logicalUnits` is the command's other spelling of `units`, and the preset defaults to custom.
|
||||
let other = json!({"pixelLength": 120, "logicalLength": 3, "logicalUnits": "mm"});
|
||||
assert_eq!(export_item(SET_SCALE, &other, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 120, 3.0, "mm"))));
|
||||
}
|
||||
|
||||
/// A native step the event can't hold is refused: reading the scale, the default preset, a
|
||||
/// value left to the current scale, a fractional pixel length (the event stores a long), blank
|
||||
/// units, or another key.
|
||||
#[test]
|
||||
fn settings_the_event_cannot_hold_are_refused() {
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"preset": "default"}),
|
||||
json!({"preset": "query"}),
|
||||
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3}),
|
||||
json!({"preset": "custom", "logicalLength": 3, "units": "mm"}),
|
||||
json!({"preset": "custom", "pixelLength": 120.5, "logicalLength": 3, "units": "mm"}),
|
||||
json!({"preset": "custom", "pixelLength": 0, "logicalLength": 3, "units": "mm"}),
|
||||
json!({"preset": "custom", "pixelLength": 120, "logicalLength": -1, "units": "mm"}),
|
||||
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": " "}),
|
||||
json!({"preset": "custom", "pixelLength": 120, "logicalLength": 3, "units": "mm", "layer": 2}),
|
||||
] {
|
||||
assert!(export_item(SET_SCALE, &p, "S").is_none(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Any other form keeps the reference step: another target, a missing or extra key, values of
|
||||
/// another type, or values the native command refuses.
|
||||
#[test]
|
||||
fn other_forms_keep_the_photoshop_step() {
|
||||
let with = |key: &str, v: D| {
|
||||
let mut t = scale("measurementScale", 120, 3.0, "mm");
|
||||
if let Some(slot) = t.items.iter_mut().find(|(k, _)| k.is(key)) {
|
||||
slot.1 = v;
|
||||
}
|
||||
recorded(t)
|
||||
};
|
||||
let mut missing = scale("measurementScale", 120, 3.0, "mm");
|
||||
missing.items.pop();
|
||||
let mut layer_target = recorded(scale("measurementScale", 120, 3.0, "mm"));
|
||||
layer_target.items[0].1 = D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
|
||||
ReferenceItem::Enumerated { class: class("Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
]);
|
||||
let forms = [
|
||||
recorded(missing),
|
||||
recorded(scale("measurementScale", 120, 3.0, "mm").with("Scl ", D::Double(1.0))),
|
||||
recorded(scale("measurementScale", 120, 3.0, "mm")).with("Rslt", D::Double(72.0)),
|
||||
layer_target,
|
||||
with("pixelLength", D::Double(120.0)),
|
||||
with("logicalLength", D::Integer(3)),
|
||||
with("logicalLength", D::Double(0.0)),
|
||||
with("pixelLength", D::Integer(-4)),
|
||||
with("logicalUnits", D::Text(UnicodeString::new_nul(" "))),
|
||||
with("logicalUnits", D::Integer(1)),
|
||||
];
|
||||
for d in forms {
|
||||
let (mut s, r) = load(vec![set_item(d.clone())]);
|
||||
assert_eq!(r["unsupported"].as_array().map(Vec::len), Some(1), "{d:?}");
|
||||
assert_eq!(s.actions.list[0].steps[0].0, PHOTOSHOP_STEP, "{d:?}");
|
||||
assert_eq!(saved(&mut s)[0].descriptor.as_ref().map(Descriptor::to_bytes), Some(d.to_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded step whose scale was changed saves the new scale, not the stale recording; one whose
|
||||
/// recording is damaged still saves its settings.
|
||||
#[test]
|
||||
fn changed_or_damaged_steps_save_their_settings() {
|
||||
let (s, _) = load(vec![set_item(recorded(scale("null", 100, 25.4, "inches")))]);
|
||||
let mut p = s.actions.list[0].steps[0].1.clone();
|
||||
assert!(p.get(SOURCE).is_some(), "{p}");
|
||||
p["units"] = json!("cm");
|
||||
assert_eq!(export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 100, 25.4, "cm"))));
|
||||
p[SOURCE] = json!("not base64!");
|
||||
assert_eq!(export_item(SET_SCALE, &p, "S").and_then(|item| item.descriptor), Some(recorded(scale("measurementScale", 100, 25.4, "cm"))));
|
||||
}
|
||||
@@ -6,8 +6,8 @@ use super::{AtnEvent, AtnItem, EngineError, Result, Session, Value, bad, json};
|
||||
|
||||
/// Find a supported, unambiguous event for a menu step with no settings.
|
||||
fn menu_event(id: &str) -> Option<&'static Event> {
|
||||
// Camera Raw's ATN mapping requires a verified settings descriptor (#8714).
|
||||
if id == crate::lens_cmds::RAW || (id != "image.imageSize" && !id.starts_with("filter.")) {
|
||||
// Camera Raw (#8714) and recorded Displace require verified settings descriptors.
|
||||
if matches!(id, crate::lens_cmds::RAW | "filter.distort.displace") || (id != "image.imageSize" && !id.starts_with("filter.")) {
|
||||
return None;
|
||||
}
|
||||
EVENTS.iter().find(|e| {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Photoshop's Distort filter steps (#10605): Pinch, Polar Coordinates, Shear, Spherize, Twirl, Wave and
|
||||
//! ZigZag, each key in recorded order with the limits and defaults Photoshop measures.
|
||||
//! Photoshop's Distort filter steps, with keys in recorded order and measured limits and defaults.
|
||||
|
||||
use super::descriptors::{desc, enm, num, trimmed};
|
||||
use super::descriptors::{code, desc, enm, jstr, num, path_text, path_value, trimmed};
|
||||
use super::events::pixel_target;
|
||||
use super::fields::{Mapped, step};
|
||||
use super::tables::{lookup, lookup_back};
|
||||
use super::{D, Descriptor, Map, Value, json};
|
||||
use super::{D, Descriptor, Id, Map, Value, json};
|
||||
|
||||
/// Whether every key of `d` is one of `keys` (codes compare without their space padding).
|
||||
fn only_keys(d: &Descriptor, keys: &[&str]) -> bool {
|
||||
@@ -39,6 +38,73 @@ fn enum_of(ty: &str) -> impl Fn(&D) -> Option<String> + '_ {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the measured Windows Displace descriptor has a verified map path and settings.
|
||||
pub(super) fn displace_from(d: &Descriptor) -> Mapped {
|
||||
displace_params(d).map_or(Mapped::Unsupported { harmless: false }, |params| step("filter.distort.displace", params))
|
||||
}
|
||||
|
||||
/// Decode only the measured, byte-preserving Windows descriptor.
|
||||
fn displace_params(d: &Descriptor) -> Option<Value> {
|
||||
if d.class_id != code("null")
|
||||
|| d.items.len() != 5
|
||||
|| d.items.iter().any(|(key, _)| !matches!(key, Id::Code(_)))
|
||||
|| d.get("DspM") != Some(&enm("DspM", "StrF"))
|
||||
|| d.get("UndA") != Some(&enm("UndA", "RptE"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scale = |key| match d.get(key) {
|
||||
Some(D::Integer(value)) if (-999..=999).contains(value) => Some(*value),
|
||||
_ => None,
|
||||
};
|
||||
let horizontal = scale("HrzS")?;
|
||||
let vertical = scale("VrtS")?;
|
||||
let wire = d.get("DspF")?;
|
||||
let path = path_text(wire)?;
|
||||
// The shared decoder tolerates malformed lengths and UTF-16 for legacy callers. The
|
||||
// measured form must reproduce every byte, and an embedded NUL is never a file name.
|
||||
if path.contains('\0') || &path_value(&path) != wire {
|
||||
return None;
|
||||
}
|
||||
// An action file is shared like a document, and playback reads this path. Map only a path a
|
||||
// document may name: a local absolute file. Others stay kept steps, so playing a downloaded
|
||||
// set never opens a network share (which sends the user's credentials) or a device.
|
||||
crate::doc_path::resolve_in(&path, None).ok()?;
|
||||
Some(json!({"horizontal":horizontal,"vertical":vertical,"fit":"stretch","undefinedAreas":"repeat","mapPath":path}))
|
||||
}
|
||||
|
||||
/// Write the measured form with native defaults for omitted settings, without dropping
|
||||
/// settings the descriptor cannot represent.
|
||||
pub(super) fn displace_to(_: &str, p: &Value) -> Option<Descriptor> {
|
||||
if !pixel_target(p) || !sets_only(p, &["horizontal", "vertical", "fit", "undefinedAreas", "mapPath", "target"]) {
|
||||
return None;
|
||||
}
|
||||
if p.get("fit").is_some_and(|v| v.as_str() != Some("stretch")) || p.get("undefinedAreas").is_some_and(|v| v.as_str() != Some("repeat")) {
|
||||
return None;
|
||||
}
|
||||
let scale = |key| {
|
||||
let number = match p.get(key) {
|
||||
None => 10.0,
|
||||
Some(value) => value.as_f64()?,
|
||||
};
|
||||
let value = number as i32;
|
||||
((-999..=999).contains(&value) && f64::from(value) == number).then_some(D::Integer(value))
|
||||
};
|
||||
let path = jstr(p, "mapPath")?;
|
||||
// The header carries a u32 byte length, including its 12 bytes and the UTF-16 terminator.
|
||||
if path.is_empty() || path.contains('\0') || path.len() > ((u32::MAX - 14) / 2) as usize {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
desc("null")
|
||||
.with("HrzS", scale("horizontal")?)
|
||||
.with("VrtS", scale("vertical")?)
|
||||
.with("DspM", enm("DspM", "StrF"))
|
||||
.with("UndA", enm("UndA", "RptE"))
|
||||
.with("DspF", path_value(path)),
|
||||
)
|
||||
}
|
||||
|
||||
/// One key of a Distort filter's step, in recorded order.
|
||||
pub(super) enum Key {
|
||||
/// An integer clamped to `min..=max` on import, as Photoshop clamps it; `default` when the
|
||||
|
||||
@@ -1168,6 +1168,8 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
|
||||
(Some("Chnl"), Target::Property(p)) if p == "fsel" => selection_from(d),
|
||||
// Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off.
|
||||
(Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})),
|
||||
// Image › Analysis › Set Measurement Scale › Custom (#10723).
|
||||
(Some("Prpr"), Target::Property(p)) if p == "measurementScale" => super::steps_measurement::from(d),
|
||||
// Layer › New › Layer From Background (Photoshop records the New Layer dialog's values
|
||||
// as a `set` of the Background); values other than the defaults then apply to the layer.
|
||||
(Some("Lyr"), Target::Property(p)) if p == "Bckg" => background_from(d),
|
||||
@@ -1272,6 +1274,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
|
||||
let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t);
|
||||
Some(match id {
|
||||
"path.set" => return super::steps_path::to(p),
|
||||
super::steps_measurement::SET_SCALE => return super::steps_measurement::to(p),
|
||||
"select.all" => sel(enm("Ordn", "Al ")),
|
||||
"select.deselect" => sel(enm("Ordn", "None")),
|
||||
"select.reselect" => sel(enm("Ordn", "Prvs")),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Image › Analysis › Set Measurement Scale › Custom, recorded as a Set of the document's
|
||||
//! `measurementScale` property, as `docs/atn.md` ("Recorded through the menus") measures it:
|
||||
//! `T ` holds `pixelLength` (long), `logicalLength` (double) and `logicalUnits` (text) (#10723).
|
||||
|
||||
use super::descriptors::{class, desc, jstr, text};
|
||||
use super::fields::{Mapped, step};
|
||||
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
|
||||
|
||||
/// The native command the step plays as.
|
||||
pub(super) const SET_SCALE: &str = "image.analysis.setMeasurementScale";
|
||||
/// The recorded descriptor, kept when it differs from the one [`to`] would write (the class
|
||||
/// of `T `, key order or ID encodings), so a load and save keeps the step unchanged.
|
||||
const SOURCE: &str = "_atnMeasurementScaleDescriptor";
|
||||
|
||||
/// The custom scale the step sets.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Scale {
|
||||
pixels: i32,
|
||||
logical: f64,
|
||||
units: String,
|
||||
}
|
||||
|
||||
/// `null`: the `measurementScale` property of the target document.
|
||||
fn target() -> D {
|
||||
D::Reference(vec![
|
||||
ReferenceItem::Property { class: class("Prpr"), key: Id::new("measurementScale") },
|
||||
ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Trgt") },
|
||||
])
|
||||
}
|
||||
|
||||
/// Whether `d` has each of `keys` exactly once and nothing else.
|
||||
fn exactly(d: &Descriptor, keys: &[&str]) -> bool {
|
||||
d.items.len() == keys.len() && keys.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
|
||||
}
|
||||
|
||||
/// The measured form only. A length the native command refuses (not above 0, not finite) or
|
||||
/// blank units keep the step as recorded, so playback can't fail on a step that loaded.
|
||||
fn recorded(d: &Descriptor) -> Option<Scale> {
|
||||
if !exactly(d, &["null", "T "]) || d.get("null")? != &target() {
|
||||
return None;
|
||||
}
|
||||
let D::Descriptor(t) = d.get("T ")? else { return None };
|
||||
if !exactly(t, &["pixelLength", "logicalLength", "logicalUnits"]) {
|
||||
return None;
|
||||
}
|
||||
let (D::Integer(pixels), D::Double(logical), D::Text(units)) = (t.get("pixelLength")?, t.get("logicalLength")?, t.get("logicalUnits")?) else {
|
||||
return None;
|
||||
};
|
||||
let units = String::from_utf16(units.0.strip_suffix(&[0]).unwrap_or(&units.0)).ok()?;
|
||||
(*pixels > 0 && logical.is_finite() && *logical > 0.0 && !units.trim().is_empty()).then_some(Scale { pixels: *pixels, logical: *logical, units })
|
||||
}
|
||||
|
||||
/// The descriptor a step recorded in PhotoCraft saves as; `T ` takes the property's class.
|
||||
fn canonical(scale: &Scale) -> Descriptor {
|
||||
let t = desc("measurementScale")
|
||||
.with("pixelLength", D::Integer(scale.pixels))
|
||||
.with("logicalLength", D::Double(scale.logical))
|
||||
.with("logicalUnits", text(&scale.units));
|
||||
desc("null").with("null", target()).with("T ", D::Descriptor(t))
|
||||
}
|
||||
|
||||
/// Map the measured form; any other stays a complete kept step.
|
||||
pub(super) fn from(d: &Descriptor) -> Mapped {
|
||||
let Some(scale) = recorded(d) else { return Mapped::Unsupported { harmless: false } };
|
||||
let mut p = json!({"preset": "custom", "pixelLength": scale.pixels, "logicalLength": scale.logical, "units": scale.units});
|
||||
if *d != canonical(&scale) {
|
||||
p[SOURCE] = photocraft_paint::tile::b64_encode(&d.to_bytes()).into();
|
||||
}
|
||||
step(SET_SCALE, p)
|
||||
}
|
||||
|
||||
/// A native step the event holds: a custom scale with all three values given, a whole pixel
|
||||
/// length (the event stores a long) and units that aren't blank. A step that reads the scale
|
||||
/// (no params), resets it to the default or leaves a value to the document's current scale
|
||||
/// refuses export: the event can't say any of those.
|
||||
fn native(p: &Value) -> Option<Scale> {
|
||||
let m = p.as_object()?;
|
||||
if m.keys().any(|key| !["preset", "pixelLength", "logicalLength", "units", "logicalUnits", SOURCE].contains(&key.as_str())) {
|
||||
return None;
|
||||
}
|
||||
if !matches!(m.get("preset"), None | Some(Value::Null)) && jstr(p, "preset") != Some("custom") {
|
||||
return None;
|
||||
}
|
||||
let pixels = m.get("pixelLength")?.as_f64().filter(|v| v.is_finite() && *v > 0.0 && v.fract() == 0.0 && *v <= f64::from(i32::MAX))?;
|
||||
let logical = m.get("logicalLength")?.as_f64().filter(|v| v.is_finite() && *v > 0.0)?;
|
||||
// The native command reads `units`, else `logicalUnits`.
|
||||
let units = m.get("units").or_else(|| m.get("logicalUnits")).and_then(Value::as_str).filter(|u| !u.trim().is_empty())?;
|
||||
Some(Scale { pixels: pixels as i32, logical, units: units.to_string() })
|
||||
}
|
||||
|
||||
/// Save the recorded descriptor while the step still sets the same scale, otherwise the
|
||||
/// canonical form of the step's settings.
|
||||
pub(super) fn to(p: &Value) -> Option<Descriptor> {
|
||||
let scale = native(p)?;
|
||||
let source = jstr(p, SOURCE)
|
||||
.and_then(photocraft_paint::tile::b64_decode)
|
||||
.and_then(|bytes| Descriptor::from_bytes(&bytes).ok())
|
||||
.filter(|d| recorded(d).as_ref() == Some(&scale));
|
||||
Some(source.unwrap_or_else(|| canonical(&scale)))
|
||||
}
|
||||
@@ -742,6 +742,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})),
|
||||
("filter.blur.blur", json!({})),
|
||||
("file.automate.createDroplet", json!({"path": "/d/Sharpen.pcdroplet", "action": "Sharpen", "set": "Tools", "output": "/d/out", "overrideOpen": true})),
|
||||
("filter.distort.displace", json!({"horizontal":-999,"vertical":999,"fit":"stretch","undefinedAreas":"repeat","mapPath":LOCAL_MAP})),
|
||||
("filter.distort.shear", json!({"points": [[0,0], [0.5,0.25], [1,0]], "undefinedAreas": "repeat"})),
|
||||
("filter.distort.pinch", json!({"amount": -30})),
|
||||
("filter.distort.polarCoordinates", json!({"mode": "polarToRectangular"})),
|
||||
@@ -858,6 +859,7 @@ fn every_mapped_command_exports_and_imports_back() {
|
||||
("type.loadDefaultTypeStyles", json!({})),
|
||||
("type.saveDefaultTypeStyles", json!({})),
|
||||
("image.analysis.recordMeasurements", json!({})),
|
||||
("image.analysis.setMeasurementScale", json!({"preset": "custom", "pixelLength": 120, "logicalLength": 2.5, "units": "mm"})),
|
||||
("layer.videoLayers.insertBlankFrame", json!({})),
|
||||
("layer.videoLayers.duplicateFrame", json!({})),
|
||||
("layer.videoLayers.deleteFrame", json!({})),
|
||||
@@ -5634,6 +5636,216 @@ fn custom_outside_the_commands_range_is_kept_and_refused() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A local absolute map path on this platform: playback may read only such a path.
|
||||
#[cfg(windows)]
|
||||
const LOCAL_MAP: &str = r"C:\máp.psd";
|
||||
#[cfg(not(windows))]
|
||||
const LOCAL_MAP: &str = "/máp.psd";
|
||||
/// [`LOCAL_MAP`] as a `txtu` path, encoded independently of the production path writer.
|
||||
#[cfg(windows)]
|
||||
const LOCAL_MAP_WIRE: &[u8] = b"txtu\x22\0\0\0\x0b\0\0\0C\0:\0\\\0m\0\xe1\0p\0.\0p\0s\0d\0\0\0";
|
||||
#[cfg(not(windows))]
|
||||
const LOCAL_MAP_WIRE: &[u8] = b"txtu\x1e\0\0\0\x09\0\0\0/\0m\0\xe1\0p\0.\0p\0s\0d\0\0\0";
|
||||
|
||||
/// The selected-map descriptor measured in a Windows Displace recording.
|
||||
fn displace_descriptor(path: D) -> Descriptor {
|
||||
desc("null")
|
||||
.with("HrzS", D::Integer(10))
|
||||
.with("VrtS", D::Integer(10))
|
||||
.with("DspM", enm("DspM", "StrF"))
|
||||
.with("UndA", enm("UndA", "RptE"))
|
||||
.with("DspF", path)
|
||||
}
|
||||
|
||||
/// Preserve the typed path and materialize native defaults when saving sparse steps.
|
||||
#[test]
|
||||
fn recorded_displace_round_trips_text_event_and_typed_path() {
|
||||
let descriptor = displace_descriptor(D::Path(LOCAL_MAP_WIRE.to_vec()));
|
||||
let data = set_of("Maps", vec![("A", vec![item("displace", "Displace", Some(descriptor.clone()))])]);
|
||||
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
|
||||
assert!(imported.unsupported.is_empty());
|
||||
let (id, params) = &imported.actions[0].steps[0];
|
||||
assert_eq!(id, "filter.distort.displace");
|
||||
assert_eq!(*params, json!({"horizontal":10,"vertical":10,"fit":"stretch","undefinedAreas":"repeat","mapPath":LOCAL_MAP}));
|
||||
let exported = export_item(id, params, "Maps").unwrap();
|
||||
assert_eq!(exported.event, AtnEvent::name("displace"));
|
||||
assert_eq!(exported.descriptor, Some(descriptor.clone()));
|
||||
let minimal = json!({"mapPath":LOCAL_MAP});
|
||||
assert_eq!(export_item(id, &minimal, "Maps").unwrap().descriptor, Some(descriptor.clone()));
|
||||
for key in ["horizontal", "vertical", "fit", "undefinedAreas"] {
|
||||
let mut sparse = params.clone();
|
||||
sparse.as_object_mut().unwrap().remove(key);
|
||||
assert_eq!(export_item(id, &sparse, "Maps").unwrap().descriptor, Some(descriptor.clone()), "{key}");
|
||||
}
|
||||
let custom = json!({"horizontal":-7,"mapPath":LOCAL_MAP});
|
||||
let mut expected = descriptor;
|
||||
expected.items[0].1 = D::Integer(-7);
|
||||
assert_eq!(export_item(id, &custom, "Maps").unwrap().descriptor, Some(expected));
|
||||
}
|
||||
|
||||
/// A shared action file must not make playback open a network share, a device or a path taken
|
||||
/// from the working directory: such a map path stays a kept step with its bytes.
|
||||
#[test]
|
||||
fn displace_map_paths_a_document_may_not_name_stay_kept_steps() {
|
||||
let refused = [r"\\server\share\map.psd", "//server/share/map.psd", r"\\?\UNC\server\share\map.psd", r"\\.\COM1", "file://server/share/map.psd", "map.psd"];
|
||||
let cases: Vec<_> = refused.iter().map(|path| item("displace", "Displace", Some(displace_descriptor(path_value(path))))).collect();
|
||||
let data = set_of("Maps", vec![("A", cases.clone())]);
|
||||
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
|
||||
assert_eq!(imported.unsupported.len(), cases.len());
|
||||
for ((id, params), expected) in imported.actions[0].steps.iter().zip(cases) {
|
||||
assert_eq!(id, PHOTOSHOP_STEP, "{params}");
|
||||
assert_eq!(export_item(id, params, "Maps").unwrap().descriptor, expected.descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep unmeasured inputs intact and refuse settings that would be changed by export.
|
||||
#[test]
|
||||
fn unmeasured_displace_stays_opaque_and_lossy_export_is_refused() {
|
||||
assert!(!super::menu_item_commands().contains(&"filter.distort.displace"));
|
||||
let path = r"C:\map.psd";
|
||||
let wire = path_value(path);
|
||||
let descriptor = displace_descriptor(wire.clone());
|
||||
let replace = |key: &str, value: D| {
|
||||
let mut d = descriptor.clone();
|
||||
d.items.iter_mut().find(|(id, _)| id.is(key)).unwrap().1 = value;
|
||||
d
|
||||
};
|
||||
let alter_path = |i: usize, pair: [u8; 2]| {
|
||||
let D::Path(mut bytes) = wire.clone() else { panic!("expected a path") };
|
||||
bytes[i..i + 2].copy_from_slice(&pair);
|
||||
D::Path(bytes)
|
||||
};
|
||||
let mut without_path = descriptor.clone();
|
||||
without_path.items.retain(|(id, _)| !id.is("DspF"));
|
||||
let mut string_key = descriptor.clone();
|
||||
string_key.items[0].0 = Id::Str(b"HrzS".to_vec());
|
||||
let cases = [
|
||||
item("displace", "Displace", None),
|
||||
item("displace", "Displace", Some(without_path)),
|
||||
item("displace", "Displace", Some(string_key)),
|
||||
item("displace", "Displace", Some(descriptor.clone().with("HrzS", D::Integer(10)))),
|
||||
item("displace", "Displace", Some(replace("HrzS", D::Double(10.0)))),
|
||||
item("displace", "Displace", Some(replace("VrtS", D::Integer(1000)))),
|
||||
item("displace", "Displace", Some(replace("DspM", enm("DspM", "Tile")))),
|
||||
item("displace", "Displace", Some(replace("UndA", enm("OtherEnum", "RptE")))),
|
||||
item("displace", "Displace", Some(replace("DspF", D::Alias(vec![1, 2, 3])))),
|
||||
item("displace", "Displace", Some(replace("DspF", alter_path(4, [0, 0])))),
|
||||
item("displace", "Displace", Some(replace("DspF", alter_path(8, [9, 0])))),
|
||||
item("displace", "Displace", Some(replace("DspF", alter_path(12, [0, 0])))),
|
||||
item("displace", "Displace", Some(replace("DspF", alter_path(12, [0, 0xd8])))),
|
||||
item("Dspl", "Displace", Some(descriptor)),
|
||||
];
|
||||
let data = set_of("Maps", vec![("A", cases.to_vec())]);
|
||||
let imported = import(&photocraft_paint::tile::b64_decode(&data).unwrap()).unwrap();
|
||||
assert_eq!(imported.unsupported.len(), cases.len());
|
||||
assert_eq!(imported.actions[0].steps.len(), cases.len());
|
||||
for ((id, params), expected) in imported.actions[0].steps.iter().zip(cases) {
|
||||
assert_eq!(id, PHOTOSHOP_STEP);
|
||||
let exported = export_item(id, params, "Maps").unwrap();
|
||||
assert_eq!(exported.event, expected.event);
|
||||
assert_eq!(exported.descriptor, expected.descriptor);
|
||||
}
|
||||
|
||||
let good = json!({"horizontal":10,"vertical":10,"fit":"stretch","undefinedAreas":"repeat","mapPath":path});
|
||||
for (key, value) in [
|
||||
("horizontal", json!(10.5)),
|
||||
("horizontal", json!(1000)),
|
||||
("vertical", json!(-1000)),
|
||||
("vertical", json!("10")),
|
||||
("fit", json!("tile")),
|
||||
("fit", Value::Null),
|
||||
("undefinedAreas", json!("wrap")),
|
||||
("undefinedAreas", json!(false)),
|
||||
("mapPath", json!("x\0y.psd")),
|
||||
("mapPath", json!("")),
|
||||
("mapLayer", json!(1)),
|
||||
("mapDocument", json!(0)),
|
||||
("target", json!("mask")),
|
||||
] {
|
||||
let mut params = good.clone();
|
||||
params[key] = value;
|
||||
assert!(export_item("filter.distort.displace", ¶ms, "Maps").is_none(), "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading needs no map; playback reads it only after authorization and stretches it.
|
||||
#[test]
|
||||
fn recorded_displace_loads_without_map_and_plays_through_the_step_gate() {
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("synthetic-map.psd");
|
||||
let wire = displace_descriptor(path_value(path.to_str().unwrap()));
|
||||
let data = set_of("Maps", vec![("A", vec![item("displace", "Displace", Some(wire))])]);
|
||||
let mut source = Session::new();
|
||||
let loaded = source.execute("actions.load", json!({"data":data})).unwrap();
|
||||
assert_eq!(loaded["unsupported"], json!([]));
|
||||
assert!(!path.exists(), "loading the ATN needs no map");
|
||||
|
||||
let paint_split = |s: &mut Session, side: i32, split: i32, right: [f32; 4]| {
|
||||
s.execute("file.new", json!({"width":side,"height":side})).unwrap();
|
||||
s.edit("Two-tone fixture", |doc, _| {
|
||||
let id = doc.layers[0].id;
|
||||
let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
surface.fill_rect(Rect::new(0, 0, split, side), &[0.0, 0.0, 0.0, 1.0]);
|
||||
surface.fill_rect(Rect::new(split, 0, side, side), &right);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
// Offset the source edge so the positive shift crosses it within the white part of the map.
|
||||
paint_split(&mut source, 64, 48, [1.0, 0.0, 0.0, 1.0]);
|
||||
source
|
||||
.edit("Bottom edge fixture", |doc, _| {
|
||||
let id = doc.layers[0].id;
|
||||
let surface = doc.layer_mut(id).unwrap().surface_mut().unwrap();
|
||||
surface.fill_rect(Rect::new(0, 48, 64, 64), &[0.0, 1.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let snapshot = |s: &Session| {
|
||||
let active = s.active().unwrap();
|
||||
(active.revision, active.history.past_len(), photocraft_compose::render(&active.doc, active.doc.bounds()).into_px())
|
||||
};
|
||||
let before = snapshot(&source);
|
||||
let missing = source.execute("actions.play", json!({"action":"A"})).unwrap();
|
||||
assert_eq!(missing["ran"], 0);
|
||||
assert_eq!(missing["failed"]["id"], "filter.distort.displace");
|
||||
assert!(missing["failed"]["error"].as_str().unwrap().contains("cannot read displacement map"));
|
||||
assert_eq!(snapshot(&source), before);
|
||||
|
||||
// Leave the map absent so a read before authorization would report the same I/O failure.
|
||||
source.authorize = Some(|id, p| {
|
||||
if id == "filter.distort.displace" && p["mapPath"].is_string() {
|
||||
return Err(EngineError::Other("test denied the map path".into()));
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let denied = source.execute("actions.play", json!({"action":"A"})).unwrap();
|
||||
assert_eq!(denied["ran"], 0);
|
||||
assert_eq!(denied["failed"]["id"], "filter.distort.displace");
|
||||
assert!(denied["failed"]["error"].as_str().unwrap().contains("test denied the map path"));
|
||||
assert_eq!(snapshot(&source), before);
|
||||
|
||||
source.authorize = None;
|
||||
let mut map = Session::new();
|
||||
paint_split(&mut map, 16, 8, [1.0, 1.0, 1.0, 1.0]);
|
||||
let encoded = photocraft_io::export(&map.active().unwrap().doc, "psd", &Default::default()).unwrap();
|
||||
std::fs::write(&path, encoded.bytes).unwrap();
|
||||
let played = source.execute("actions.play", json!({"action":"A"})).unwrap();
|
||||
assert_eq!(played["ran"], 1, "{played}");
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
let pixels = snapshot(&source).2;
|
||||
// With stretch, x=38 samples the white half and shifts into red; tiling samples black.
|
||||
assert_eq!(before.2[32 * 64 + 38], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixels[32 * 64 + 38], [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixels[32 * 64 + 20], [0.0, 0.0, 0.0, 1.0]);
|
||||
// The vertical shift crosses into green, then repeats the bottom edge instead of wrapping to red.
|
||||
assert_eq!(before.2[40 * 64 + 38], [0.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixels[40 * 64 + 38], [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(pixels[60 * 64 + 38], [0.0, 1.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// The step `d` of Photoshop event `code`, as PhotoCraft loads it.
|
||||
fn distort(code: &str, d: Descriptor) -> Mapped {
|
||||
let event = AtnEvent::Code(code.as_bytes().try_into().unwrap());
|
||||
|
||||
@@ -348,7 +348,11 @@ fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&St
|
||||
/// The Pencil's brush: the session brush made aliased (every pixel fully painted or untouched,
|
||||
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
|
||||
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
pencil_shape(with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p), p)
|
||||
}
|
||||
|
||||
/// `brush` with the Pencil's hard, aliased tip, or the Eraser's Block (`"block": true`).
|
||||
pub(crate) fn pencil_shape(mut brush: BrushSettings, p: &Value) -> Result<BrushSettings> {
|
||||
if flag(p, "block", false) {
|
||||
return block_brush(brush, p);
|
||||
}
|
||||
@@ -861,7 +865,8 @@ fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSet
|
||||
mode: parse(p, cmd, "mode")?.unwrap_or(ReplaceMode::Color),
|
||||
sampling: parse(p, cmd, "sampling")?.unwrap_or(Sampling::Continuous),
|
||||
limits: parse(p, cmd, "limits")?.unwrap_or(Limits::Contiguous),
|
||||
tolerance: pct(p, "tolerance", 30.0),
|
||||
// 1..100 %, as the reference's field takes it: 0 and below run as 1 (#11930).
|
||||
tolerance: pct(p, "tolerance", 30.0).max(0.01),
|
||||
anti_alias: flag(p, "antiAlias", true),
|
||||
color: brush.color,
|
||||
background: s.tools.background,
|
||||
@@ -1361,7 +1366,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.colorReplacement",
|
||||
"Color Replacement Tool",
|
||||
r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":1..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##,
|
||||
crate::commands::has_paintable,
|
||||
color_replacement,
|
||||
true
|
||||
|
||||
@@ -169,6 +169,7 @@ impl LiveMixer {
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels,
|
||||
clean_after_stroke: flag(p, "cleanAfterStroke", bm.clean_after_stroke),
|
||||
load_after_stroke: flag(p, "loadAfterStroke", bm.load_after_stroke),
|
||||
solid_colors_only: bm.solid_colors_only,
|
||||
};
|
||||
let initial_state = if let Some(value) = p.get("mixerState") {
|
||||
if value.get("spatial").and_then(Value::as_array).is_some_and(|v| v.len() != 4096) {
|
||||
|
||||
@@ -282,7 +282,7 @@ fn mixer_large_tip_retains_off_canvas_paint_and_commits_the_live_result() {
|
||||
#[test]
|
||||
fn mixer_long_segments_are_interpolated_and_replay_matches_live() {
|
||||
let mut s = fixture(PixelFormat::RGBA8);
|
||||
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000"});
|
||||
let p = json!({"points":[[20,48],[5020,48]],"seed":71,"size":2,"sampleAllLayers":false,"wet":0,"mix":0,"load":100,"color":"#ff0000","brush":{"smoothing":{"catchUpOnEnd":true}}});
|
||||
let live = LiveMixer::begin(&s, &p).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let expected = live.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
|
||||
@@ -985,7 +985,11 @@ fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
|
||||
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
|
||||
s.execute(
|
||||
"tools.setBrush",
|
||||
json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}, "smoothing": {"catchUpOnEnd": true}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
|
||||
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
|
||||
// A param still overrides the brush.
|
||||
|
||||
@@ -135,7 +135,7 @@ pub(crate) use targeting::{
|
||||
Target, channel_surface_for_filter, clear_channel, clear_color_channel, color_restriction, edits_channel, filter_mask_layer, inject_target, invert_region,
|
||||
is_channel_target, is_spot_target, mask_target_enabled, restrict_to_color, routed, target_of, target_surface, targeted_channel,
|
||||
};
|
||||
pub use targeting::{follows_filter_mask, follows_target, read_target_surface};
|
||||
pub use targeting::{follows_filter_mask, follows_target, follows_vector_mask_target, read_target_surface};
|
||||
pub(crate) use view::fix_view;
|
||||
pub use view::{ChannelTarget, ChannelView, QuickMaskOptions, color_names, composite_name};
|
||||
|
||||
|
||||
@@ -167,6 +167,18 @@ pub fn follows_filter_mask(id: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Edit › Transform's Rotate/Flip presets can turn a targeted vector mask's path (#12283).
|
||||
pub fn follows_vector_mask_target(id: &str) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
"edit.transform.rotate180"
|
||||
| "edit.transform.rotate90Cw"
|
||||
| "edit.transform.rotate90Ccw"
|
||||
| "edit.transform.flipHorizontal"
|
||||
| "edit.transform.flipVertical"
|
||||
)
|
||||
}
|
||||
|
||||
/// The paint and retouch tools that follow the targeted channel or mask.
|
||||
const PAINT_IDS: &[&str] = &[
|
||||
"paint.stroke",
|
||||
@@ -251,6 +263,8 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
json!("filterMask")
|
||||
}
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
|
||||
// Edit › Transform's Rotate/Flip presets turn the targeted vector mask's path (#12283).
|
||||
ChannelTarget::VectorMask if follows_vector_mask_target(id) => json!("vectorMask"),
|
||||
_ => return params,
|
||||
};
|
||||
match params {
|
||||
|
||||
@@ -142,6 +142,8 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet
|
||||
return Err(bad(CMD, "`points` must be finite canvas coordinates"));
|
||||
}
|
||||
let brush = crate::brush_cmds::resolve_brush(s, p, CMD)?;
|
||||
// The inks a colour was picked as, for a CMYK document (#12352).
|
||||
let picked = |c: [f32; 4]| s.tools.picked_cmyk([c[0], c[1], c[2]]).map(|k| ([c[0], c[1], c[2]], k));
|
||||
let bs = BgEraseSettings {
|
||||
sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?,
|
||||
limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?,
|
||||
@@ -149,6 +151,8 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet
|
||||
protect_foreground: flag(p, "protectForegroundColor", false),
|
||||
foreground: s.tools.foreground,
|
||||
background: s.tools.background,
|
||||
foreground_cmyk: picked(s.tools.foreground),
|
||||
background_cmyk: picked(s.tools.background),
|
||||
};
|
||||
let id = active_pixel_layer(s, p, CMD)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
|
||||
@@ -215,6 +215,55 @@ fn background_eraser_options() {
|
||||
assert_eq!(alpha(&s, 6, 20), 1.0);
|
||||
}
|
||||
|
||||
/// #12352: in a CMYK document, Protect Foreground Color and Sampling Background Swatch compare
|
||||
/// against the inks the colour was picked as, not what its sRGB stand-in converts back to.
|
||||
#[test]
|
||||
fn background_eraser_compares_against_picked_cmyk_inks() {
|
||||
const STAND_IN: &str = "#00aeef";
|
||||
const CYAN: [f32; 4] = [1.0, 0.0, 0.0, 0.0];
|
||||
let cyan_square = |key: &str| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "mode": "cmyk", "depth": 8})).unwrap();
|
||||
s.execute("tools.setColors", json!({key: STAND_IN, format!("{key}Cmyk"): CYAN})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": STAND_IN})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
assert_eq!(surface(&s).pixel(20, 20)[..4], CYAN, "the square holds the picked inks");
|
||||
s
|
||||
};
|
||||
let stroke = |extra: Value| {
|
||||
let mut p = json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "limits": "discontiguous", "tolerance": 1});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let mut s = cyan_square("foreground");
|
||||
s.execute("paint.backgroundEraser", stroke(json!({"sampling": "continuous", "protectForegroundColor": true}))).unwrap();
|
||||
assert_eq!(alpha(&s, 20, 20), 1.0, "the foreground's inks are protected");
|
||||
assert_eq!(alpha(&s, 6, 20), 0.0, "white erased");
|
||||
let mut s = cyan_square("background");
|
||||
s.execute("paint.backgroundEraser", stroke(json!({"sampling": "backgroundSwatch"}))).unwrap();
|
||||
assert_eq!(alpha(&s, 20, 20), 0.0, "the background's inks are erased fully");
|
||||
assert_eq!(alpha(&s, 6, 20), 1.0, "white kept");
|
||||
// Other modes ignore the picked inks: the stand-in is compared as it always was.
|
||||
for mode in ["rgb", "gray", "lab"] {
|
||||
let run = |cmyk: bool| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": 8})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "antiAlias": false})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": STAND_IN})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let picks = if cmyk { json!({"foregroundCmyk": CYAN, "backgroundCmyk": CYAN}) } else { json!({}) };
|
||||
let mut colors = json!({"foreground": STAND_IN, "background": STAND_IN});
|
||||
colors.as_object_mut().unwrap().extend(picks.as_object().unwrap().clone());
|
||||
s.execute("tools.setColors", colors).unwrap();
|
||||
s.execute("paint.backgroundEraser", stroke(json!({"sampling": "continuous", "protectForegroundColor": true}))).unwrap();
|
||||
s.execute("paint.backgroundEraser", stroke(json!({"points": [[4, 30], [40, 30]], "sampling": "backgroundSwatch"}))).unwrap();
|
||||
surface(&s).clone()
|
||||
};
|
||||
assert!(run(true) == run(false), "{mode}: a CMYK pick changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_eraser_fails_gracefully() {
|
||||
let mut s = session("rgb", 8);
|
||||
|
||||
@@ -274,13 +274,43 @@ fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------- transform presets ----------
|
||||
|
||||
/// The presets' params: like Free Transform, they follow the Channels and Layers panel target.
|
||||
const TRANSFORM_TARGET: &str =
|
||||
r#"{"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask turns alone)}"#;
|
||||
const TRANSFORM_TARGET: &str = r#"{"target":"pixels"|"mask"|"vectorMask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, a vector mask, an alpha channel or the Quick Mask turns alone)}"#;
|
||||
|
||||
/// The active layer's vector mask when the target names it, with its path's control bounds.
|
||||
/// The path tools' Free Transform and `path.transform` reuse those bounds, so a flip mirrors
|
||||
/// anchors and Bézier handles about the same box (#12283).
|
||||
fn targeted_vector_mask(s: &Session, p: &Value) -> Result<Option<(LayerId, [f64; 4])>> {
|
||||
let explicit = p.get("target").and_then(Value::as_str) == Some("vectorMask");
|
||||
let panel = p.get("target").is_none() && s.active().is_some_and(|d| d.channel_view.target == crate::channel_cmds::ChannelTarget::VectorMask);
|
||||
if !explicit && !panel {
|
||||
return Ok(None);
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let vm = l.vector_mask.as_ref().ok_or_else(|| EngineError::Other("the active layer has no vector mask".into()))?;
|
||||
let (x0, y0, x1, y1) = vm.path.control_bounds().ok_or_else(|| EngineError::Other("nothing to transform".into()))?;
|
||||
Ok(Some((id, [x0, y0, x1, y1])))
|
||||
}
|
||||
|
||||
/// Flips and turns about the centre of the box Free Transform would open: with a selection
|
||||
/// limiting the transform it is the selection's own bounds, as in Photoshop, so Select All
|
||||
/// mirrors across the canvas (#6928); otherwise the content's bounds.
|
||||
fn transform_preset(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
// A targeted vector mask turns its own path, not the layer's pixels (and whether or not the
|
||||
// mask is linked): reuse `path.transform` for its geometry and History step.
|
||||
if let Some((id, [x0, y0, x1, y1])) = targeted_vector_mask(s, p)? {
|
||||
let (sx, sy) = (x0 + x1, y0 + y1);
|
||||
let (cx, cy) = (sx / 2.0, sy / 2.0);
|
||||
let m: [f64; 6] = match kind {
|
||||
"rotate180" => [-1.0, 0.0, 0.0, -1.0, sx, sy],
|
||||
"rotate90Cw" => [0.0, 1.0, -1.0, 0.0, cx + cy, cy - cx],
|
||||
"rotate90Ccw" => [0.0, -1.0, 1.0, 0.0, cx - cy, cx + cy],
|
||||
"flipHorizontal" => [-1.0, 0.0, 0.0, 1.0, sx, 0.0],
|
||||
_ => [1.0, 0.0, 0.0, -1.0, 0.0, sy],
|
||||
};
|
||||
return s.execute("path.transform", json!({"name": "layer", "layer": id.0, "vectorMask": true, "matrix": m}));
|
||||
}
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
// A targeted alpha channel, the Quick Mask or an unlinked layer mask turns by itself (#2835).
|
||||
let (b, mut params) = match crate::transform_cmds::lone_target(&d.doc, d.active_layer, p)? {
|
||||
@@ -377,7 +407,6 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||
let key = step_key(s, "pasteInto");
|
||||
params["coalesce"] = json!(key);
|
||||
params["target"] = json!("pixels");
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let r = s.execute("edit.paste", params)?;
|
||||
let id = layer_param(s, &Value::Null)?;
|
||||
s.execute("layer.layerMask.revealAll", json!({"layer": id.0, "coalesce": key}))?;
|
||||
@@ -388,9 +417,9 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||
Ok(())
|
||||
});
|
||||
s.coalesce_request = None;
|
||||
// Three revisions: `Session::dispatch` reports what the whole command changed, so only where
|
||||
// the masked paste draws is recomposited (#2901).
|
||||
r2?;
|
||||
// Only where the masked paste draws needs recompositing (#2901).
|
||||
crate::layer_multi_cmds::note_insert(s, &before);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
|
||||
@@ -465,6 +465,251 @@ fn transform_presets_turn_about_the_selection() {
|
||||
assert_eq!(s.active().unwrap().doc.channels[0].surface.content_bounds(), Rect::new(20, 10, 30, 20));
|
||||
}
|
||||
|
||||
/// A two-knot path whose Bézier handles reach past its anchors on every side, so a flip about
|
||||
/// the anchors' bounds and one about the control bounds differ.
|
||||
fn asymmetric_vector_path() -> photocraft_doc::Path {
|
||||
use photocraft_doc::{Knot, Path, Subpath};
|
||||
use photocraft_geom::Point;
|
||||
Path::new(vec![Subpath {
|
||||
closed: false,
|
||||
knots: vec![
|
||||
Knot { anchor: Point::new(10.0, 20.0), in_ctrl: Point::new(8.0, 18.0), out_ctrl: Point::new(12.0, 22.0), smooth: false },
|
||||
Knot { anchor: Point::new(30.0, 30.0), in_ctrl: Point::new(28.0, 28.0), out_ctrl: Point::new(34.0, 34.0), smooth: false },
|
||||
],
|
||||
op: photocraft_doc::PathOp::Combine,
|
||||
}])
|
||||
}
|
||||
|
||||
fn vector_mask_session(linked: bool, path: photocraft_doc::Path) -> (Session, LayerId) {
|
||||
let mut s = session(8);
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("vector mask", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().vector_mask = Some(photocraft_doc::VectorMask { linked, ..photocraft_doc::VectorMask::new(path) });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn mask_path(s: &Session, id: LayerId) -> photocraft_doc::Path {
|
||||
s.active().unwrap().doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path.clone()
|
||||
}
|
||||
|
||||
/// #12283: a targeted vector mask flips its own path about the path's control bounds (anchors and
|
||||
/// handles), on a layer with no pixels, whether the mask is linked or unlinked.
|
||||
#[test]
|
||||
fn transform_presets_flip_a_targeted_vector_mask_about_its_control_bounds() {
|
||||
use photocraft_geom::Point;
|
||||
let xy = |p: Point| (p.x, p.y);
|
||||
for linked in [true, false] {
|
||||
let (mut s, id) = vector_mask_session(linked, asymmetric_vector_path());
|
||||
let before = mask_path(&s, id);
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "vectorMask"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one Transform Path step, linked {linked}");
|
||||
let flipped = mask_path(&s, id);
|
||||
let k = &flipped.subpaths[0].knots;
|
||||
// Bounds are x 8..34, so x' = 42 - x.
|
||||
assert_eq!(xy(k[0].anchor), (32.0, 20.0));
|
||||
assert_eq!(xy(k[0].in_ctrl), (34.0, 18.0));
|
||||
assert_eq!(xy(k[0].out_ctrl), (30.0, 22.0));
|
||||
assert_eq!(xy(k[1].anchor), (12.0, 30.0));
|
||||
assert_eq!(xy(k[1].in_ctrl), (14.0, 28.0));
|
||||
assert_eq!(xy(k[1].out_ctrl), (8.0, 34.0));
|
||||
assert!(s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().content_bounds().is_empty(), "the empty pixel layer stays empty");
|
||||
// Undo restores the path exactly, redo puts the flip back.
|
||||
s.undo();
|
||||
assert_eq!(mask_path(&s, id), before, "undo");
|
||||
s.redo();
|
||||
assert_eq!(mask_path(&s, id), flipped, "redo");
|
||||
s.undo();
|
||||
// Bounds are y 18..34, so y' = 52 - y.
|
||||
s.execute("edit.transform.flipVertical", json!({"target": "vectorMask"})).unwrap();
|
||||
let k = &mask_path(&s, id).subpaths[0].knots;
|
||||
assert_eq!(xy(k[0].anchor), (10.0, 32.0));
|
||||
assert_eq!(xy(k[0].in_ctrl), (8.0, 34.0));
|
||||
assert_eq!(xy(k[0].out_ctrl), (12.0, 30.0));
|
||||
assert_eq!(xy(k[1].anchor), (30.0, 22.0));
|
||||
assert_eq!(xy(k[1].in_ctrl), (28.0, 24.0));
|
||||
assert_eq!(xy(k[1].out_ctrl), (34.0, 18.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Fractional path centers must not be rounded to the pixel grid, and clockwise and
|
||||
/// counterclockwise presets must move every anchor and handle in the expected direction.
|
||||
#[test]
|
||||
fn transform_presets_rotate_vector_masks_about_fractional_control_bounds() {
|
||||
let path = asymmetric_vector_path().transform(&photocraft_geom::Affine::translate(0.25, 0.5));
|
||||
assert_eq!(path.control_bounds(), Some((8.25, 18.5, 34.25, 34.5)));
|
||||
let cases = [
|
||||
("rotate180", [[(32.25, 32.5), (34.25, 34.5), (30.25, 30.5)], [(12.25, 22.5), (14.25, 24.5), (8.25, 18.5)]]),
|
||||
("rotate90Cw", [[(27.25, 15.5), (29.25, 13.5), (25.25, 17.5)], [(17.25, 35.5), (19.25, 33.5), (13.25, 39.5)]]),
|
||||
("rotate90Ccw", [[(15.25, 37.5), (13.25, 39.5), (17.25, 35.5)], [(25.25, 17.5), (23.25, 19.5), (29.25, 13.5)]]),
|
||||
];
|
||||
for linked in [true, false] {
|
||||
for (kind, expected) in cases {
|
||||
let (mut s, id) = vector_mask_session(linked, path.clone());
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
s.execute(&format!("edit.transform.{kind}"), json!({"target": "vectorMask"})).unwrap();
|
||||
let after = mask_path(&s, id);
|
||||
let actual: Vec<_> =
|
||||
after.subpaths[0].knots.iter().map(|k| [(k.anchor.x, k.anchor.y), (k.in_ctrl.x, k.in_ctrl.y), (k.out_ctrl.x, k.out_ctrl.y)]).collect();
|
||||
assert_eq!(actual, expected, "{kind}, linked {linked}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one history step");
|
||||
s.undo();
|
||||
assert_eq!(mask_path(&s, id), path, "undo {kind}");
|
||||
s.redo();
|
||||
assert_eq!(mask_path(&s, id), after, "redo {kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit path route must not let presets or direct path commands edit a vector mask
|
||||
/// through Lock All, including when that lock comes from a containing group.
|
||||
#[test]
|
||||
fn targeted_vector_mask_transforms_respect_layer_and_group_lock_all() {
|
||||
for inherited in [false, true] {
|
||||
let (mut s, id) = vector_mask_session(true, asymmetric_vector_path());
|
||||
let lock_id = if inherited {
|
||||
let group = LayerId(s.execute("layer.groupLayers", json!({"layer": id.0})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layer": id.0})).unwrap();
|
||||
group
|
||||
} else {
|
||||
id
|
||||
};
|
||||
s.edit("Lock All", |doc, _| {
|
||||
doc.layer_mut(lock_id).unwrap().locks.all = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
for kind in ["flipHorizontal", "flipVertical", "rotate180", "rotate90Cw", "rotate90Ccw"] {
|
||||
let err = s.execute(&format!("edit.transform.{kind}"), json!({"target": "vectorMask"})).unwrap_err();
|
||||
assert!(err.to_string().contains("locked"), "{kind}: {err}");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
for vector_mask in [true, false] {
|
||||
let err = s.execute("path.transform", json!({"name": "layer", "layer": id.0, "vectorMask": vector_mask, "translateX": 1})).unwrap_err();
|
||||
assert!(err.to_string().contains("locked"), "{err}");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
}
|
||||
|
||||
s.edit("Lock Pixels Only", |doc, _| {
|
||||
let locks = &mut doc.layer_mut(lock_id).unwrap().locks;
|
||||
locks.all = false;
|
||||
locks.pixels = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "vectorMask"})).unwrap();
|
||||
assert_ne!(mask_path(&s, id), asymmetric_vector_path(), "pixel-only locks permit mask edits");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12283: a shape layer's path is its content, but it can also carry a separate vector mask. A
|
||||
/// targeted flip moves only the mask; the shape's path and rendered cache stay as they were.
|
||||
#[test]
|
||||
fn a_targeted_vector_mask_on_a_shape_layer_leaves_the_shape_path_and_cache_alone() {
|
||||
let mut s = session(8);
|
||||
let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [2, 2, 20, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("vector mask", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().vector_mask = Some(photocraft_doc::VectorMask::new(asymmetric_vector_path()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let (shape_path, shape_cache) = match &doc(&s).layer(id).unwrap().content {
|
||||
LayerContent::Shape(sh) => (sh.path.clone(), sh.cache.clone()),
|
||||
_ => panic!("a shape layer"),
|
||||
};
|
||||
let mask_before = mask_path(&s, id);
|
||||
assert!(shape_cache.is_some(), "the shape is rendered");
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "vectorMask"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one Transform Path step");
|
||||
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("still a shape layer") };
|
||||
assert_eq!(sh.path, shape_path, "the shape's path is untouched");
|
||||
assert_eq!(sh.cache, shape_cache, "the shape's cache is untouched");
|
||||
let mask_after = mask_path(&s, id);
|
||||
assert_ne!(mask_after, mask_before, "the vector mask moved");
|
||||
assert_eq!((mask_after.subpaths[0].knots[0].anchor.x, mask_after.subpaths[0].knots[0].anchor.y), (32.0, 20.0), "about the mask's bounds");
|
||||
|
||||
s.undo();
|
||||
assert_eq!(mask_path(&s, id), mask_before, "undo restores the mask");
|
||||
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("still a shape layer") };
|
||||
assert_eq!((sh.path.clone(), sh.cache.clone()), (shape_path, shape_cache), "and leaves the shape alone");
|
||||
s.redo();
|
||||
assert_eq!(mask_path(&s, id), mask_after, "redo flips the mask again");
|
||||
}
|
||||
|
||||
/// The Channels panel's vector-mask target reaches the presets through the engine's target
|
||||
/// injection, without an explicit `target` param.
|
||||
#[test]
|
||||
fn the_channels_panel_vector_mask_target_routes_the_presets() {
|
||||
let (mut s, id) = vector_mask_session(true, asymmetric_vector_path());
|
||||
s.execute("channel.target", json!({"channel": "vectorMask"})).unwrap();
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
let k = &mask_path(&s, id).subpaths[0].knots;
|
||||
assert_eq!((k[0].anchor.x, k[0].anchor.y), (32.0, 20.0));
|
||||
}
|
||||
|
||||
/// A targeted vector mask turns alone: the layer's pixels do not move, even when it has some.
|
||||
#[test]
|
||||
fn a_targeted_vector_mask_flip_leaves_layer_pixels_alone() {
|
||||
let mut s = session(8);
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
paint_square(&mut s, Rect::new(2, 2, 6, 6), [1.0, 0.0, 0.0, 1.0]);
|
||||
s.edit("vector mask", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().vector_mask = Some(photocraft_doc::VectorMask::new(asymmetric_vector_path()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30));
|
||||
s.execute("edit.transform.flipHorizontal", json!({"target": "vectorMask"})).unwrap();
|
||||
let after = active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30));
|
||||
assert_eq!(after, before, "the pixels are untouched");
|
||||
assert_eq!(pixel(&s, 2, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
/// A whole-layer preset still carries a linked vector mask and leaves an unlinked one in place.
|
||||
#[test]
|
||||
fn a_whole_layer_flip_carries_only_a_linked_vector_mask() {
|
||||
for linked in [true, false] {
|
||||
let mut s = session(8);
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
paint_square(&mut s, Rect::new(2, 2, 6, 6), [1.0, 0.0, 0.0, 1.0]);
|
||||
s.edit("vector mask", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().vector_mask = Some(photocraft_doc::VectorMask { linked, ..photocraft_doc::VectorMask::new(asymmetric_vector_path()) });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = mask_path(&s, id);
|
||||
s.execute("edit.transform.flipHorizontal", json!({})).unwrap();
|
||||
let after = mask_path(&s, id);
|
||||
if linked {
|
||||
assert_ne!(after, before, "a linked vector mask moves with its layer");
|
||||
} else {
|
||||
assert_eq!(after, before, "an unlinked vector mask stays put");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty targeted vector mask is refused before anything is recorded, so history is unchanged.
|
||||
#[test]
|
||||
fn an_empty_targeted_vector_mask_refuses_without_history() {
|
||||
let (mut s, id) = vector_mask_session(true, photocraft_doc::Path::default());
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let past = s.active().unwrap().history.past_len();
|
||||
let err = s.execute("edit.transform.flipHorizontal", json!({"target": "vectorMask"})).unwrap_err();
|
||||
assert!(err.to_string().contains("nothing to transform"), "{err}");
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), past);
|
||||
assert!(s.active().unwrap().doc.layer(id).unwrap().vector_mask.as_ref().unwrap().path.subpaths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reselect_restores_the_last_selection() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -44,6 +44,16 @@ pub struct CutParts {
|
||||
}
|
||||
|
||||
impl CutParts {
|
||||
/// Selected pixels in source-document coordinates, including soft coverage.
|
||||
pub(crate) fn selected_pixels(&self) -> &Surface {
|
||||
&self.piece
|
||||
}
|
||||
|
||||
/// Whether the floating piece contains a layer mask rather than layer pixels.
|
||||
pub(crate) fn is_mask(&self) -> bool {
|
||||
self.mask
|
||||
}
|
||||
|
||||
/// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer
|
||||
/// too (the piece is a duplicate). Without, they leave a transparent hole, or on the Background,
|
||||
/// which can't hold transparency, one filled with `background` (Photoshop). Errors without a
|
||||
@@ -340,7 +350,7 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
||||
|
||||
/// Commands that leave a floating piece floating: its own, and ones that don't touch documents.
|
||||
fn keeps_floating(id: &str) -> bool {
|
||||
matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|
||||
matches!(id, "select.float" | "select.drop" | "layer.copyToDocument" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean")
|
||||
|| ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p))
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (from, to) = (&s.documents()[src], &s.documents()[dest]);
|
||||
let (sdoc, ddoc) = (from.doc.clone(), to.doc.clone());
|
||||
let floating = crate::float_cmds::floating(from).cloned();
|
||||
let ids: Vec<LayerId> = match p.get("layers") {
|
||||
None => from.selected_layers(),
|
||||
Some(v) => v
|
||||
@@ -236,6 +237,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad("no layers to copy"));
|
||||
}
|
||||
let selection_only = match p.get("selectionOnly") {
|
||||
None => false,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(_) => return Err(bad("`selectionOnly` must be a boolean")),
|
||||
};
|
||||
if selection_only && (ids.len() != 1 || sdoc.selection.is_none()) {
|
||||
return Err(bad("copying selected pixels requires a selection and one pixel layer"));
|
||||
}
|
||||
for (doc, role) in [(&sdoc, "source"), (&ddoc, "destination")] {
|
||||
if !layered(doc.mode) {
|
||||
return Err(EngineError::Other(format!("layers can't be copied with a {:?} {role} document", doc.mode)));
|
||||
@@ -250,7 +259,24 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
copies.text_styles.clone_from(&sdoc.text_styles);
|
||||
for id in &ids {
|
||||
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
|
||||
let mut l = src_layer.duplicate();
|
||||
let mut l = if selection_only {
|
||||
let parts = match floating.as_ref().filter(|f| f.layer == *id) {
|
||||
Some(f) if f.parts.is_mask() => return Err(bad("a floating layer mask cannot be transferred as selected pixels")),
|
||||
Some(f) => f.parts.clone(),
|
||||
None => std::sync::Arc::new(crate::float_cmds::CutParts::new(&sdoc, *id, true, s.tools.background, false)?),
|
||||
};
|
||||
if parts.selected_pixels().content_bounds().is_empty() {
|
||||
return Err(bad("the selected pixels are empty"));
|
||||
}
|
||||
let mut layer = Layer::new(src_layer.name.clone(), LayerContent::Raster(parts.selected_pixels().clone()));
|
||||
layer.opacity = src_layer.opacity;
|
||||
if let Some(f) = floating.as_ref().filter(|f| f.layer == *id) {
|
||||
crate::commands::translate_layer(&sdoc, &mut layer, f.offset.0, f.offset.1);
|
||||
}
|
||||
layer
|
||||
} else {
|
||||
src_layer.duplicate()
|
||||
};
|
||||
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
|
||||
if sdoc.is_background(*id) {
|
||||
l.name = ddoc.next_layer_name("Layer");
|
||||
@@ -399,7 +425,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Copy Layers to Document",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##,
|
||||
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"selectionOnly":bool=false (copy selected pixels from one raster layer),"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##,
|
||||
enabled,
|
||||
run: copy_to_document,
|
||||
journal: true,
|
||||
|
||||
@@ -28,6 +28,53 @@ fn copied(r: &Value) -> LayerId {
|
||||
LayerId(r["layers"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_preserves_source_and_soft_coverage_across_documents() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, paint) = two_docs(40, 30, depth);
|
||||
s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": paint.0, "opacity": 0.4})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection.as_mut().unwrap().write_pixel(7, 8, &[0.5]);
|
||||
let before = doc(&s, 0).clone();
|
||||
let r = s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true, "offset": [10, 5]})).unwrap();
|
||||
let id = copied(&r);
|
||||
assert_eq!(bounds(&s, 1, id), Rect::new(17, 13, 20, 17));
|
||||
assert_eq!(*doc(&s, 0), before);
|
||||
let pixel = doc(&s, 1).layer(id).unwrap().surface().unwrap().rgba(17, 13);
|
||||
assert!((doc(&s, 1).layer(id).unwrap().opacity - 0.4).abs() < 1e-6);
|
||||
assert_eq!(&pixel[..3], &[1.0, 0.0, 0.0]);
|
||||
assert!((pixel[3] - 0.5).abs() <= 1.0 / 255.0, "soft selection coverage survives depth conversion");
|
||||
assert!(s.undo());
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
assert_eq!(*doc(&s, 0), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_rejects_a_floating_layer_mask() {
|
||||
let (mut s, paint) = two_docs(40, 30, 8);
|
||||
s.edit("mask", |doc, _| {
|
||||
doc.layer_mut(paint).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "target": "mask"})).unwrap();
|
||||
let source = doc(&s, 0).clone();
|
||||
assert!(s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true})).is_err());
|
||||
assert_eq!(*doc(&s, 0), source);
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_only_copy_rejects_missing_selection_and_wrong_parameter_type() {
|
||||
let (mut s, _) = two_docs(40, 30, 8);
|
||||
for option in [json!(true), json!("yes")] {
|
||||
assert!(s.execute("layer.copyToDocument", json!({"document": 1, "selectionOnly": option})).is_err());
|
||||
assert_eq!(doc(&s, 1).layers.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, i: usize, id: LayerId) -> Rect {
|
||||
crate::layer_multi_cmds::layer_bounds(doc(s, i).layer(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ mod structure;
|
||||
use align::{align, distribute};
|
||||
pub(crate) use damage::grow_hint;
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub(crate) use damage::hint_misses;
|
||||
pub(crate) use damage::note_insert;
|
||||
pub(crate) use damage::{hint_misses, small_change_pct};
|
||||
pub use damage::{layer_bounds, same_pixels, selection_only_change, step_damage};
|
||||
use moves::link_groups;
|
||||
pub(crate) use moves::shift_surface;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
use photocraft_compose::Ancestors;
|
||||
|
||||
use super::{Document, Layer, LayerContent, LayerId, Rect, Session, VideoData};
|
||||
use super::{Document, Layer, LayerContent, LayerId, Rect, VideoData};
|
||||
|
||||
/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and
|
||||
/// default pixel: a tile only one side has, or a different copy-on-write tile pointer at the same
|
||||
@@ -238,7 +238,8 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
clipping_path: _,
|
||||
quick_mask,
|
||||
quick_mask_unselected: _,
|
||||
patterns,
|
||||
// Compared by `patterns_pixels_equal`.
|
||||
patterns: _,
|
||||
color_table,
|
||||
duotone,
|
||||
layer_comps: _,
|
||||
@@ -265,11 +266,57 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
(Some(x), Some(y)) => alpha_channels_equal(x, y),
|
||||
_ => false,
|
||||
}
|
||||
&& *patterns == b.patterns
|
||||
&& patterns_pixels_equal(a, b)
|
||||
&& *color_table == b.color_table
|
||||
&& *duotone == b.duotone
|
||||
}
|
||||
|
||||
/// Whether `a`'s and `b`'s pattern libraries composite the same. Layers draw a pattern only
|
||||
/// through a reference that `photocraft_doc::pattern::find` resolves (fill and shape layers,
|
||||
/// Pattern Overlay, pattern strokes and glows, Bevel textures), so patterns appended after an
|
||||
/// unchanged prefix change nothing unless some reference now resolves differently: to an
|
||||
/// appended pattern where it resolved to an earlier one or to none. A same-named addition that
|
||||
/// loses to an existing id match changes nothing. Pattern Stamp copies its pattern into the
|
||||
/// document this way on first use; the pixels it paints are a raster change, diffed by tile like
|
||||
/// any other.
|
||||
fn patterns_pixels_equal(a: &Document, b: &Document) -> bool {
|
||||
use photocraft_doc::effects::{Effect, FxPaint};
|
||||
use photocraft_doc::{Fill, Pattern, pattern::find};
|
||||
if a.patterns == b.patterns {
|
||||
return true;
|
||||
}
|
||||
if a.patterns.len() >= b.patterns.len() || !b.patterns.starts_with(a.patterns.as_slice()) {
|
||||
return false;
|
||||
}
|
||||
// The index a reference resolves to; the prefix is equal, so equal indices mean equal patterns.
|
||||
let at = |ps: &[Pattern], id: &str, name: &str| find(ps, id, name).and_then(|p| ps.iter().position(|q| std::ptr::eq(q, p)));
|
||||
let hits = |id: &str, name: &str| at(&a.patterns, id, name) != at(&b.patterns, id, name);
|
||||
let fill = |f: &Fill| matches!(f, Fill::Pattern { id, name, .. } if hits(id, name));
|
||||
let paint = |p: &FxPaint| matches!(p, FxPaint::Pattern { id, name, .. } if hits(id, name));
|
||||
// Past a nesting depth no real document reaches, assume a reference (unequal), as
|
||||
// `pixels_equal_with` does.
|
||||
fn any_layer(layers: &[Layer], depth: usize, refers: &dyn Fn(&Layer) -> bool) -> bool {
|
||||
depth >= 256 || layers.iter().any(|l| refers(l) || matches!(&l.content, LayerContent::Group(g) if any_layer(&g.children, depth + 1, refers)))
|
||||
}
|
||||
let refers = |l: &Layer| {
|
||||
let effect = l.effects.items.iter().any(|e| match e {
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint(&g.paint),
|
||||
Effect::Stroke(s) => paint(&s.paint),
|
||||
Effect::PatternOverlay { id, name, .. } => hits(id, name),
|
||||
Effect::BevelEmboss(b) => b.texture.as_ref().is_some_and(|t| hits(&t.id, &t.name)),
|
||||
Effect::DropShadow(_) | Effect::InnerShadow(_) | Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::Satin(_) => false,
|
||||
});
|
||||
effect
|
||||
|| match &l.content {
|
||||
LayerContent::Fill(f) => fill(f),
|
||||
LayerContent::Shape(s) => s.fill.as_ref().is_some_and(fill) || s.stroke.as_ref().is_some_and(|st| fill(&st.paint)),
|
||||
// A smart object draws its cache; its own document holds its own patterns.
|
||||
LayerContent::Raster(_) | LayerContent::Group(_) | LayerContent::Adjustment(_) | LayerContent::Text(_) | LayerContent::Smart(_) => false,
|
||||
}
|
||||
};
|
||||
!any_layer(&b.layers, 0, &refers)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` composite to the same pixels: they differ at most in fields the
|
||||
/// compositor never reads (selection, guides, paths, names, …), by the same classification as
|
||||
/// [`step_damage`] but without computing any bounds. Cheap: pixels compare by tile pointer.
|
||||
@@ -739,12 +786,18 @@ fn inert_at(layers: &[Layer], k: usize) -> bool {
|
||||
&& (l.clipped || layers.get(k + 1).is_none_or(|above| !above.clipped))
|
||||
}
|
||||
|
||||
/// Report the pixels a command made of more than one edit changed since `before` ([`step_damage`]),
|
||||
/// so the canvas recomposites only where its new layers draw instead of the whole document.
|
||||
/// A single [`Session::edit`] already reports its own damage: don't call this after one (#5695).
|
||||
pub(crate) fn note_insert(s: &mut Session, before: &Document) {
|
||||
let Some(st) = s.active_mut() else { return };
|
||||
st.last_damage = step_damage(before, &st.doc);
|
||||
/// How much of the document (percent, rounded down) changed between `a` and `b`, when that is
|
||||
/// some but under 25 %: what [`step_damage`] finds, which a command that reported "redraw
|
||||
/// everything" (`None`) should have reported instead. `None` when nothing changed (a view-only
|
||||
/// change), when it's 25 % or more, or when `step_damage` can't bound it (a canvas-size change).
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub(crate) fn small_change_pct(a: &Document, b: &Document) -> Option<u64> {
|
||||
let d = step_damage(a, b).filter(|d| !d.is_empty())?;
|
||||
let doc = b.bounds();
|
||||
let whole = u64::from(doc.width()) * u64::from(doc.height());
|
||||
let changed = d.intersect(&doc);
|
||||
let pct = u64::from(changed.width()) * u64::from(changed.height()) * 100 / whole.max(1);
|
||||
(pct < 25).then_some(pct)
|
||||
}
|
||||
|
||||
/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or
|
||||
|
||||
@@ -408,3 +408,195 @@ fn painting_command_hints_cover_their_changed_tiles() {
|
||||
assert!(unchanged_outside(&before, &doc(&s), d), "{cmd}: pixels changed outside {d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// PR C (#9329): damage spans a command, not a revision.
|
||||
|
||||
/// The damage since the command began: measured from the revision before it, bounded, and
|
||||
/// covering every pixel the command changed.
|
||||
fn command_damage(s: &mut Session, cmd: &str, p: serde_json::Value) -> (std::sync::Arc<Document>, Rect) {
|
||||
let (before, rev) = (doc(s), s.active().unwrap().revision);
|
||||
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.revision - rev >= 2, "{cmd}: several revisions ({} → {})", rev, st.revision);
|
||||
assert_eq!(st.damage_from(), rev, "{cmd}: measured from before the command");
|
||||
let d = st.last_damage.unwrap_or_else(|| panic!("{cmd}: bounded damage"));
|
||||
assert!(unchanged_outside(&before, &st.doc, d), "{cmd}: pixels changed outside {d:?}");
|
||||
(before, d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_into_reports_damage_since_the_command_began() {
|
||||
let mut s = session(1024, 768);
|
||||
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0]), (Rect::new(40, 40, 120, 120), [0.9, 0.1, 0.1, 1.0])]);
|
||||
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 700, "y": 500, "width": 120, "height": 90})).unwrap();
|
||||
let (_, d) = command_damage(&mut s, "edit.pasteSpecial.pasteInto", json!({}));
|
||||
assert!(!d.intersect(&Rect::new(700, 500, 820, 590)).is_empty(), "the pasted area: {d:?}");
|
||||
assert!(u64::from(d.width()) * u64::from(d.height()) * 4 < 1024 * 768, "the pasted area, not the canvas: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_path_damage_spans_every_subpath() {
|
||||
let mut s = session(1024, 768);
|
||||
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.5, 0.5, 0.5, 1.0])]);
|
||||
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20], [80, 80]]}, {"knots": [[800, 600], [900, 600], [900, 700]]}]});
|
||||
s.execute("path.set", json!({ "path": path })).unwrap();
|
||||
// A tool with its own `paint.*` command runs once per subpath, one revision each.
|
||||
let (_, d) = command_damage(&mut s, "path.stroke", json!({"tool": "burn", "size": 16}));
|
||||
assert!(d.contains(50, 20) && d.contains(850, 600), "both subpaths: {d:?}");
|
||||
assert_eq!(s.whole_redraws, 0);
|
||||
}
|
||||
|
||||
/// Pattern Stamp copies its pattern into the document on first use: a pattern no layer
|
||||
/// refers to draws nothing, so a multi-subpath Stroke Path stays bounded.
|
||||
#[test]
|
||||
fn a_new_unreferenced_pattern_keeps_stroke_path_bounded() {
|
||||
let mut s = session(1024, 768);
|
||||
layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.5, 0.5, 0.5, 1.0])]);
|
||||
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20], [80, 80]]}, {"knots": [[800, 600], [900, 600], [900, 700]]}]});
|
||||
s.execute("path.set", json!({ "path": path })).unwrap();
|
||||
let n = doc(&s).patterns.len();
|
||||
let p = json!({"tool": "patternStamp", "size": 16, "hardness": 100, "pattern": "Checkerboard", "scale": 100, "aligned": true});
|
||||
let (_, d) = command_damage(&mut s, "path.stroke", p);
|
||||
assert!(doc(&s).patterns.len() > n, "the stamp added its pattern");
|
||||
assert!(d.contains(50, 20) && d.contains(850, 600), "both subpaths: {d:?}");
|
||||
assert_eq!(s.whole_redraws, 0);
|
||||
}
|
||||
|
||||
/// A pattern that an existing layer refers to (here a fill layer whose pattern was missing)
|
||||
/// changes that layer's pixels when it is added: not bounded by the tile diff.
|
||||
#[test]
|
||||
fn a_new_pattern_a_layer_refers_to_is_unbounded() {
|
||||
let mut s = session(400, 300);
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"})).unwrap();
|
||||
// Without its pixel cache, so both sides render the fill from the library.
|
||||
let mut with = (*doc(&s)).clone();
|
||||
with.layers.iter_mut().for_each(|l| l.fill_cache = None);
|
||||
let mut without = with.clone();
|
||||
without.patterns.clear();
|
||||
assert!(!same_pixels(&without, &with), "the fill layer draws only with its pattern");
|
||||
assert_eq!(step_damage(&without, &with), None);
|
||||
// Unchanged libraries, and an unreferenced addition, stay pixel-equal.
|
||||
assert_eq!(step_damage(&with, &with), Some(Rect::EMPTY));
|
||||
let plain = doc(&session(400, 300));
|
||||
let mut more = (*plain).clone();
|
||||
more.patterns = with.patterns.clone();
|
||||
assert_eq!(step_damage(&plain, &more), Some(Rect::EMPTY));
|
||||
assert!(unchanged_outside(&plain, &more, Rect::EMPTY));
|
||||
}
|
||||
|
||||
/// An appended pattern sharing its name with one a layer already resolves by id loses to that id
|
||||
/// match (`pattern::find` tries ids first), so the layer draws as before: bounded, not a whole
|
||||
/// redraw. A cross-document layer copy can bring such a pattern along.
|
||||
#[test]
|
||||
fn a_same_named_new_pattern_a_reference_still_resolves_past_is_bounded() {
|
||||
let mut s = session(400, 300);
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Checkerboard"})).unwrap();
|
||||
let mut with = (*doc(&s)).clone();
|
||||
with.layers.iter_mut().for_each(|l| l.fill_cache = None);
|
||||
let refers_by_id = with.layers.iter().any(|l| matches!(&l.content, LayerContent::Fill(photocraft_doc::Fill::Pattern { id, .. }) if !id.is_empty()));
|
||||
assert!(refers_by_id, "the fill layer refers to its pattern by id");
|
||||
let mut twin = with.patterns[0].clone();
|
||||
twin.id = "another-id".into();
|
||||
twin.surface.fill_rect(Rect::new(0, 0, twin.width as i32, twin.height as i32), &[1.0, 0.0, 1.0, 1.0]);
|
||||
let mut more = with.clone();
|
||||
more.patterns.push(twin);
|
||||
assert_eq!(step_damage(&with, &more), Some(Rect::EMPTY));
|
||||
assert!(unchanged_outside(&with, &more, Rect::EMPTY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_compound_reports_no_change() {
|
||||
let mut s = session(600, 400);
|
||||
let id = layer_with(&mut s, &[(Rect::new(0, 0, 50, 50), [1.0, 0.0, 0.0, 1.0])]);
|
||||
let (before, rev) = (doc(&s), s.active().unwrap().revision);
|
||||
let r: Result<()> = crate::analysis_cmds::compound(&mut s, "Both", |s| {
|
||||
paint(s, id, Rect::new(300, 200, 340, 240), [0.0, 1.0, 0.0, 1.0]);
|
||||
paint(s, id, Rect::new(10, 300, 40, 340), [0.0, 0.0, 1.0, 1.0]);
|
||||
Err(EngineError::Other("the second step failed".into()))
|
||||
});
|
||||
assert!(r.is_err());
|
||||
// What `Session::dispatch` does after any inline command.
|
||||
s.note_command_damage("test.compound", &before, rev);
|
||||
let st = s.active().unwrap();
|
||||
assert!(same_pixels(&before, &st.doc), "rolled back");
|
||||
assert_eq!((st.damage_from(), st.last_damage), (rev, Some(Rect::EMPTY)), "nothing changed since it began");
|
||||
assert_eq!(s.whole_redraws, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_then_edit_in_one_command_is_bounded() {
|
||||
let mut s = session(1024, 768);
|
||||
let id = layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0])]);
|
||||
let steps = vec![
|
||||
("layer.select".to_string(), json!({"layer": id.0})),
|
||||
("select.rect".to_string(), json!({"x": 300, "y": 300, "width": 50, "height": 40})),
|
||||
("edit.fill".to_string(), json!({"color": "#ff0000"})),
|
||||
("select.deselect".to_string(), json!({})),
|
||||
];
|
||||
s.actions.list.push(crate::actions_cmds::Action::new("A", steps));
|
||||
let (_, d) = command_damage(&mut s, "actions.play", json!({"action": "A"}));
|
||||
assert!(d.contains(300, 300) && d.contains(349, 339), "{d:?}");
|
||||
assert!(u64::from(d.width()) * u64::from(d.height()) * 4 < 1024 * 768, "the filled tiles, not the canvas: {d:?}");
|
||||
assert_eq!(s.whole_redraws, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_document_redraws_of_small_edits_are_counted() {
|
||||
let mut s = session(1000, 1000);
|
||||
let id = layer_with(&mut s, &[(Rect::new(0, 0, 1000, 1000), [0.2, 0.4, 0.6, 1.0])]);
|
||||
// A synthetic command that paints 1 % and reports "everything".
|
||||
let rev = s.active().unwrap().revision;
|
||||
let (before, _) = paint(&mut s, id, Rect::new(10, 10, 110, 110), [1.0, 0.0, 0.0, 1.0]);
|
||||
s.active_mut().unwrap().last_damage = None;
|
||||
s.note_command_damage("test.wholeRedraw", &before, rev);
|
||||
assert_eq!(s.whole_redraws, 1);
|
||||
// A canvas-size change really does redraw everything: not counted.
|
||||
s.execute("image.canvasSize", json!({"width": 1200, "height": 900})).unwrap();
|
||||
assert_eq!(s.active().unwrap().last_damage, None);
|
||||
assert_eq!(s.whole_redraws, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_command_redraws_the_whole_document_for_a_small_edit() {
|
||||
let mut s = session(1024, 768);
|
||||
let base = layer_with(&mut s, &[(Rect::new(0, 0, 1024, 768), [0.2, 0.4, 0.6, 1.0])]);
|
||||
let small = layer_with(&mut s, &[(Rect::new(100, 100, 160, 160), [0.9, 0.1, 0.1, 1.0])]);
|
||||
let other = layer_with(&mut s, &[(Rect::new(130, 130, 190, 190), [0.1, 0.9, 0.1, 1.0])]);
|
||||
let path = json!({"subpaths": [{"knots": [[20, 20], [80, 20]]}, {"knots": [[800, 600], [900, 600]]}]});
|
||||
let steps: Vec<(&str, serde_json::Value)> = vec![
|
||||
("layer.select", json!({"layer": base.0})),
|
||||
("select.rect", json!({"x": 300, "y": 300, "width": 50, "height": 40})),
|
||||
("edit.fill", json!({"color": "#ff0000"})),
|
||||
("filter.blur.gaussianBlur", json!({"radius": 2})),
|
||||
("edit.copy", json!({})),
|
||||
("select.rect", json!({"x": 600, "y": 400, "width": 80, "height": 60})),
|
||||
("edit.pasteSpecial.pasteInto", json!({})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
("layer.select", json!({"layer": base.0})),
|
||||
("paint.stroke", json!({"points": [[100, 400], [200, 420]], "size": 20, "color": "#2040ff"})),
|
||||
("edit.undo", json!({})),
|
||||
("edit.redo", json!({})),
|
||||
("path.set", json!({ "path": path })),
|
||||
("path.stroke", json!({"tool": "pencil", "size": 4, "color": "#0000ff", "opacity": 100})),
|
||||
("path.stroke", json!({"tool": "burn", "size": 16})),
|
||||
("layer.select", json!({"layer": small.0})),
|
||||
("layer.setProps", json!({"layer": small.0, "opacity": 0.5})),
|
||||
("layer.setProps", json!({"layer": small.0, "visible": false})),
|
||||
("layer.setProps", json!({"layer": small.0, "visible": true})),
|
||||
("layer.translate", json!({"layer": small.0, "dx": 20, "dy": 10})),
|
||||
// Show Only hides the full-canvas base: a large change, so not counted either way.
|
||||
("layer.showOnly", json!({"layer": small.0})),
|
||||
("layer.showOnly", json!({"layer": small.0})),
|
||||
("layer.arrange.bringForward", json!({})),
|
||||
("layer.arrange.sendBackward", json!({})),
|
||||
("layer.setProps", json!({"layer": other.0, "clipped": true})),
|
||||
("layer.new.layer", json!({})),
|
||||
];
|
||||
for (cmd, p) in steps {
|
||||
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
assert_eq!(s.whole_redraws, 0, "{cmd}: redrew the whole document for a small edit");
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("all").and_then(Value::as_bool) == Some(true) {
|
||||
let n = s.active().map_or(0, |d| d.doc.notes.len());
|
||||
s.edit("Delete All Notes", |d, _| {
|
||||
// Photoshop's name for the step, with a lower-case "all" (#11791).
|
||||
s.edit("Delete all Notes", |d, _| {
|
||||
d.notes.clear();
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! `path.deleteAnchor` on a path's last anchor (#12764): a saved path is left empty and stays
|
||||
//! listed; the Work Path and a vector mask keep refusing it.
|
||||
|
||||
use photocraft_doc::text::TextShape;
|
||||
use photocraft_doc::{Document, LayerContent, LayerId, Path, Subpath};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 400, "height": 400, "background": "white"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn lone() -> Value {
|
||||
json!({"subpaths": [{"closed": false, "knots": [[100, 300]]}]})
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
/// "Path 1" with one subpath of one anchor, emptied by deleting that anchor.
|
||||
fn emptied() -> Session {
|
||||
let mut s = session();
|
||||
s.execute("path.set", json!({"name": "Path 1", "path": lone()})).unwrap();
|
||||
s.execute("path.deleteAnchor", json!({"name": "Path 1", "subpath": 0, "knot": 0})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_saved_paths_last_anchor_leaves_it_empty_in_one_undoable_step() {
|
||||
let mut s = session();
|
||||
s.execute("path.set", json!({"name": "Path 1", "path": lone()})).unwrap();
|
||||
let before = doc(&s).paths[0].path.clone();
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
let r = s.execute("path.deleteAnchor", json!({"name": "Path 1", "subpath": 0, "knot": 0})).unwrap();
|
||||
assert_eq!(r["subpathRemoved"], json!(true));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Delete Anchor Point"));
|
||||
let list = s.execute("path.list", json!({})).unwrap();
|
||||
assert_eq!(list["paths"], json!([{"name": "Path 1", "knots": 0, "subpaths": 0}]), "Path 1 stays in the Paths panel");
|
||||
let info = s.execute("path.info", json!({"name": "Path 1"})).unwrap();
|
||||
assert_eq!(info["path"]["subpaths"], json!([]));
|
||||
assert!(s.undo());
|
||||
assert_eq!(doc(&s).paths[0].path, before, "Undo brings the anchor back");
|
||||
assert!(s.redo());
|
||||
assert!(doc(&s).paths[0].path.subpaths.is_empty());
|
||||
assert_eq!(s.execute("path.list", json!({})).unwrap()["paths"], json!([{"name": "Path 1", "knots": 0, "subpaths": 0}]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_paths_and_a_vector_masks_last_anchor_are_still_refused() {
|
||||
let mut s = session();
|
||||
s.execute("path.set", json!({"path": lone()})).unwrap();
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
let e = s.execute("path.deleteAnchor", json!({"subpath": 0, "knot": 0})).unwrap_err();
|
||||
assert!(e.to_string().contains("only anchor"), "{e}");
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||||
assert_eq!(doc(&s).work_path.as_ref().map(|p| p.subpaths.len()), Some(1));
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
s.execute("layer.vectorMask.add", json!({"layer": id, "path": lone()})).unwrap();
|
||||
let steps_mask = s.active().unwrap().history.entries().len();
|
||||
let e = s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id, "subpath": 0, "knot": 0})).unwrap_err();
|
||||
assert!(e.to_string().contains("only anchor"), "{e}");
|
||||
assert_eq!(doc(&s).layer(LayerId(id)).unwrap().vector_mask.as_ref().unwrap().path.subpaths.len(), 1);
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps_mask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_saved_path_selects_fills_and_strokes_nothing() {
|
||||
let mut s = emptied();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let pixels = |s: &Session| doc(s).layer(id).unwrap().surface().unwrap().clone();
|
||||
let before = pixels(&s);
|
||||
assert_eq!(s.execute("path.toSelection", json!({"name": "Path 1"})).unwrap(), json!({"selected": false}));
|
||||
assert!(doc(&s).selection.is_none());
|
||||
s.execute("path.fill", json!({"name": "Path 1", "color": "#ff0000"})).unwrap();
|
||||
let r = s.execute("path.stroke", json!({"name": "Path 1", "tool": "pencil", "size": 4, "color": "#0000ff"})).unwrap();
|
||||
assert_eq!(r["damage"], json!([0, 0, 0, 0]));
|
||||
assert!(pixels(&s) == before, "an empty path paints nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_saved_path_survives_pcraft_and_psd_round_trips() {
|
||||
let s = emptied();
|
||||
let bytes = photocraft_format::save_to_bytes(doc(&s), &Default::default()).unwrap();
|
||||
let back = photocraft_format::load_from_bytes(&bytes).unwrap();
|
||||
assert_eq!(back.paths.iter().map(|p| (p.name.as_str(), p.path.subpaths.len())).collect::<Vec<_>>(), [("Path 1", 0)]);
|
||||
let psd = photocraft_io::export(doc(&s), "x.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("x.psd", &psd).unwrap().document;
|
||||
assert_eq!(back.paths.iter().map(|p| (p.name.as_str(), p.path.subpaths.len())).collect::<Vec<_>>(), [("Path 1", 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_clipping_path_survives_a_psd_round_trip() {
|
||||
let mut s = emptied();
|
||||
s.execute("path.clippingPath.set", json!({"name": "Path 1"})).unwrap();
|
||||
let psd = photocraft_io::export(doc(&s), "x.psd", &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import("x.psd", &psd).unwrap().document;
|
||||
assert_eq!(back.paths.iter().map(|p| (p.name.as_str(), p.path.subpaths.len())).collect::<Vec<_>>(), [("Path 1", 0)]);
|
||||
assert_eq!(back.clipping_path.map(|c| c.name).as_deref(), Some("Path 1"));
|
||||
}
|
||||
|
||||
/// A Type Path is not a saved path: its only anchor stays refused. No command makes a one-anchor
|
||||
/// Type Path (attaching and editing need two points), so the document is trimmed directly.
|
||||
#[test]
|
||||
fn a_type_paths_last_anchor_is_still_refused() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 20, "y": 70, "text": "Hello", "size": 36, "color": "#000000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.edit("path", |doc, _| {
|
||||
doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.attachToPath", json!({"layer": id})).unwrap();
|
||||
s.edit("trim", |doc, _| {
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(LayerId(id)).map(|l| &mut l.content)
|
||||
&& let TextShape::Path { path, .. } = &mut t.shape
|
||||
{
|
||||
path.knots.truncate(1);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let type_path = |s: &Session| match &doc(s).layer(LayerId(id)).unwrap().content {
|
||||
LayerContent::Text(t) => crate::type_extra_cmds::type_path(t),
|
||||
_ => None,
|
||||
};
|
||||
let before = type_path(&s).unwrap();
|
||||
assert_eq!(before.subpaths.iter().map(|sp| sp.knots.len()).collect::<Vec<_>>(), [1]);
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
let e = s.execute("path.deleteAnchor", json!({"name": "layer", "layer": id, "typePath": true, "subpath": 0, "knot": 0})).unwrap_err();
|
||||
assert!(e.to_string().contains("only anchor"), "{e}");
|
||||
assert_eq!(type_path(&s), Some(before));
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||||
}
|
||||
@@ -46,6 +46,16 @@ fn knot(cmd: &str, p: &Value) -> Result<[usize; 2]> {
|
||||
Ok([index(cmd, p, "subpath")?, index(cmd, p, "knot")?])
|
||||
}
|
||||
|
||||
/// The History step name: `default`, or `named` when `stepName` asks for it (the Convert Point
|
||||
/// tool names its steps as the reference does, #12736). Any other value is refused.
|
||||
fn step_name(cmd: &str, p: &Value, default: &'static str, named: &'static str) -> Result<&'static str> {
|
||||
match p.get("stepName") {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(Value::String(n)) if n == named => Ok(named),
|
||||
Some(_) => Err(bad(cmd, format!("`stepName` must be \"{named}\""))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies `f` to the targeted path in one undoable step labelled `label`.
|
||||
fn edit_path(s: &mut Session, cmd: &str, p: &Value, label: &str, f: impl FnOnce(&mut Path) -> std::result::Result<(), String>) -> Result<Value> {
|
||||
// A malformed target is an error, never a silent edit of the Work Path or the active layer.
|
||||
@@ -118,7 +128,8 @@ fn move_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = p.get("handle").and_then(Value::as_str).and_then(Handle::parse).ok_or_else(|| bad(CMD, "`handle` must be \"in\" or \"out\""))?;
|
||||
let to = point(CMD, p, "to")?.ok_or_else(|| bad(CMD, "missing `to`"))?;
|
||||
let independent = p.get("independent").and_then(Value::as_bool).unwrap_or(false);
|
||||
edit_path(s, CMD, p, "Drag Direction Point", |path| edit::move_handle(path, k, h, to, independent))
|
||||
let label = step_name(CMD, p, "Drag Direction Point", "Corner Drag")?;
|
||||
edit_path(s, CMD, p, label, |path| edit::move_handle(path, k, h, to, independent))
|
||||
}
|
||||
|
||||
fn bend_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -135,11 +146,12 @@ fn bend_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn convert_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.convertPoint";
|
||||
let k = knot(CMD, p)?;
|
||||
let label = step_name(CMD, p, "Convert Point", "Change Anchor Point")?;
|
||||
if p.get("curvature").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return edit_path(s, CMD, p, "Convert Point", |path| edit::toggle_curvature_point(path, k));
|
||||
return edit_path(s, CMD, p, label, |path| edit::toggle_curvature_point(path, k));
|
||||
}
|
||||
let out = point(CMD, p, "out")?;
|
||||
edit_path(s, CMD, p, "Convert Point", |path| edit::convert_point(path, k, out))
|
||||
edit_path(s, CMD, p, label, |path| edit::convert_point(path, k, out))
|
||||
}
|
||||
|
||||
fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -163,9 +175,12 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn delete_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.deleteAnchor";
|
||||
let k = knot(CMD, p)?;
|
||||
// A saved path may lose its last anchor and stay listed, empty; the Work Path, a shape's path,
|
||||
// a vector mask and a Type Path keep refusing it.
|
||||
let saved = p.get("name").and_then(Value::as_str).is_some_and(|n| n != "layer" && !crate::vector_cmds::is_work(Some(n)));
|
||||
let mut removed = false;
|
||||
let mut r = edit_path(s, CMD, p, "Delete Anchor Point", |path| {
|
||||
removed = edit::delete_anchor(path, k)?;
|
||||
removed = if saved { edit::delete_anchor_allow_empty(path, k)? } else { edit::delete_anchor(path, k)? };
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
@@ -255,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec(
|
||||
"path.moveHandle",
|
||||
"Drag Direction Point",
|
||||
r#""subpath":i,"knot":j,"handle":"in|out","to":[x,y],"independent":bool=false (a smooth point turns its other handle to stay collinear; independent = ⌥ / Convert Point, makes a corner)"#.into(),
|
||||
r#""subpath":i,"knot":j,"handle":"in|out","to":[x,y],"independent":bool=false (a smooth point turns its other handle to stay collinear; independent = ⌥ / Convert Point, makes a corner),"stepName":"Corner Drag"? (History step name; the Convert Point tool sends it; default "Drag Direction Point"; any other value is refused)"#.into(),
|
||||
move_handle,
|
||||
),
|
||||
spec(
|
||||
@@ -267,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec(
|
||||
"path.convertPoint",
|
||||
"Convert Point",
|
||||
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored)"#.into(),
|
||||
r#""subpath":i,"knot":j,"out":[x,y]? (none: corner with retracted handles; given: smooth with this out handle and its mirror),"curvature":bool=false (the Curvature Pen's double-click: toggles smooth/corner and reflows the smooth knots of the subpath; out ignored),"stepName":"Change Anchor Point"? (History step name; the Convert Point tool sends it; default "Convert Point"; any other value is refused)"#.into(),
|
||||
convert_point,
|
||||
),
|
||||
spec(
|
||||
@@ -279,7 +294,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec(
|
||||
"path.deleteAnchor",
|
||||
"Delete Anchor Point",
|
||||
r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor is refused) (the result also has subpathRemoved:bool)"#.into(),
|
||||
r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor empties a saved path and is refused elsewhere) (the result also has subpathRemoved:bool)"#.into(),
|
||||
delete_anchor,
|
||||
),
|
||||
spec(
|
||||
@@ -744,3 +759,7 @@ mod tests {
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_delete_last_anchor_tests.rs"]
|
||||
mod path_delete_last_anchor_tests;
|
||||
@@ -84,16 +84,30 @@ fn top_group(doc: &Document, id: LayerId) -> LayerId {
|
||||
path.get(..depth).and_then(|p| doc.layer_at(p)).map_or(id, |l| l.id)
|
||||
}
|
||||
|
||||
/// The layer an Auto-Select click at (x, y) picks (with `group`, its outermost group): the topmost
|
||||
/// hit, a type layer by its text bounds. Like Photoshop, it clicks through a fully locked layer
|
||||
/// (its own Lock All or a locked group's) to the layer under it (#1641); the right-click list
|
||||
/// still shows it. The click (`layer.pickAt`) and the Move tool's rollover highlight both ask
|
||||
/// here, so the outline always names the layer a click takes.
|
||||
/// The topmost selectable layer at (x, y), or its outermost group if `group` is set.
|
||||
/// Type layers hit across their text bounds. Effective Lock All is skipped. Position-locked layers remain selectable.
|
||||
pub fn auto_select_target(doc: &Document, x: i32, y: i32, group: bool) -> Option<LayerId> {
|
||||
let hit = layers_hit(doc, x, y, true).into_iter().find(|id| !doc.effective_locks(*id).all)?;
|
||||
pick_target(doc, x, y, group, false)
|
||||
}
|
||||
|
||||
/// The Move tool's Auto-Select target, matching `layer.pickAt` with `moveAutoSelect` enabled.
|
||||
/// Both effective Position and All locks are skipped before resolving the group.
|
||||
pub fn move_auto_select_target(doc: &Document, x: i32, y: i32, group: bool) -> Option<LayerId> {
|
||||
pick_target(doc, x, y, group, true)
|
||||
}
|
||||
|
||||
/// Choose the topmost hit eligible for selection before resolving its group. An effective Lock
|
||||
/// All excludes a layer from selection here. Move Auto-Select also skips effective Position locks.
|
||||
fn pick_target(doc: &Document, x: i32, y: i32, group: bool, move_auto_select: bool) -> Option<LayerId> {
|
||||
let hit = layers_hit(doc, x, y, true).into_iter().find(|id| {
|
||||
let locks = doc.effective_locks(*id);
|
||||
!locks.all && (!move_auto_select || !locks.position)
|
||||
})?;
|
||||
Some(if group { top_group(doc, hit) } else { hit })
|
||||
}
|
||||
|
||||
/// List the hits for a context menu or pick one layer or group. A probe leaves the selection
|
||||
/// alone. A replace click on a selected member keeps the full multi-selection for dragging.
|
||||
fn pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32;
|
||||
let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32;
|
||||
@@ -105,7 +119,8 @@ fn pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "layers": names }));
|
||||
}
|
||||
let group = p.get("target").and_then(Value::as_str) == Some("group");
|
||||
let Some(target) = auto_select_target(&doc, x, y, group) else { return Ok(json!({ "layer": null })) };
|
||||
let move_auto_select = p.get("moveAutoSelect").and_then(Value::as_bool).unwrap_or(false);
|
||||
let Some(target) = pick_target(&doc, x, y, group, move_auto_select) else { return Ok(json!({ "layer": null })) };
|
||||
if p.get("select").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace");
|
||||
// Like Photoshop, a plain click on one of several selected layers keeps them all
|
||||
@@ -118,11 +133,15 @@ fn pick(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "layer": target.0 }))
|
||||
}
|
||||
|
||||
/// Layers whose content touches document rect `r`, top to bottom: visible, not fully locked, not
|
||||
/// the locked Background (as Select › All Layers). A layer on an artboard counts only where the
|
||||
/// board shows it, and without artboards only where the canvas does (#10665). With `group`, each
|
||||
/// counts as its outermost group (deduplicated).
|
||||
/// Visible layers whose content touches document rect `r`, top to bottom, excluding the locked
|
||||
/// Background and layers with effective All locks. A layer on an artboard counts only where the
|
||||
/// board shows it, and without artboards only where the canvas does (#10665). With `group`,
|
||||
/// each counts as its outermost group (deduplicated).
|
||||
pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
|
||||
layers_in_rect_with_locks(doc, r, group, false)
|
||||
}
|
||||
|
||||
fn layers_in_rect_with_locks(doc: &Document, r: Rect, group: bool, skip_position_locked: bool) -> Vec<LayerId> {
|
||||
let mut out = Vec::new();
|
||||
let canvas = doc.bounds();
|
||||
let r = if doc.has_artboards() { r } else { r.intersect(&canvas) };
|
||||
@@ -132,7 +151,10 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec<LayerId> {
|
||||
for (path, _, l) in doc.walk().iter().rev() {
|
||||
if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_))
|
||||
|| doc.is_background(l.id)
|
||||
|| doc.effective_locks(l.id).all
|
||||
|| {
|
||||
let locks = doc.effective_locks(l.id);
|
||||
locks.all || (skip_position_locked && locks.position)
|
||||
}
|
||||
|| !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible))
|
||||
{
|
||||
continue;
|
||||
@@ -166,14 +188,15 @@ fn select_in_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = Rect::new(px(x.min(x + w).floor()), px(y.min(y + h).floor()), px(x.max(x + w).ceil()), px(y.max(y + h).ceil()));
|
||||
let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let group = p.get("target").and_then(Value::as_str) == Some("group");
|
||||
let hits = layers_in_rect(&doc, r, group);
|
||||
let move_auto_select = p.get("moveAutoSelect").and_then(Value::as_bool).unwrap_or(false);
|
||||
let clear_on_empty = p.get("clearOnEmpty").and_then(Value::as_bool).unwrap_or(false);
|
||||
let hits = layers_in_rect_with_locks(&doc, r, group, move_auto_select);
|
||||
let add = match p.get("mode").and_then(Value::as_str).unwrap_or("replace") {
|
||||
"replace" => false,
|
||||
"add" => true,
|
||||
other => return Err(EngineError::BadParams { cmd: "layer.selectInRect".into(), msg: format!("unknown mode `{other}`") }),
|
||||
};
|
||||
// Nothing touched leaves the layer selection as it is.
|
||||
if !hits.is_empty() {
|
||||
if !hits.is_empty() || (clear_on_empty && !add) {
|
||||
let mut ids = if add { s.active().map(|st| st.selected_layers()).unwrap_or_default() } else { Vec::new() };
|
||||
ids.extend(hits.iter().filter(|id| !ids.contains(id)).copied().collect::<Vec<_>>());
|
||||
crate::layer_multi_cmds::set_selection(s, ids, hits.first().copied(), hits.first().copied())?;
|
||||
@@ -188,7 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Select Layers in Marquee",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"x":px,"y":px,"width":px,"height":px,"target":"layer|group"="layer","mode":"replace|add"="replace"} → {layers} (the layers whose content touches the rect, topmost first; none leaves the selection as it is)"##,
|
||||
params: r##"{"x":px,"y":px,"width":px,"height":px,"target":"layer|group"="layer","mode":"replace|add"="replace","moveAutoSelect":bool=false (also skip effective Position locks),"clearOnEmpty":bool=false (clear on no hits in replace mode only)} → {layers} (layers whose content touches the rect, topmost first, skipping effective All locks and Background)"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: select_in_rect,
|
||||
@@ -198,7 +221,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Auto-Select Layer",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##,
|
||||
params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","moveAutoSelect":bool=false (also skip Position locks),"list":bool=false (return visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##,
|
||||
enabled: has_doc,
|
||||
journal: false,
|
||||
run: pick,
|
||||
@@ -321,7 +344,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fully_locked_layer_is_clicked_through() {
|
||||
fn position_and_fully_locked_layers_are_clicked_through() {
|
||||
// #1641: a locked layer on top was picked, so the drag couldn't reach the layer under it.
|
||||
let (mut s, a, _) = two_squares();
|
||||
s.execute("layer.new.layer", json!({"name": "Top"})).unwrap();
|
||||
@@ -330,12 +353,17 @@ mod tests {
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let top = s.active().unwrap().active_layer.unwrap();
|
||||
let lock = |s: &mut Session, id: u64, locks: Value| s.execute("layer.setProps", json!({"layer": id, "locks": locks})).unwrap();
|
||||
let pick = |s: &mut Session| s.execute("layer.pickAt", json!({"x": 5, "y": 5})).unwrap()["layer"].clone();
|
||||
let pick = |s: &mut Session| s.execute("layer.pickAt", json!({"x": 5, "y": 5, "moveAutoSelect": true})).unwrap()["layer"].clone();
|
||||
let direct = |s: &Session| auto_select_target(&s.active().unwrap().doc, 5, 5, false);
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
// A position lock alone doesn't hide it from Auto-Select.
|
||||
// Position and All locks are both skipped; Image Pixels still permits picking.
|
||||
lock(&mut s, top.0, json!({"position": true}));
|
||||
assert_eq!(direct(&s), Some(top), "ordinary Rust callers can select a position-locked layer");
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
lock(&mut s, top.0, json!({"position": false, "pixels": true}));
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
lock(&mut s, top.0, json!({"all": true}));
|
||||
assert_eq!(direct(&s), Some(a));
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// The right-click layer list still names it.
|
||||
@@ -346,12 +374,16 @@ mod tests {
|
||||
s.execute("layer.select", json!({"layer": top.0})).unwrap();
|
||||
let g = s.execute("layer.new.groupFromLayers", json!({"name": "G"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(pick(&mut s), top.0);
|
||||
lock(&mut s, g, json!({"position": true}));
|
||||
assert_eq!(direct(&s), Some(top), "inherited Position has the same ordinary default");
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
lock(&mut s, g, json!({"all": true}));
|
||||
assert_eq!(direct(&s), Some(a));
|
||||
assert_eq!(pick(&mut s), a.0);
|
||||
// Nothing unlocked under the point: no pick.
|
||||
lock(&mut s, a.0, json!({"all": true}));
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
assert_eq!(pick(&mut s), bg.0);
|
||||
assert_eq!(pick(&mut s), Value::Null, "the Background's effective position lock also excludes it");
|
||||
lock(&mut s, bg.0, json!({"all": true}));
|
||||
assert_eq!(pick(&mut s), Value::Null);
|
||||
}
|
||||
@@ -412,6 +444,8 @@ mod tests {
|
||||
assert_eq!(menu["layers"][1]["layer"], bg.0);
|
||||
}
|
||||
|
||||
/// A marquee normalizes reversed bounds and extends the existing selection in add mode.
|
||||
/// An empty direct rectangle pick preserves the selection in both modes by default.
|
||||
#[test]
|
||||
fn a_marquee_selects_the_layers_it_touches() {
|
||||
let (mut s, a, b) = two_squares();
|
||||
@@ -426,9 +460,16 @@ mod tests {
|
||||
// Touching A's corner only (dragged up and left); never the empty layer or the Background.
|
||||
assert_eq!(sel(&mut s, [-5.0, -5.0, 6.0, 6.0], "layer", "replace"), (vec![a.0], vec![a.0]));
|
||||
assert_eq!(sel(&mut s, [50.0, 50.0, -27.0, -27.0], "layer", "replace"), (vec![b.0], vec![b.0]));
|
||||
// ⇧ adds; a box over nothing leaves the selection alone.
|
||||
// Adding extends the selection; a direct empty box preserves it in either mode.
|
||||
assert_eq!(sel(&mut s, [0.0, 0.0, 5.0, 5.0], "layer", "add"), (vec![a.0], vec![a.0, b.0]));
|
||||
assert_eq!(sel(&mut s, [11.0, 11.0, 8.0, 8.0], "layer", "add"), (vec![], vec![a.0, b.0]));
|
||||
assert_eq!(sel(&mut s, [11.0, 11.0, 8.0, 8.0], "layer", "replace"), (vec![], vec![a.0, b.0]));
|
||||
let empty = s.execute("layer.selectInRect", json!({"x": 11, "y": 11, "width": 8, "height": 8, "clearOnEmpty": true})).unwrap();
|
||||
assert_eq!(empty["layers"], json!([]));
|
||||
assert!(s.active().unwrap().selected_layers().is_empty());
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "locks": {"position": true}})).unwrap();
|
||||
assert_eq!(sel(&mut s, [0.0, 0.0, 5.0, 5.0], "layer", "replace"), (vec![a.0], vec![a.0]));
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "locks": {"position": false}})).unwrap();
|
||||
assert_eq!(sel(&mut s, [-10.0, -10.0, 60.0, 60.0], "layer", "replace"), (vec![b.0, a.0], vec![a.0, b.0]));
|
||||
// Group mode selects the outermost group; a hidden or fully locked layer is skipped.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! runs on a window around each click plus the mask-filter halo.
|
||||
|
||||
use photocraft_algo::redeye::{self, MAX_POINTS, MAX_WINDOW};
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_doc::{Document, LayerId};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba_into};
|
||||
use serde_json::{Value, json};
|
||||
@@ -198,6 +200,17 @@ fn apply_click(surf: &mut Surface, sel: Option<&Surface>, lock: bool, click: (i3
|
||||
Ok((area, changed))
|
||||
}
|
||||
|
||||
/// Whether a click that changed no pixel still ran the tool, leaving one unchanged history state
|
||||
/// as in Photoshop. Photoshop refuses non-RGB/Lab images, hidden layers and a click that starts
|
||||
/// outside the selection; alpha-channel, Quick Mask and layer/filter-mask targets, off-canvas
|
||||
/// clicks and clicks on locked transparency were not measured and keep the miss.
|
||||
fn runs_unchanged(doc: &Document, id: Option<LayerId>, p: &Value, first: (i32, i32)) -> bool {
|
||||
let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
|
||||
let shown = id.and_then(|id| doc.path_of(id)).is_some_and(|path| doc.shown_at(&path));
|
||||
let selected = doc.selection.as_ref().is_none_or(|m| m.sample_channel(first.0, first.1, 0) > 0.0);
|
||||
matches!(doc.mode, ColorMode::Rgb | ColorMode::Lab) && pixels && shown && selected && doc.bounds().contains(first.0, first.1)
|
||||
}
|
||||
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
crate::commands::has_paintable(s)?;
|
||||
if crate::channel_cmds::edits_channel(s) {
|
||||
@@ -223,10 +236,14 @@ fn red_eye(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pupil = pupil_raw.clamp(1.0, 100.0);
|
||||
let darken = darken.clamp(0.0, 100.0);
|
||||
let id = if p.get("layer").is_some() { Some(crate::commands::layer_param(s, p)?) } else { s.active().and_then(|d| d.active_layer) };
|
||||
let (damage, total) = s.edit("Red Eye", |doc, active| {
|
||||
let (damage, total) = s.edit("Red Eye Removal Tool", |doc, active| {
|
||||
let canvas = doc.bounds();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id.or(*active), p)?;
|
||||
let layer = id.or(*active);
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, layer, p)?;
|
||||
let Some(&first) = pts.first() else { return Err(EngineError::Other(MISS.into())) };
|
||||
// The Background always locks transparency, so only a click on a clear pixel counts.
|
||||
let on_locked_clear = lock && surf.rgba(first.0, first.1)[3] <= 1.0e-5;
|
||||
let mut total = 0u32;
|
||||
let mut damage = Rect::EMPTY;
|
||||
for click in &pts {
|
||||
@@ -235,7 +252,7 @@ fn red_eye(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
damage = if damage.is_empty() { area } else { damage.union(&area) };
|
||||
}
|
||||
if total == 0 {
|
||||
return Err(EngineError::Other(MISS.into()));
|
||||
return if !on_locked_clear && runs_unchanged(doc, layer, p, first) { Ok((Rect::EMPTY, 0)) } else { Err(EngineError::Other(MISS.into())) };
|
||||
}
|
||||
Ok((damage, total))
|
||||
})?;
|
||||
@@ -337,8 +354,8 @@ mod tests {
|
||||
fn miss_and_bad_params_are_errors() {
|
||||
let mut s = session(32, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [0.1, 0.2, 0.8, 1.0]);
|
||||
let err = s.execute("paint.redEye", json!({"x": 16, "y": 16})).unwrap_err().to_string();
|
||||
assert!(err.contains(MISS), "{err}");
|
||||
let r = s.execute("paint.redEye", json!({"x": 16, "y": 16})).unwrap();
|
||||
assert_eq!(r["pixels"], 0, "a miss runs without changing a pixel: {r}");
|
||||
assert!(s.execute("paint.redEye", json!({})).is_err());
|
||||
assert!(s.execute("paint.redEye", json!({"x": null, "y": 1})).is_err());
|
||||
let pts: Vec<[i32; 2]> = (0..65).map(|i| [i, 0]).collect();
|
||||
@@ -439,3 +456,7 @@ mod tests {
|
||||
assert!(s.execute("paint.redEye", json!({"x": 12, "y": 12})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "redeye_history_state_tests.rs"]
|
||||
mod redeye_history_state_tests;
|
||||
@@ -0,0 +1,165 @@
|
||||
//! #12573: every Red Eye click that runs adds one History state named "Red Eye Removal Tool",
|
||||
//! also when it changes no pixel; clicks the tool refuses add none and stay errors.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
const STEP: &str = "Red Eye Removal Tool";
|
||||
|
||||
fn session(mode: &str) -> Session {
|
||||
session_at(mode, 8)
|
||||
}
|
||||
|
||||
fn session_at(mode: &str, depth: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 64, "height": 64, "mode": mode, "depth": depth})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn last(s: &Session) -> (usize, Option<String>) {
|
||||
let h = &s.active().unwrap().history;
|
||||
(h.past_len(), h.entries().last().map(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// The active layer's pixels, if it has any (a Multichannel document may not).
|
||||
fn pixels(s: &Session) -> Option<Vec<f32>> {
|
||||
let d = s.active().unwrap();
|
||||
let surf = d.doc.layer(d.active_layer?)?.surface()?;
|
||||
Some(surf.read_region(d.doc.bounds()))
|
||||
}
|
||||
|
||||
fn red_dot(s: &mut Session) {
|
||||
s.execute("paint.pencil", json!({"points": [[32, 32]], "size": 14, "hardness": 100, "color": "#f21818"})).unwrap();
|
||||
}
|
||||
|
||||
/// Runs `params`, which must change no pixel, and checks it leaves one unchanged state that
|
||||
/// Undo takes back and Redo puts back.
|
||||
fn unchanged_state(s: &mut Session, params: Value, case: &str) {
|
||||
let (n, _) = last(s);
|
||||
let before = pixels(s);
|
||||
let r = s.execute("paint.redEye", params).unwrap_or_else(|e| panic!("{case}: {e}"));
|
||||
assert_eq!(r["pixels"], 0, "{case}: {r}");
|
||||
assert_eq!(last(s), (n + 1, Some(STEP.to_string())), "{case}");
|
||||
assert!(pixels(s) == before, "{case}: no pixel changes");
|
||||
assert!(s.undo(), "{case}");
|
||||
assert_eq!(last(s).0, n, "{case}: undo removes the state");
|
||||
assert!(pixels(s) == before, "{case}");
|
||||
assert!(s.redo(), "{case}");
|
||||
assert_eq!(last(s), (n + 1, Some(STEP.to_string())), "{case}: redo");
|
||||
assert!(pixels(s) == before, "{case}: redo");
|
||||
}
|
||||
|
||||
fn refused(s: &mut Session, params: Value, case: &str) {
|
||||
let (n, _) = last(s);
|
||||
let before = pixels(s);
|
||||
assert!(s.execute("paint.redEye", params).is_err(), "{case}");
|
||||
assert_eq!(last(s).0, n, "{case}: no state");
|
||||
assert!(pixels(s) == before, "{case}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correcting_click_is_one_red_eye_removal_tool_state_that_undoes() {
|
||||
let mut s = session("rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
red_dot(&mut s);
|
||||
let (n, _) = last(&s);
|
||||
let before = pixels(&s);
|
||||
let r = s.execute("paint.redEye", json!({"x": 32, "y": 32})).unwrap();
|
||||
assert!(r["pixels"].as_u64().unwrap() > 0, "{r}");
|
||||
assert_eq!(last(&s), (n + 1, Some(STEP.to_string())));
|
||||
let after = pixels(&s);
|
||||
assert!(after != before);
|
||||
// A second click on the fixed eye is a second state.
|
||||
s.execute("paint.redEye", json!({"x": 32, "y": 32})).unwrap();
|
||||
assert_eq!(last(&s), (n + 2, Some(STEP.to_string())));
|
||||
assert!(s.undo() && s.undo());
|
||||
assert!(pixels(&s) == before, "undo restores the pixels");
|
||||
assert!(s.redo());
|
||||
assert!(pixels(&s) == after, "redo applies the click again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_that_changes_no_pixel_still_adds_one_state() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session_at("rgb", depth);
|
||||
// On the Background, which locks transparency.
|
||||
s.execute("edit.fill", json!({"color": "#283c78"})).unwrap();
|
||||
unchanged_state(&mut s, json!({"x": 32, "y": 32}), &format!("dark blue pupil, {depth} bit"));
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
unchanged_state(&mut s, json!({"x": 32, "y": 32}), &format!("empty transparent layer, {depth} bit"));
|
||||
red_dot(&mut s);
|
||||
unchanged_state(&mut s, json!({"x": 6, "y": 6, "pupilSize": 1}), &format!("transparent area of a layer, {depth} bit"));
|
||||
unchanged_state(&mut s, json!({"x": 32, "y": 32, "darken": 0}), &format!("Darken Amount 0, {depth} bit"));
|
||||
}
|
||||
let mut s = session("rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
red_dot(&mut s);
|
||||
unchanged_state(&mut s, json!({"points": [[6, 6], [58, 6], [6, 58]], "pupilSize": 1}), "a points batch");
|
||||
let mut lab = session("lab");
|
||||
lab.execute("edit.fill", json!({"color": "#283c78"})).unwrap();
|
||||
unchanged_state(&mut lab, json!({"x": 32, "y": 32}), "Lab document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_points_batch_is_one_state() {
|
||||
let mut s = session("rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
red_dot(&mut s);
|
||||
let (n, _) = last(&s);
|
||||
let before = pixels(&s);
|
||||
s.execute("paint.redEye", json!({"points": [[6, 6], [32, 32]], "pupilSize": 15})).unwrap();
|
||||
assert_eq!(last(&s), (n + 1, Some(STEP.to_string())));
|
||||
assert!(pixels(&s) != before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_clicks_add_no_state() {
|
||||
let mut s = session("rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
for (params, case) in [
|
||||
(json!({}), "no click"),
|
||||
(json!({"x": "nope", "y": 1}), "bad coordinates"),
|
||||
(json!({"points": []}), "empty batch"),
|
||||
(json!({"x": 8, "y": 8, "pupilSize": 10_000.0}), "window too large"),
|
||||
(json!({"x": 80, "y": 8}), "off the canvas"),
|
||||
] {
|
||||
refused(&mut s, params, case);
|
||||
}
|
||||
s.execute("select.rect", json!({"x": 40, "y": 40, "width": 10, "height": 10})).unwrap();
|
||||
refused(&mut s, json!({"x": 8, "y": 8}), "a click outside the selection");
|
||||
unchanged_state(&mut s, json!({"x": 44, "y": 44}), "a click inside the selection");
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap();
|
||||
refused(&mut s, json!({"x": 8, "y": 8}), "a hidden layer");
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "visible": true})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
|
||||
refused(&mut s, json!({"x": 8, "y": 8}), "a clear pixel under Lock transparent pixels");
|
||||
s.execute("layer.lockLayers", json!({"transparency": false})).unwrap();
|
||||
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
|
||||
refused(&mut s, json!({"x": 8, "y": 8}), "a locked layer");
|
||||
for mode in ["gray", "cmyk"] {
|
||||
let mut s = session(mode);
|
||||
s.execute("edit.fill", json!({"color": "#283c78"})).unwrap();
|
||||
refused(&mut s, json!({"x": 32, "y": 32}), mode);
|
||||
}
|
||||
for (convert, params) in
|
||||
[("image.mode.indexedColor", json!({"palette": "exact", "dither": "none", "forced": "none"})), ("image.mode.multichannel", json!({}))]
|
||||
{
|
||||
let mut s = session("rgb");
|
||||
s.execute("edit.fill", json!({"color": "#283c78"})).unwrap();
|
||||
s.execute(convert, params).unwrap();
|
||||
refused(&mut s, json!({"x": 32, "y": 32}), convert);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_and_quick_mask_targets_keep_the_miss() {
|
||||
let mut s = session("rgb");
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
refused(&mut s, json!({"x": 32, "y": 32, "target": {"channel": 0}}), "an alpha channel");
|
||||
let mut s = session("rgb");
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
refused(&mut s, json!({"x": 32, "y": 32, "target": "quickMask"}), "the Quick Mask");
|
||||
}
|
||||
@@ -142,6 +142,11 @@ pub fn stroke_brush(base: &BrushSettings, p: &Value) -> BrushSettings {
|
||||
flow: pct("flow", 100.0, 1.0, 100.0),
|
||||
// Brush Settings › Brush Tip Shape › Spacing, as the Brush uses it (#2140).
|
||||
spacing: pct("spacing", base.spacing * 100.0, 1.0, 1000.0),
|
||||
// The options bar's Smoothing; the UI sends 0 for a ⇧-click line, which isn't smoothed (#11887).
|
||||
smoothing: match p.get("smoothing") {
|
||||
Some(_) => photocraft_paint::Smoothing { enabled: true, amount: pct("smoothing", 0.0, 0.0, 100.0), ..base.smoothing.clone() },
|
||||
None => base.smoothing.clone(),
|
||||
},
|
||||
erase: false,
|
||||
..base.clone()
|
||||
}
|
||||
@@ -164,7 +169,22 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option<Laye
|
||||
return Err(bad(cmd, "`points` is empty"));
|
||||
}
|
||||
crate::brush_cmds::check_coords(&pts, cmd)?;
|
||||
let brush = stroke_brush(&s.tools.brush, p);
|
||||
// A History Brush stroke carries the brush it was pressed with (`"brush"`, deep-merged onto the
|
||||
// session brush as `paint.stroke`'s is): a tool key mid-stroke changes the session brush, and
|
||||
// the commit, its fallback and a replayed action must all paint what the press began.
|
||||
let base = match p.get("brush").filter(|v| v.is_object()) {
|
||||
Some(patch) if cmd == "paint.historyBrush" => {
|
||||
let mut b = crate::brush_cmds::merge_brush(&s.tools.brush, patch, cmd)?;
|
||||
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
|
||||
b
|
||||
}
|
||||
_ => s.tools.brush.clone(),
|
||||
};
|
||||
let mut brush = stroke_brush(&base, p);
|
||||
// The Eraser's Erase to History in Pencil or Block mode paints back through that hard tip.
|
||||
if flag(p, "eraseToHistory", false) && (flag(p, "pencil", false) || flag(p, "block", false)) {
|
||||
brush = crate::brush_cmds::pencil_shape(brush, p)?;
|
||||
}
|
||||
crate::brush_cmds::validate_brush(&brush, cmd)?;
|
||||
if crate::channel_cmds::is_channel_target(p) {
|
||||
return Ok((Stroke { brush, points: pts }, None));
|
||||
@@ -205,7 +225,7 @@ fn damage_json(r: Rect) -> Value {
|
||||
/// The brush every retouching command shares, plus the tool's own params.
|
||||
macro_rules! brush_params {
|
||||
($extra:literal) => {
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"smoothing":0..100=options bar Smoothing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,7 +394,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"#
|
||||
r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error),"eraseToHistory":bool=false (a layer missing from a source state that holds a single layer paints that layer's own pixels, or a shape's solid colour, else white, outside its shape too, as the Eraser's Erase to History does, instead of an error),"pencil":bool=false and "block":bool=false with "zoom" (with eraseToHistory: the hard aliased tip or the square, as paint.pencil's),"brush":{…}? (BrushSettings fields, deep-merged onto the session brush as paint.stroke's: the brush the stroke was pressed with, so a tool picked mid-stroke changes nothing)"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: history_brush,
|
||||
@@ -386,7 +406,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(
|
||||
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0,"opacity":0..100=100 (overrides the common 1..100: how strongly the history state is painted; 0 changes nothing; no flow, mode is Normal)"#
|
||||
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size or colour mode is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0,"opacity":0..100=100 (overrides the common 1..100: how strongly the history state is painted; 0 changes nothing; no flow, mode is Normal)"#
|
||||
),
|
||||
enabled: has_pixel_layer,
|
||||
run: art_history::run,
|
||||
@@ -400,3 +420,6 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod patch_history_names_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod remove_history_name_tests;
|
||||
@@ -195,7 +195,15 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
let _ = stroke;
|
||||
// The same source as the History Brush: the `state` or `snapshot` passed, else the History
|
||||
// panel's source, refused after a canvas size change.
|
||||
// panel's source, refused after a canvas size change, then after a colour mode change, as
|
||||
// Photoshop does (#12826), before a layer missing from it. Compare document modes: the pixel
|
||||
// format reads Indexed and Multichannel as RGB.
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
if crate::history_source_cmds::paint_source(st, p, CMD)?.mode != st.doc.mode {
|
||||
return Err(EngineError::Other(
|
||||
"the current color mode does not match that of the History Brush source: set a source of this color mode in the History panel".into(),
|
||||
));
|
||||
}
|
||||
let src_surface = super::history_source(s, id, p, CMD)?;
|
||||
// Truncating a huge row index only changes the seed.
|
||||
let seed = RNG_SEED ^ p.get("state").and_then(Value::as_u64).map_or(0, |i| i as u32);
|
||||
@@ -215,8 +223,8 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(err("stroke too large"));
|
||||
}
|
||||
|
||||
// Nothing written (every dab under the tolerance, outside the selection or on locked
|
||||
// transparency): a no-op stroke, with no history step.
|
||||
// A stroke that writes nothing even at tolerance 0 (wholly outside the selection, locked
|
||||
// transparency) is a quiet no-op; one the tolerance alone holds back adds an unchanged state (#12636).
|
||||
let mut nothing_written = false;
|
||||
let dmg = run_stroke(s, "Art History Brush", id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
@@ -232,6 +240,10 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sel_mask = selection_mask(sel, outer);
|
||||
let n = stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, tolerance, sel_mask.as_deref(), lock, alpha_index(&fmt))?;
|
||||
if n == 0 {
|
||||
// `dest` is unchanged, so the same stroke at tolerance 0 tells whether it would paint.
|
||||
if tolerance > 0.0 && stamp_region(&mut dest, &src_reg, &dabs, hardness, opacity, flow, 0.0, sel_mask.as_deref(), lock, alpha_index(&fmt))? > 0 {
|
||||
return Ok(Rect::EMPTY);
|
||||
}
|
||||
nothing_written = true;
|
||||
return Err(err("the stroke changed no pixel"));
|
||||
}
|
||||
@@ -242,7 +254,7 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Err(_) if nothing_written => return Ok(json!({ "damage": Value::Null })),
|
||||
r => r?,
|
||||
};
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
Ok(json!({ "damage": if dmg.is_empty() { Value::Null } else { damage_json(dmg) } }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -331,14 +343,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_under_the_tolerance_records_no_step() {
|
||||
fn stroke_under_the_tolerance_records_an_unchanged_step() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
paint_layer(&mut s, |_, _| [0.5, 0.5, 0.5, 1.0]);
|
||||
paint_layer(&mut s, |_, _| [0.6, 0.6, 0.6, 1.0]);
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = stroke(&mut s, json!({"state": 1, "tolerance": 50})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps);
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Art History Brush"));
|
||||
assert!((rgba(&s, 24, 16)[0] - 0.6).abs() < 0.01);
|
||||
}
|
||||
|
||||
@@ -388,7 +401,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// A stroke that writes nothing (wholly outside the selection, or on transparent pixels
|
||||
/// with Lock Transparency) does nothing, as with the tolerance: no error, no History step.
|
||||
/// with Lock Transparency) does nothing, unlike one the tolerance holds back: no error, no
|
||||
/// History step.
|
||||
#[test]
|
||||
fn a_stroke_that_writes_nothing_is_a_quiet_no_op() {
|
||||
let mut s = session(48, 32, 8, "rgb");
|
||||
@@ -525,3 +539,11 @@ mod tests {
|
||||
assert!(s.is_enabled("paint.artHistoryBrush"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "art_history_noop_state_tests.rs"]
|
||||
mod art_history_noop_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "art_history_mode_mismatch_tests.rs"]
|
||||
mod art_history_mode_mismatch_tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! The Art History Brush refuses a source state from before Image › Mode changed the document's
|
||||
//! colour mode, as Photoshop does, for a normal stroke and for a click the tolerance holds back:
|
||||
//! no state, no pixel change (#12826).
|
||||
|
||||
use super::art_history_noop_state_tests::{held_back_session, pixels, steps, stroke};
|
||||
use super::*;
|
||||
|
||||
const MODES: [&str; 3] = ["image.mode.grayscale", "image.mode.cmyk", "image.mode.lab"];
|
||||
|
||||
/// Indexed Color flattens the document, so the source no longer holds the active layer: the
|
||||
/// refusal still names the colour mode, which its RGB pixel format would hide. (Multichannel leaves
|
||||
/// no layer, so the command is unavailable there before any source is read.)
|
||||
#[test]
|
||||
fn a_source_of_another_color_mode_is_refused() {
|
||||
let cases = [8u64, 16].into_iter().flat_map(|depth| MODES.map(move |mode| (depth, mode)));
|
||||
for (depth, mode) in cases.chain([(8, "image.mode.indexedColor")]) {
|
||||
// Tolerance 0 paints; 90 holds this small change back, which #12636 records as a state.
|
||||
for tolerance in [0, 90] {
|
||||
// Row 1 (RGB) passed, and the History panel's default source, the RGB Open state.
|
||||
for state in [json!(1), Value::Null] {
|
||||
let mut s = held_back_session(depth);
|
||||
s.execute(mode, json!({})).unwrap();
|
||||
let (before, n) = (s.active().unwrap().doc.clone(), steps(&s));
|
||||
let case = format!("depth {depth} {mode} tolerance {tolerance} state {state}");
|
||||
let e = stroke(&mut s, json!({"tolerance": tolerance, "state": state})).unwrap_err().to_string();
|
||||
assert!(e.contains("color mode does not match"), "{case}: {e}");
|
||||
assert_eq!(steps(&s), n, "{case}: no state");
|
||||
assert!(s.active().unwrap().doc == before, "{case}: no pixel changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_source_in_the_current_color_mode_still_paints() {
|
||||
for depth in [8u64, 16] {
|
||||
for mode in MODES {
|
||||
let mut s = held_back_session(depth);
|
||||
s.execute(mode, json!({})).unwrap();
|
||||
let row = s.active().unwrap().history.entries().len() - 1;
|
||||
s.execute("edit.fill", json!({"color": "#ffffff"})).unwrap();
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let r = stroke(&mut s, json!({"tolerance": 0, "state": row})).unwrap();
|
||||
assert!(!r["damage"].is_null(), "depth {depth} {mode}: {r}");
|
||||
assert_eq!(steps(&s), n + 1, "depth {depth} {mode}");
|
||||
assert_ne!(pixels(&s), before, "depth {depth} {mode}: the source state painted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the colour mode is refused: a source of another bit depth converts as before (not measured
|
||||
/// for this brush; today's behaviour).
|
||||
#[test]
|
||||
fn a_source_of_another_bit_depth_still_paints() {
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("image.mode.bits16", json!({})).unwrap();
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let r = stroke(&mut s, json!({"tolerance": 0})).unwrap();
|
||||
assert!(!r["damage"].is_null(), "{r}");
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_ne!(pixels(&s), before);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! An Art History Brush click the tolerance holds back still adds one unchanged "Art History
|
||||
//! Brush" state, as in Photoshop; refusals and strokes that could write nothing add none (#12636).
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session(depth: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 48, "height": 32, "depth": depth, "mode": "rgb"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn fill_gray(s: &mut Session, v: f32) {
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let px = photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]);
|
||||
let data: Vec<f32> = (0..b.width() * b.height()).flat_map(|_| px.iter().copied()).collect();
|
||||
surf.write_region(b, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// History row 1 is grey 0.5, the layer now grey 0.6: a small change every high tolerance holds back.
|
||||
pub(super) fn held_back_session(depth: u64) -> Session {
|
||||
let mut s = session(depth);
|
||||
fill_gray(&mut s, 0.5);
|
||||
fill_gray(&mut s, 0.6);
|
||||
s
|
||||
}
|
||||
|
||||
pub(super) fn pixels(s: &Session) -> Vec<f32> {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().read_region(d.doc.bounds())
|
||||
}
|
||||
|
||||
pub(super) fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
pub(super) fn stroke(s: &mut Session, extra: Value) -> Result<Value> {
|
||||
let mut p = json!({"points": [[8, 16], [40, 16]], "size": 12, "hardness": 100, "area": 50, "style": "tightMedium", "state": 1, "tolerance": 90});
|
||||
if let Some(map) = extra.as_object() {
|
||||
for (k, v) in map {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
s.execute(CMD, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_held_back_by_the_tolerance_adds_one_unchanged_state() {
|
||||
for depth in [8u64, 16, 32] {
|
||||
for tolerance in [90, 95, 99, 100] {
|
||||
for points in [json!([[24, 16]]), json!([[8, 16], [40, 16]])] {
|
||||
let mut s = held_back_session(depth);
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let r = stroke(&mut s, json!({"tolerance": tolerance, "points": points})).unwrap();
|
||||
let case = format!("depth {depth} tolerance {tolerance} points {points}");
|
||||
assert!(r["damage"].is_null(), "{case}: {r}");
|
||||
assert_eq!(steps(&s), n + 1, "{case}: one state");
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Art History Brush"), "{case}");
|
||||
assert_eq!(pixels(&s), before, "{case}: no pixel changed");
|
||||
assert!(s.undo(), "{case}");
|
||||
assert_eq!(steps(&s), n, "{case}: undo removes the state");
|
||||
assert_eq!(pixels(&s), before, "{case}: undo leaves the layer as it was");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_under_the_tolerance_add_no_state() {
|
||||
let refused = |name: &str, s: &mut Session, extra: Value| {
|
||||
let n = steps(s);
|
||||
assert!(stroke(s, extra).is_err(), "{name} is refused");
|
||||
assert_eq!(steps(s), n, "{name} adds no state");
|
||||
};
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("layer.setProps", json!({"visible": false})).unwrap();
|
||||
refused("a hidden layer", &mut s, json!({}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("layer.setProps", json!({"locks": {"pixels": true}})).unwrap();
|
||||
refused("a locked layer", &mut s, json!({}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("layer.new.group", json!({})).unwrap();
|
||||
refused("a group", &mut s, json!({}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
refused("a missing state", &mut s, json!({"state": 99}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
refused("a missing snapshot", &mut s, json!({"state": null, "snapshot": 99}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
let snapshot = s.execute("history.newSnapshot", json!({})).unwrap()["snapshot"].clone();
|
||||
s.execute("history.setSource", json!({"snapshot": snapshot})).unwrap();
|
||||
s.execute("history.deleteSnapshot", json!({"snapshot": snapshot})).unwrap();
|
||||
refused("a deleted History panel source", &mut s, json!({"state": null}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("image.canvasSize", json!({"width": 60, "height": 40, "anchor": "center"})).unwrap();
|
||||
refused("a source of another canvas size", &mut s, json!({}));
|
||||
|
||||
let mut s = held_back_session(8);
|
||||
refused("another blend mode", &mut s, json!({"mode": "multiply"}));
|
||||
}
|
||||
|
||||
/// Not measured in Photoshop, so these keep the behaviour they had: no state, no error.
|
||||
#[test]
|
||||
fn strokes_that_can_write_nothing_stay_quiet() {
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let r = stroke(&mut s, json!({})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(steps(&s), n, "wholly outside the selection");
|
||||
assert_eq!(pixels(&s), before);
|
||||
|
||||
let mut s = session(8);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("layer.setProps", json!({"locks": {"transparency": true}})).unwrap();
|
||||
let n = steps(&s);
|
||||
// Row 1 is the new transparent layer: at tolerance 0 the stroke would write here, but Lock Transparency skips every pixel.
|
||||
let r = stroke(&mut s, json!({})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(steps(&s), n, "locked transparent pixels");
|
||||
}
|
||||
|
||||
/// A selection across part of the stroke: at tolerance 0 the stroke would paint inside it, so the
|
||||
/// click the tolerance holds back adds its unchanged state.
|
||||
#[test]
|
||||
fn a_held_back_click_across_part_of_the_selection_adds_one_unchanged_state() {
|
||||
let mut s = held_back_session(8);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 24, "height": 32})).unwrap();
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let r = stroke(&mut s, json!({})).unwrap();
|
||||
assert!(r["damage"].is_null(), "{r}");
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(s.active().unwrap().history.undo_label(), Some("Art History Brush"));
|
||||
assert_eq!(pixels(&s), before);
|
||||
}
|
||||
@@ -265,9 +265,26 @@ impl DabTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference application refuses a Dodge stroke in Indexed Color and Bitmap documents, with
|
||||
/// its alert's words (#12686); other modes (Multichannel and Duotone were not measured) tone as
|
||||
/// before. The live stroke checks this too, so it previews nothing there.
|
||||
fn mode_refusal(s: &Session, cmd: &str) -> Result<()> {
|
||||
let tool = match cmd {
|
||||
"paint.dodge" => "dodge tool",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let why = match s.active().map(|st| st.doc.mode) {
|
||||
Some(photocraft_color::ColorMode::Indexed) => "it does not work with Indexed Color images (convert image to RGB Color mode to edit).",
|
||||
Some(photocraft_color::ColorMode::Bitmap) => "it does not work with Bitmap images (convert image to Grayscale mode to edit).",
|
||||
_ => return Ok(()),
|
||||
};
|
||||
Err(EngineError::Other(format!("Could not use the {tool} because {why}")))
|
||||
}
|
||||
|
||||
/// Dodge, Burn, Sponge, Blur, Sharpen and Smudge: each dab edits a working copy in order
|
||||
/// (`apply_dab_stroke`). Blur, Sharpen and Smudge can sample all layers.
|
||||
pub(super) fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
mode_refusal(s, cmd)?;
|
||||
let (stroke, id) = parse_brush(s, p, cmd)?;
|
||||
let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?;
|
||||
let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p);
|
||||
@@ -418,6 +435,7 @@ pub struct LiveDab {
|
||||
impl LiveDab {
|
||||
/// Start a live stroke of `cmd` from its params; their `points` are rendered.
|
||||
pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||||
mode_refusal(s, cmd)?;
|
||||
has_pixel_layer(s).map_err(EngineError::Other)?;
|
||||
if sample_all_layers(p) {
|
||||
return Err(bad(cmd, "Sample All Layers strokes aren't previewed live"));
|
||||
@@ -673,3 +691,7 @@ impl LiveDab {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dodge_refuses_indexed_tests.rs"]
|
||||
mod dodge_refuses_indexed_tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
//! #12686: the Dodge tool refuses Indexed Color and Bitmap documents with the reference
|
||||
//! application's messages, toning nothing and adding no history; the live stroke is refused the
|
||||
//! same way. RGB, Grayscale, CMYK and Lab at every depth, Multichannel and Duotone keep dodging.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Size};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const INDEXED: &str = "Could not use the dodge tool because it does not work with Indexed Color images (convert image to RGB Color mode to edit).";
|
||||
const BITMAP: &str = "Could not use the dodge tool because it does not work with Bitmap images (convert image to Grayscale mode to edit).";
|
||||
|
||||
/// A session with one 64 × 64 document of `mode` and `depth`, filled mid grey.
|
||||
fn session(mode: ColorMode, depth: SampleType) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.add_document(Document::with_background("t", Size::new(64, 64), mode, depth, Color::rgb(0.5, 0.5, 0.5)), None);
|
||||
s
|
||||
}
|
||||
|
||||
fn stroke() -> Value {
|
||||
json!({"points": [[20, 32], [44, 32]], "size": 20, "hardness": 100, "range": "midtones", "exposure": 100})
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn pixels(s: &Session) -> Vec<f32> {
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layers[0].surface().unwrap();
|
||||
(0..64).flat_map(|y| (0..64).flat_map(move |x| surf.pixel(x, y))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_and_bitmap_documents_refuse_with_the_reference_words() {
|
||||
for (mode, want) in [(ColorMode::Indexed, INDEXED), (ColorMode::Bitmap, BITMAP)] {
|
||||
let mut s = session(mode, SampleType::U8);
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
let err = s.execute("paint.dodge", stroke()).unwrap_err().to_string();
|
||||
assert_eq!(err, want, "{mode:?}");
|
||||
assert_eq!(pixels(&s), before, "{mode:?}: nothing toned");
|
||||
assert_eq!(steps(&s), n, "{mode:?}: no history");
|
||||
// The live stroke previews nothing either.
|
||||
let live = LiveDab::begin(&s, "paint.dodge", &stroke()).err().map(|e| e.to_string());
|
||||
assert_eq!(live.as_deref(), Some(want), "{mode:?}: live");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_modes_keep_dodging_at_every_depth() {
|
||||
let mut cases = Vec::new();
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
cases.push((mode, depth));
|
||||
}
|
||||
}
|
||||
// Not measured in the reference: they keep today's behaviour.
|
||||
cases.extend([(ColorMode::Multichannel, SampleType::U8), (ColorMode::Duotone, SampleType::U8)]);
|
||||
for (mode, depth) in cases {
|
||||
let mut s = session(mode, depth);
|
||||
let (before, n) = (pixels(&s), steps(&s));
|
||||
s.execute("paint.dodge", stroke()).unwrap_or_else(|e| panic!("{mode:?}/{depth:?}: {e}"));
|
||||
assert_ne!(pixels(&s), before, "{mode:?}/{depth:?}: dodged");
|
||||
assert_eq!(steps(&s), n + 1, "{mode:?}/{depth:?}: one history step");
|
||||
assert!(LiveDab::begin(&s, "paint.dodge", &stroke()).is_ok(), "{mode:?}/{depth:?}: live");
|
||||
}
|
||||
}
|
||||
@@ -86,14 +86,46 @@ pub(super) fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in History
|
||||
/// panel row `state` or snapshot `snapshot` (default: the source set in the History panel,
|
||||
/// `history.setSource`, else the oldest state still held, the "Open" state unless trimmed).
|
||||
/// A layer that did not exist in that state has no pixels there, and the History Brush refuses it
|
||||
/// as Photoshop's does. `eraseToHistory` (the Eraser's Erase to History option) paints such a layer
|
||||
/// with that layer's own pixels (its opacity not applied) from a state holding a single layer,
|
||||
/// Background or not, or past its path with a shape's solid colour, else white, and refuses it
|
||||
/// from any other state: observed in Photoshop 27.11.0, where a state with a second layer, even an
|
||||
/// empty one, gives an error.
|
||||
pub(super) fn history_source(s: &Session, id: Option<LayerId>, p: &Value, cmd: &str) -> Result<Surface> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let source_doc = crate::history_source_cmds::paint_source(st, p, cmd)?;
|
||||
crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p)
|
||||
.cloned()
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))
|
||||
let missing = || EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into());
|
||||
match crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p) {
|
||||
Ok(surf) => Ok(surf.clone()),
|
||||
Err(_)
|
||||
if flag(p, "eraseToHistory", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels && source_doc.layers.len() == 1 =>
|
||||
{
|
||||
let fmt = id.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()).map(Surface::format).ok_or_else(missing)?;
|
||||
let only = source_doc.layers.first().ok_or_else(missing)?;
|
||||
// A shape paints past its path, as in Photoshop: a solid fill its colour, and a gradient,
|
||||
// a pattern or no fill plain white, whatever its outline.
|
||||
if let photocraft_doc::LayerContent::Shape(shape) = &only.content {
|
||||
let color = match &shape.fill {
|
||||
Some(photocraft_doc::Fill::Solid(color)) => *color,
|
||||
_ => photocraft_color::Color::WHITE,
|
||||
};
|
||||
let c = color.in_mode(fmt.mode);
|
||||
let mut pixel = c.c.get(..fmt.mode.color_channels()).unwrap_or_default().to_vec();
|
||||
if fmt.alpha {
|
||||
pixel.push(c.alpha);
|
||||
}
|
||||
return Ok(Surface::with_default(fmt, &pixel));
|
||||
}
|
||||
let only = only.surface().ok_or_else(missing)?;
|
||||
Ok(if only.format() == fmt { only.clone() } else { only.convert(fmt) })
|
||||
}
|
||||
Err(_) => Err(missing()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `paint.historyBrush`: paints the stroke from the `history_source` surface. With `eraseToHistory`
|
||||
/// it is the Eraser's Erase to History, and its History row says History Eraser.
|
||||
pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "paint.historyBrush";
|
||||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||||
@@ -105,7 +137,8 @@ pub(super) fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points));
|
||||
let from_live = live.is_some();
|
||||
let src_surface = history_source(s, id, p, CMD)?;
|
||||
let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| {
|
||||
let label = if flag(p, "eraseToHistory", false) { "History Eraser" } else { "History Brush" };
|
||||
let dmg = run_stroke(s, label, id, p, |_, surf, sel, lock| {
|
||||
if let Some(l) = live {
|
||||
*surf = l.surface;
|
||||
return Ok(l.bounds);
|
||||
|
||||
@@ -24,6 +24,10 @@ use photocraft_doc::Layer;
|
||||
|
||||
const CMD: &str = "paint.remove";
|
||||
|
||||
/// The History step of an applied removal, as Photoshop names it (#12541). The job and its
|
||||
/// progress keep the command's label.
|
||||
const HISTORY: &str = "Remove Tool";
|
||||
|
||||
/// Largest stroke, in pixels of its bounding box (as the Patch Tool's and Content-Aware Move's).
|
||||
/// Checked before the stroke's coverage is rendered, which allocates its whole bounding box, also
|
||||
/// where it leaves the canvas.
|
||||
@@ -217,13 +221,20 @@ fn fill_to_new_layer(doc: &mut Document, id: Option<LayerId>, out: &Region, hole
|
||||
*a = if out.ch == n { src.last().copied().unwrap_or(1.0) } else { 1.0 };
|
||||
}
|
||||
}
|
||||
let mut l = Layer::raster(doc.next_layer_name("Layer"), fmt);
|
||||
let mut l = Layer::raster(result_layer_name(doc), fmt);
|
||||
crate::pixels_mut(&mut l)?.write_region(out.rect, &data);
|
||||
// Above the sampled layer and in its clipping mask, like Layer via Copy.
|
||||
let nid = crate::layer_place::insert_new(doc, id, l, crate::layer_place::Place::Above);
|
||||
Ok((out.rect, nid))
|
||||
}
|
||||
|
||||
/// The new layer's name, as Photoshop names it (#12542): "Remove tool edits" while it is free,
|
||||
/// then the first free "Remove tool edits N" from 1.
|
||||
fn result_layer_name(doc: &Document) -> String {
|
||||
const BASE: &str = "Remove tool edits";
|
||||
if doc.walk().into_iter().any(|(_, _, l)| l.name == BASE) { doc.next_layer_name(BASE) } else { BASE.to_string() }
|
||||
}
|
||||
|
||||
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plan = plan(s, p)?;
|
||||
let label = "Remove";
|
||||
@@ -247,7 +258,7 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move |s, filled| {
|
||||
let Some((out, hole)) = filled else { return Ok(json!({"changed": false})) };
|
||||
if new_layer {
|
||||
let (damage, nid) = s.edit(label, |doc, active| {
|
||||
let (damage, nid) = s.edit(HISTORY, |doc, active| {
|
||||
let r = fill_to_new_layer(doc, id, &out, &hole)?;
|
||||
*active = Some(r.1);
|
||||
Ok(r)
|
||||
@@ -255,9 +266,13 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.hint_damage(damage);
|
||||
return Ok(json!({ "damage": damage_json(damage), "layer": nid.0 }));
|
||||
}
|
||||
let damage = s.edit(label, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?;
|
||||
let damage = s.edit(HISTORY, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?;
|
||||
s.hint_damage(damage);
|
||||
Ok(json!({ "damage": damage_json(damage) }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "remove_new_layer_name_tests.rs"]
|
||||
mod remove_new_layer_name_tests;
|
||||
@@ -0,0 +1,92 @@
|
||||
//! History step name of the Remove Tool (#12541): every applied removal adds exactly one step
|
||||
//! named "Remove Tool", whatever the strokes, options, colour mode or bit depth.
|
||||
|
||||
use super::*;
|
||||
use crate::jobs::Started;
|
||||
|
||||
/// A flat grey layer with a dark square at 40..52 × 24..36.
|
||||
fn square_session(depth: u64, mode: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 96, "height": 64, "depth": depth, "mode": mode})).unwrap();
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let v = if (40..52).contains(&x) && (24..36).contains(&y) { 0.05 } else { 0.5 };
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn past(s: &Session) -> usize {
|
||||
s.active().unwrap().history.past_len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> Option<String> {
|
||||
s.active().unwrap().history.undo_label().map(str::to_string)
|
||||
}
|
||||
|
||||
const STROKE: [[i32; 2]; 2] = [[38, 30], [54, 30]];
|
||||
|
||||
#[test]
|
||||
fn one_stroke_is_one_step_named_remove_tool_in_every_mode_and_depth() {
|
||||
for (depth, mode) in [(8, "rgb"), (16, "rgb"), (32, "rgb"), (8, "cmyk"), (16, "gray"), (16, "lab")] {
|
||||
let mut s = square_session(depth, mode);
|
||||
let steps = past(&s);
|
||||
s.execute("paint.remove", json!({"points": STROKE, "size": 16})).unwrap();
|
||||
assert_eq!(past(&s), steps + 1, "{mode}/{depth}: one step");
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"), "{mode}/{depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_strokes_applied_together_are_one_step_named_remove_tool() {
|
||||
let mut s = square_session(8, "rgb");
|
||||
let steps = past(&s);
|
||||
s.execute("paint.remove", json!({"strokes": [[[38, 30], [45, 30]], [[47, 30], [54, 30]]], "size": 16})).unwrap();
|
||||
assert_eq!(past(&s), steps + 1);
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_all_layers_and_create_new_layer_are_one_step_named_remove_tool() {
|
||||
let mut s = square_session(8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let steps = past(&s);
|
||||
s.execute("paint.remove", json!({"points": STROKE, "size": 16, "sampleAllLayers": true})).unwrap();
|
||||
assert_eq!(past(&s), steps + 1, "sampleAllLayers");
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"), "sampleAllLayers");
|
||||
|
||||
let mut s = square_session(8, "rgb");
|
||||
let layers = s.active().unwrap().doc.layer_count();
|
||||
let steps = past(&s);
|
||||
let v = s.execute("paint.remove", json!({"points": STROKE, "size": 16, "newLayer": true})).unwrap();
|
||||
assert!(v["layer"].is_u64(), "{v}");
|
||||
assert_eq!(s.active().unwrap().doc.layer_count(), layers + 1, "newLayer adds the result layer");
|
||||
assert_eq!(past(&s), steps + 1, "newLayer");
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"), "newLayer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_job_records_remove_tool_and_undo_redo_keep_the_name() {
|
||||
let mut s = square_session(8, "rgb");
|
||||
let steps = past(&s);
|
||||
let Started::Job(job) = s.start("paint.remove", json!({"points": STROKE, "size": 16})).unwrap() else {
|
||||
panic!("paint.remove runs as a background job");
|
||||
};
|
||||
s.wait_job(job).unwrap();
|
||||
assert_eq!(past(&s), steps + 1);
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.redo_label(), Some("Remove Tool"));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(last_step(&s).as_deref(), Some("Remove Tool"));
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Name of the Remove Tool's result layer (#12542): with Create new layer on, the first is
|
||||
//! "Remove tool edits", the next "Remove tool edits 1", "Remove tool edits 2"…; placement, the
|
||||
//! active layer and the pixels are as before, and with it off no layer is added.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A flat grey layer with a dark square at 40..52 × 24..36.
|
||||
fn square_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 96, "height": 64, "depth": 8, "mode": "rgb"})).unwrap();
|
||||
s.edit("setup", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
let v = if (40..52).contains(&x) && (24..36).contains(&y) { 0.05 } else { 0.5 };
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, [v, v, v, 1.0]));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
const STROKE: [[i32; 2]; 2] = [[38, 30], [54, 30]];
|
||||
|
||||
/// One stroke with Create new layer. Sample All Layers, as a result layer filled from its own
|
||||
/// transparent surroundings would leave the next stroke on it nothing to remove.
|
||||
fn remove_to_new_layer(s: &mut Session) -> LayerId {
|
||||
let r = s.execute("paint.remove", json!({"points": STROKE, "size": 16, "newLayer": true, "sampleAllLayers": true})).unwrap();
|
||||
LayerId(r["layer"].as_u64().unwrap_or_else(|| panic!("no new layer: {r}")))
|
||||
}
|
||||
|
||||
fn name(s: &Session, id: LayerId) -> String {
|
||||
s.active().unwrap().doc.layer(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<LayerId> {
|
||||
s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect()
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, id: LayerId, to: &str) {
|
||||
s.edit("rename", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().name = to.to_string();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successive_strokes_name_remove_tool_edits_then_1_then_2_each_above_the_active_layer() {
|
||||
let mut s = square_session();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
let before = s.active().unwrap().doc.layer(source).unwrap().surface().unwrap().rgba(46, 30);
|
||||
let mut stack = vec![source];
|
||||
for want in ["Remove tool edits", "Remove tool edits 1", "Remove tool edits 2"] {
|
||||
let nid = remove_to_new_layer(&mut s);
|
||||
stack.push(nid);
|
||||
assert_eq!(name(&s, nid), want);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(nid), "{want}: the new layer is active");
|
||||
assert_eq!(order(&s), stack, "{want}: a new layer directly above the then active one");
|
||||
}
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(d.doc.layer(source).unwrap().surface().unwrap().rgba(46, 30), before, "the source layer is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_strokes_without_sample_all_layers_name_remove_tool_edits_then_1() {
|
||||
let mut s = square_session();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
let mut stack = vec![source];
|
||||
for want in ["Remove tool edits", "Remove tool edits 1"] {
|
||||
let r = s.execute("paint.remove", json!({"points": STROKE, "size": 16, "newLayer": true})).unwrap();
|
||||
let nid = LayerId(r["layer"].as_u64().unwrap_or_else(|| panic!("no new layer: {r}")));
|
||||
stack.push(nid);
|
||||
assert_eq!(name(&s, nid), want);
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(nid), "{want}: the new layer is active");
|
||||
assert_eq!(order(&s), stack, "{want}: a new layer directly above the then active one");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_frees_the_name_for_the_next_stroke() {
|
||||
let mut s = square_session();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
let nid = remove_to_new_layer(&mut s);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(nid).is_none(), "undo removes the layer");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(source));
|
||||
let again = remove_to_new_layer(&mut s);
|
||||
assert_eq!(name(&s, again), "Remove tool edits");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(name(&s, again), "Remove tool edits", "redo brings the layer back with its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taken_name_gives_the_first_free_one() {
|
||||
let mut s = square_session();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
rename(&mut s, source, "Remove tool edits");
|
||||
let nid = remove_to_new_layer(&mut s);
|
||||
assert_eq!(name(&s, nid), "Remove tool edits 1");
|
||||
|
||||
// A gap left by a renamed result layer is filled first.
|
||||
let mut s = square_session();
|
||||
let first = remove_to_new_layer(&mut s);
|
||||
let second = remove_to_new_layer(&mut s);
|
||||
assert_eq!(name(&s, second), "Remove tool edits 1");
|
||||
rename(&mut s, second, "Kept");
|
||||
let nid = remove_to_new_layer(&mut s);
|
||||
assert_eq!(name(&s, nid), "Remove tool edits 1");
|
||||
rename(&mut s, first, "Other");
|
||||
let nid = remove_to_new_layer(&mut s);
|
||||
assert_eq!(name(&s, nid), "Remove tool edits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_create_new_layer_the_fill_goes_into_the_active_layer() {
|
||||
let mut s = square_session();
|
||||
let source = s.active().unwrap().active_layer.unwrap();
|
||||
let r = s.execute("paint.remove", json!({"points": STROKE, "size": 16})).unwrap();
|
||||
assert!(r.get("layer").is_none(), "{r}");
|
||||
assert_eq!(order(&s), vec![source], "no new layer");
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(d.active_layer, Some(source));
|
||||
let px = d.doc.layer(source).unwrap().surface().unwrap().rgba(46, 30);
|
||||
assert!((px[0] - 0.5).abs() < 0.03, "the square is removed in place: {px:?}");
|
||||
}
|
||||
@@ -668,6 +668,244 @@ fn history_brush_refuses_a_locked_destination_when_source_is_unlocked() {
|
||||
assert!(pixel[0] > 0.99 && pixel[1] < 0.01, "{pixel:?}");
|
||||
}
|
||||
|
||||
/// Erase to History paints a layer made since the source state from that state's visible image, at
|
||||
/// every depth and through the Brush, Pencil and Block tips; the History Brush alone still refuses.
|
||||
#[test]
|
||||
fn erase_to_history_paints_the_source_states_visible_image_at_every_depth() {
|
||||
for depth in [8u64, 16, 32] {
|
||||
for tip in ["brush", "pencil", "block"] {
|
||||
let mut s = session(40, 20, depth, "rgb");
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
let red = rgba(&s, 20, 10);
|
||||
// State 1 = after the stroke; the layer made next did not exist then.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let stroke = json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 1});
|
||||
assert!(s.execute("paint.historyBrush", stroke.clone()).is_err(), "depth {depth} {tip}: the History Brush refuses a new layer");
|
||||
let mut p = stroke;
|
||||
p["eraseToHistory"] = json!(true);
|
||||
match tip {
|
||||
"pencil" => p["pencil"] = json!(true),
|
||||
"block" => p["block"] = json!(true),
|
||||
_ => {}
|
||||
}
|
||||
s.execute("paint.historyBrush", p).unwrap();
|
||||
let got = rgba(&s, 20, 10);
|
||||
assert!(got[3] > 0.99 && (0..3).all(|i| (got[i] - red[i]).abs() < 0.01), "depth {depth} {tip}: {got:?} vs {red:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_to_history_paints_a_new_layer_from_a_single_layer_state_background_or_not() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
let red = rgba(&s, 20, 10);
|
||||
assert!(red[3] > 0.99);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 1, "eraseToHistory": true})).unwrap();
|
||||
assert_eq!(rgba(&s, 20, 10), red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_to_history_paints_a_new_layer_with_the_single_layers_own_pixels_not_its_opacity() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
let red = rgba(&s, 20, 10);
|
||||
s.execute("layer.setProps", json!({"opacity": 0.5})).unwrap(); // State 2.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 2, "eraseToHistory": true})).unwrap();
|
||||
assert_eq!(rgba(&s, 20, 10), red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_to_history_refuses_a_new_layer_from_a_state_holding_only_a_group() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap(); // State 2: one group at the root.
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
assert!(s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 2, "eraseToHistory": true})).is_err());
|
||||
}
|
||||
|
||||
/// A state whose one root item is a group of several layers counts one root layer, but a group has
|
||||
/// no pixels to paint back: refused like the one-layer group (FredApps' review of #10692).
|
||||
#[test]
|
||||
fn erase_to_history_refuses_a_new_layer_from_a_state_holding_one_group_of_several_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 5], [40, 5]], "size": 6, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 15], [40, 15]], "size": 6, "hardness": 1.0, "color": "#0000ff"})).unwrap();
|
||||
let ids: Vec<LayerId> = s.active().unwrap().doc.layers.iter().map(|l| l.id).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": ids[1].0, "mode": "toggle"})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(doc.layers.len(), 1, "one group at the root");
|
||||
assert_eq!(doc.layers[0].children().map(<[_]>::len), Some(2), "holding both layers");
|
||||
let state = s.active().unwrap().history.past_len();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
let before = layer_pixels(&s);
|
||||
let r = s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": state, "eraseToHistory": true}));
|
||||
let err = r.expect_err("refused").to_string();
|
||||
assert!(err.contains("did not exist"), "refused as having no pixels there: {err}");
|
||||
assert!(layer_pixels(&s) == before, "nothing painted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erase_to_history_on_a_new_layer_without_pixels_names_the_target_not_the_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
// An adjustment layer has a mask, so the command is available, but this targets its pixels.
|
||||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
let err = s
|
||||
.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 1, "eraseToHistory": true}))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("did not exist"), "{err}");
|
||||
}
|
||||
|
||||
/// A single shape with a solid fill paints that colour along the whole stroke, past its path.
|
||||
#[test]
|
||||
fn erase_to_history_paints_a_single_solid_shapes_colour_past_its_path() {
|
||||
for depth in [8u64, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let first = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 5, 5], "fill": "#0000ff"})).unwrap();
|
||||
s.execute("layer.delete", json!({"layer": first.0})).unwrap(); // State 2: the shape alone.
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 1);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 2, "eraseToHistory": true})).unwrap();
|
||||
let got = rgba(&s, 20, 10);
|
||||
assert!(got[3] > 0.99 && got[0] < 0.01 && got[1] < 0.01 && got[2] > 0.99, "depth {depth}: {got:?}");
|
||||
assert!(rgba(&s, 20, 2)[3] < 0.01, "depth {depth}: only under the stroke");
|
||||
}
|
||||
}
|
||||
|
||||
/// A single shape without a solid fill paints plain white along the whole stroke, past its path.
|
||||
#[test]
|
||||
fn erase_to_history_paints_white_past_the_path_of_a_single_shape_without_a_solid_fill() {
|
||||
let gradient = json!({"kind": "rect", "rect": [1, 1, 5, 5], "fill": {"gradient": {"stops": [[0, "#ff0000"], [1, "#0000ff"]], "angle": 0}}});
|
||||
let outline = json!({"kind": "rect", "rect": [1, 1, 5, 5], "fill": null, "stroke": {"width": 2, "color": "#ff0000"}});
|
||||
for shape in [gradient, outline] {
|
||||
for depth in [8u64, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "depth": depth, "background": "transparent"})).unwrap();
|
||||
let first = s.active().unwrap().active_layer.unwrap();
|
||||
s.execute("shape.create", shape.clone()).unwrap();
|
||||
s.execute("layer.delete", json!({"layer": first.0})).unwrap(); // State 2: the shape alone.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 2, "eraseToHistory": true})).unwrap();
|
||||
let got = rgba(&s, 20, 10);
|
||||
assert!(got.iter().all(|v| *v > 0.99), "depth {depth}, {shape}: {got:?}");
|
||||
assert!(rgba(&s, 20, 2)[3] < 0.01, "depth {depth}, {shape}: only under the stroke");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The white past a shape's path holds in every colour mode.
|
||||
#[test]
|
||||
fn erase_to_history_paints_white_past_a_shapes_path_in_every_colour_mode() {
|
||||
for mode in ["cmyk", "gray", "lab"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20, "mode": mode, "background": "transparent"})).unwrap();
|
||||
let first = s.active().unwrap().active_layer.unwrap();
|
||||
let fill = json!({"gradient": {"stops": [[0, "#ff0000"], [1, "#0000ff"]], "angle": 0}});
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 5, 5], "fill": fill})).unwrap();
|
||||
s.execute("layer.delete", json!({"layer": first.0})).unwrap(); // State 2: the shape alone.
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.historyBrush", json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": 2, "eraseToHistory": true})).unwrap();
|
||||
let got = rgba(&s, 20, 10);
|
||||
assert!(got.iter().all(|v| *v > 0.99), "{mode}: {got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source state holding two layers refuses a layer it doesn't contain.
|
||||
#[test]
|
||||
fn erase_to_history_refuses_a_new_layer_from_a_state_holding_two_layers() {
|
||||
let mut s = session(40, 20, 8, "rgb");
|
||||
let picture = rgba(&s, 20, 10);
|
||||
s.execute("layer.new.layer", json!({})).unwrap(); // State 1: the Background and an empty layer.
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "color": "#ff0000"})).unwrap(); // State 2.
|
||||
let red = rgba(&s, 20, 10);
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 12, "hardness": 1.0, "erase": true})).unwrap();
|
||||
assert!(rgba(&s, 20, 10)[3] < 0.01);
|
||||
// A layer the source state does hold paints back as it was, whatever else the state holds.
|
||||
let stroke = |state: u64| json!({"points": [[15, 10], [25, 10]], "size": 6, "hardness": 100, "state": state, "eraseToHistory": true});
|
||||
s.execute("paint.historyBrush", stroke(2)).unwrap();
|
||||
assert_eq!(rgba(&s, 20, 10), red);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
// A second layer in the source state, empty or not, leaves no corresponding layer.
|
||||
for state in [1, 2] {
|
||||
assert!(s.execute("paint.historyBrush", stroke(state)).is_err(), "state {state}");
|
||||
assert!(rgba(&s, 20, 10)[3] < 0.01, "state {state}");
|
||||
}
|
||||
s.execute("paint.historyBrush", stroke(0)).unwrap();
|
||||
assert_eq!(rgba(&s, 20, 10), picture);
|
||||
}
|
||||
|
||||
/// A red stroke (state 1), then a green one over it, and a History Brush stroke from state 1
|
||||
/// across them, with `session_brush` set as the session brush and `param`, if any, as the
|
||||
/// stroke's own `"brush"`.
|
||||
fn history_stroke_with(session_brush: Value, param: Option<Value>) -> Session {
|
||||
let mut s = session(40, 20, 8, "rgb");
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 14, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 14, "hardness": 1.0, "color": "#00ff00"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({ "brush": session_brush })).unwrap();
|
||||
// A zigzag: smoothing changes a bent path, not a straight one.
|
||||
let mut p = json!({"points": [[6, 6], [12, 14], [18, 6], [24, 14], [30, 6], [34, 12]], "size": 8, "hardness": 100, "spacing": 10, "state": 1});
|
||||
if let Some(b) = param {
|
||||
p["brush"] = b;
|
||||
}
|
||||
s.execute("paint.historyBrush", p).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A History Brush stroke with a `"brush"` param paints with that brush whatever the session brush
|
||||
/// is by then, so the stroke replays (the commit's fallback, a recorded action) with the brush it
|
||||
/// was pressed with, its tip shape, sampled tip, dynamics and smoothing included (FredApps' review
|
||||
/// of #10692).
|
||||
#[test]
|
||||
fn history_brush_paints_with_its_brush_param_not_the_session_brush() {
|
||||
let round = json!({"roundness": 1.0, "angle": 0.0});
|
||||
let tip = json!({"sampled": {"width": 3, "height": 3, "data": [0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0]}});
|
||||
for pressed in [
|
||||
json!({"roundness": 0.2, "angle": 30.0}),
|
||||
json!({"tip": tip}),
|
||||
json!({"shapeDynamics": {"enabled": true, "size": {"jitter": 1.0}}}),
|
||||
json!({"smoothing": {"enabled": true, "amount": 0.9}}),
|
||||
] {
|
||||
// Painted with `pressed` as the session brush, and with it as the param over a round one.
|
||||
let as_session = layer_pixels(&history_stroke_with(pressed.clone(), None));
|
||||
let as_param = layer_pixels(&history_stroke_with(round.clone(), Some(pressed.clone())));
|
||||
assert!(as_param == as_session, "{pressed}: the param's brush, not the session's");
|
||||
// The two brushes paint differently, so the comparison means something.
|
||||
assert!(layer_pixels(&history_stroke_with(round.clone(), None)) != as_session, "{pressed}: no different from round");
|
||||
}
|
||||
// A partial `"brush"` merges onto the session brush, as `paint.stroke`'s does.
|
||||
let partial = layer_pixels(&history_stroke_with(json!({"angle": 30.0}), Some(json!({"roundness": 0.2}))));
|
||||
assert!(partial == layer_pixels(&history_stroke_with(json!({"roundness": 0.2, "angle": 30.0}), None)));
|
||||
}
|
||||
|
||||
/// A malformed `"brush"` is refused before anything is painted.
|
||||
#[test]
|
||||
fn history_brush_refuses_a_bad_brush_param() {
|
||||
for bad in [json!({"size": "big"}), json!({"tip": {"sampled": {"width": 0}}}), json!({"size": 1e9}), json!({"roundness": [1, 2]})] {
|
||||
let mut s = session(40, 20, 8, "rgb");
|
||||
s.execute("paint.stroke", json!({"points": [[0, 10], [40, 10]], "size": 14, "hardness": 1.0, "color": "#ff0000"})).unwrap();
|
||||
let before = layer_pixels(&s);
|
||||
let r = s.execute("paint.historyBrush", json!({"points": [[8, 10], [32, 10]], "state": 0, "brush": bad}));
|
||||
assert!(r.is_err(), "{bad}: {r:?}");
|
||||
assert!(layer_pixels(&s) == before, "{bad}: nothing painted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_brush_errors_when_layer_is_new() {
|
||||
let mut s = session(20, 20, 8, "rgb");
|
||||
@@ -2151,6 +2389,7 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() {
|
||||
};
|
||||
for (depth, kind) in [(8u64, "contentAware"), (8, "proximityMatch"), (8, "createTexture"), (16, "createTexture")] {
|
||||
let mut s = session(400, 300, depth, "rgb");
|
||||
s.execute("tools.setBrush", json!({"brush": {"smoothing": {"catchUpOnEnd": true}}})).unwrap();
|
||||
paint_layer(&mut s, film_grain);
|
||||
let (m0, sd0) = red(&s, 0, 100, 0, 100);
|
||||
for i in 0..8 {
|
||||
@@ -2289,7 +2528,7 @@ fn remove_to_a_new_layer_leaves_the_target_untouched() {
|
||||
assert_ne!(nid, target);
|
||||
let order: Vec<LayerId> = d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
assert_eq!(order, vec![target, nid], "the new layer sits above the target");
|
||||
assert_eq!(d.doc.layer(nid).unwrap().name, "Layer 1");
|
||||
assert_eq!(d.doc.layer(nid).unwrap().name, "Remove tool edits");
|
||||
// The target keeps its pixels; the new layer carries the fill inside the hole only.
|
||||
let old = d.doc.layer(target).unwrap().surface().unwrap();
|
||||
assert!(near(old.rgba(46, 30), square, 0.001));
|
||||
@@ -2442,6 +2681,7 @@ fn periodic_clone_low_flow_revisits_accumulate_across_tile_aliases() {
|
||||
let setup = |width| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":width,"height":24,"depth":32})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"smoothing": {"catchUpOnEnd": true}}})).unwrap();
|
||||
for x in (8..width).step_by(32) {
|
||||
s.execute("paint.pencil", json!({"points":[[x,12]],"size":16,"color":"#ff0000"})).unwrap();
|
||||
}
|
||||
@@ -2721,3 +2961,19 @@ fn live_history_and_pattern_strokes_stop_at_the_canvas_edge() {
|
||||
assert_eq!(bounds(true), bounds(false), "{cmd}: the live stroke stays where it was drawn");
|
||||
}
|
||||
}
|
||||
|
||||
/// `smoothing` (the options bar's %) overrides the session brush's amount and keeps its other
|
||||
/// options; without it the stroke takes the session brush's. The UI sends 0 for a ⇧-click line (#11887).
|
||||
#[test]
|
||||
fn stroke_brush_takes_the_smoothing_param() {
|
||||
let mut base = BrushSettings::default();
|
||||
base.smoothing.amount = 0.6;
|
||||
base.smoothing.pulled_string = true;
|
||||
assert_eq!(stroke_brush(&base, &json!({})).smoothing, base.smoothing);
|
||||
let line = stroke_brush(&base, &json!({"smoothing": 0}));
|
||||
assert_eq!(line.smoothing.amount, 0.0);
|
||||
assert!(line.smoothing.pulled_string);
|
||||
assert_eq!(stroke_brush(&base, &json!({"smoothing": 25})).smoothing.amount, 0.25);
|
||||
assert_eq!(stroke_brush(&base, &json!({"smoothing": 1e9})).smoothing.amount, 1.0);
|
||||
assert_eq!(stroke_brush(&base, &json!({"smoothing": -5})).smoothing.amount, 0.0);
|
||||
}
|
||||
@@ -60,14 +60,16 @@ fn coord(cmd: &str, p: &Value, key: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number (document pixels)")))
|
||||
}
|
||||
|
||||
/// The pixel a point at document coordinate `(x, y)` sits on, if it is inside the canvas. Whole
|
||||
/// pixels only: the click's fractional position floors to the pixel it lands on.
|
||||
/// The centre of the pixel a point at document coordinate `(x, y)` sits on, if it is inside the
|
||||
/// canvas: the click's fractional position snaps to the middle of the pixel it lands on, so a
|
||||
/// click anywhere in pixel 199,149 stores 199.5,149.5, as in Photoshop (#11725). The readout
|
||||
/// floors it back to that pixel.
|
||||
fn pixel(doc: &Document, x: f64, y: f64) -> Option<[f64; 2]> {
|
||||
let (px, py) = (x.floor(), y.floor());
|
||||
if px < f64::from(i32::MIN) || px > f64::from(i32::MAX) || py < f64::from(i32::MIN) || py > f64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
doc.bounds().contains(px as i32, py as i32).then_some([px, py])
|
||||
doc.bounds().contains(px as i32, py as i32).then_some([px + 0.5, py + 0.5])
|
||||
}
|
||||
|
||||
fn index_param(cmd: &str, p: &Value) -> Result<usize> {
|
||||
@@ -202,3 +204,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pixel_centre_tests;
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Color Sampler points sit on the centre of the clicked pixel (#11725), as in Photoshop: a click
|
||||
//! anywhere in pixel 199,149 stores 199.5,149.5, the readout still reads that pixel, and document
|
||||
//! operations move the centre with its pixel.
|
||||
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// An 800 × 600 white document, as in the issue's measurement.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn positions(s: &mut Session) -> Vec<[f64; 2]> {
|
||||
let l = s.execute("view.colorSamplers.list", json!({})).unwrap();
|
||||
l["samplers"].as_array().unwrap().iter().map(|p| [p["position"][0].as_f64().unwrap(), p["position"][1].as_f64().unwrap()]).collect()
|
||||
}
|
||||
|
||||
fn assert_near(got: &[[f64; 2]], want: &[[f64; 2]], what: &str) {
|
||||
assert_eq!(got.len(), want.len(), "{what}: {got:?}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!((g[0] - w[0]).abs() < 1e-9 && (g[1] - w[1]).abs() < 1e-9, "{what}: got {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_inside_a_pixel_is_stored_and_listed_at_its_centre() {
|
||||
let mut s = session();
|
||||
let r = s.execute("view.colorSamplers.add", json!({"x": 199.3, "y": 149.6})).unwrap();
|
||||
assert_eq!(r["point"]["position"], json!([199.5, 149.5]));
|
||||
for (x, y) in [(199.0, 149.0), (199.99, 149.01)] {
|
||||
s.execute("view.colorSamplers.add", json!({"x": x, "y": y})).unwrap();
|
||||
}
|
||||
assert_near(&positions(&mut s), &[[199.5, 149.5]; 3], "anywhere in pixel 199,149");
|
||||
// The last pixel of the canvas.
|
||||
s.execute("view.colorSamplers.clear", json!({})).unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 799.7, "y": 598.2})).unwrap();
|
||||
assert_near(&positions(&mut s), &[[799.5, 598.5]], "the last pixel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_point_snaps_it_to_the_pixel_centre() {
|
||||
let mut s = session();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 10, "y": 10})).unwrap();
|
||||
let r = s.execute("view.colorSamplers.move", json!({"index": 0, "x": 352.9, "y": 150.2})).unwrap();
|
||||
assert_near(&positions(&mut s), &[[352.5, 150.5]], &format!("moved: {r}"));
|
||||
// Off the canvas is still refused and the point stays put.
|
||||
assert!(s.execute("view.colorSamplers.move", json!({"index": 0, "x": 800.0, "y": 10.0})).is_err());
|
||||
assert_near(&positions(&mut s), &[[352.5, 150.5]], "after a refused move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_readout_reads_the_pixel_the_centre_is_on() {
|
||||
let mut s = session();
|
||||
// Pixel 199,149 red, its lower-right neighbour 200,150 blue.
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layers[0].surface_mut().unwrap();
|
||||
let red = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]);
|
||||
let blue = photocraft_raster::from_rgba(&surf.format(), [0.0, 0.0, 1.0, 1.0]);
|
||||
surf.fill_rect(Rect::new(199, 149, 200, 150), &red);
|
||||
surf.fill_rect(Rect::new(200, 150, 201, 151), &blue);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let r = s.execute("view.colorSamplers.add", json!({"x": 199.3, "y": 149.6})).unwrap();
|
||||
assert_eq!(r["point"]["rgb"], json!([255, 0, 0]), "199.5,149.5 reads pixel 199,149");
|
||||
let l = s.execute("view.colorSamplers.list", json!({})).unwrap();
|
||||
assert_eq!(l["samplers"][0]["rgb"], json!([255, 0, 0]));
|
||||
}
|
||||
|
||||
/// The issue's two measured points (#1 on pixel 239,169 and #2 on pixel 352,150) after `id`.
|
||||
fn after(id: &str, p: Value) -> Vec<[f64; 2]> {
|
||||
let mut s = session();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 239.4, "y": 169.8})).unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 352.1, "y": 150.0})).unwrap();
|
||||
assert_near(&positions(&mut s), &[[239.5, 169.5], [352.5, 150.5]], "placed");
|
||||
s.execute(id, p).unwrap();
|
||||
positions(&mut s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_operations_move_the_centre_as_measured() {
|
||||
let cases = [
|
||||
("image.canvasSize", json!({"width": 1000, "height": 800, "anchor": "center"}), [[339.5, 269.5], [452.5, 250.5]]),
|
||||
("image.imageRotation.90cw", json!({}), [[430.5, 239.5], [449.5, 352.5]]),
|
||||
("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"}), [[430.5, 239.5], [449.5, 352.5]]),
|
||||
("image.imageRotation.flipCanvasHorizontal", json!({}), [[560.5, 169.5], [447.5, 150.5]]),
|
||||
// Scaled with the pixels, not snapped back to a pixel centre.
|
||||
("image.imageSize", json!({"width": 400, "height": 300}), [[119.75, 84.75], [176.25, 75.25]]),
|
||||
("image.crop", json!({"x": 100, "y": 100, "width": 400, "height": 300}), [[139.5, 69.5], [252.5, 50.5]]),
|
||||
];
|
||||
for (id, p, want) in cases {
|
||||
assert_near(&after(id, p), &want, id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_outside_the_new_canvas_are_still_dropped() {
|
||||
let mut s = session();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 239, "y": 169})).unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 650, "y": 500})).unwrap();
|
||||
// The last pixel inside the crop stays; the one just outside goes.
|
||||
s.execute("view.colorSamplers.add", json!({"x": 499, "y": 399})).unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 500, "y": 399})).unwrap();
|
||||
s.execute("image.crop", json!({"x": 100, "y": 100, "width": 400, "height": 300})).unwrap();
|
||||
assert_near(&positions(&mut s), &[[139.5, 69.5], [399.5, 299.5]], "after the crop");
|
||||
assert_eq!(s.take_samplers_removed(), 2);
|
||||
}
|
||||
@@ -25,20 +25,20 @@ fn add_reads_the_composite_pixel_and_lists_it() {
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(r["point"]["number"], 1);
|
||||
assert_eq!(r["point"]["position"], json!([5.0, 6.0]));
|
||||
assert_eq!(r["point"]["position"], json!([5.5, 6.5]));
|
||||
assert_eq!(r["point"]["rgb"], json!([255, 255, 255]));
|
||||
assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 0]));
|
||||
let l = list(&mut s);
|
||||
assert_eq!(l["max"], 10);
|
||||
assert_eq!(l["samplers"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(l["samplers"][0]["position"], json!([5.0, 6.0]));
|
||||
assert_eq!(l["samplers"][0]["position"], json!([5.5, 6.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_clicks_floor_to_the_pixel() {
|
||||
fn fractional_clicks_snap_to_the_pixel_centre() {
|
||||
let mut s = session();
|
||||
let r = s.execute("view.colorSamplers.add", json!({"x": 5.9, "y": 6.1})).unwrap();
|
||||
assert_eq!(r["point"]["position"], json!([5.0, 6.0]));
|
||||
assert_eq!(r["point"]["position"], json!([5.5, 6.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,12 +84,12 @@ fn move_delete_and_clear() {
|
||||
s.execute("view.colorSamplers.add", json!({"x": 5, "y": 5})).unwrap();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 50, "y": 50})).unwrap();
|
||||
let r = s.execute("view.colorSamplers.move", json!({"index": 0, "x": 10, "y": 20})).unwrap();
|
||||
assert_eq!(r["point"]["position"], json!([10.0, 20.0]));
|
||||
assert_eq!(points(&mut s)[0]["position"], json!([10.0, 20.0]));
|
||||
assert_eq!(r["point"]["position"], json!([10.5, 20.5]));
|
||||
assert_eq!(points(&mut s)[0]["position"], json!([10.5, 20.5]));
|
||||
s.execute("view.colorSamplers.delete", json!({"index": 1})).unwrap();
|
||||
assert_eq!(points(&mut s).len(), 1);
|
||||
// The remaining point is the moved #1.
|
||||
assert_eq!(points(&mut s)[0]["position"], json!([10.0, 20.0]));
|
||||
assert_eq!(points(&mut s)[0]["position"], json!([10.5, 20.5]));
|
||||
let r = s.execute("view.colorSamplers.clear", json!({})).unwrap();
|
||||
assert_eq!(r["deleted"], 1);
|
||||
assert!(points(&mut s).is_empty());
|
||||
@@ -132,7 +132,7 @@ fn a_crop_drops_points_outside_the_new_canvas() {
|
||||
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1, "the outside point was removed");
|
||||
assert_eq!(p[0]["position"], json!([5.0, 5.0]));
|
||||
assert_eq!(p[0]["position"], json!([5.5, 5.5]));
|
||||
assert_eq!(s.take_samplers_removed(), 1, "one removed point reported");
|
||||
assert_eq!(s.take_samplers_removed(), 0, "reported once");
|
||||
}
|
||||
@@ -145,7 +145,7 @@ fn a_crop_moves_points_with_their_pixels() {
|
||||
s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20})).unwrap();
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0]["position"], json!([10.0, 10.0]), "moved by (-40, -30)");
|
||||
assert_eq!(p[0]["position"], json!([10.5, 10.5]), "moved by (-40, -30)");
|
||||
assert_eq!(s.take_samplers_removed(), 0, "nothing removed");
|
||||
}
|
||||
|
||||
@@ -157,12 +157,13 @@ fn a_canvas_size_shift_moves_points() {
|
||||
s.execute("image.canvasSize", json!({"width": 120, "height": 100, "anchor": "bottomRight"})).unwrap();
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0]["position"], json!([50.0, 50.0]), "moved by the canvas shift");
|
||||
assert_eq!(p[0]["position"], json!([50.5, 50.5]), "moved by the canvas shift");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_right_angle_rotation_moves_points() {
|
||||
// 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x).
|
||||
// 90° clockwise about the centre: pixel (x, y) of the 100×80 canvas goes to (79 - y, x), and
|
||||
// the point stays on that pixel's centre.
|
||||
let mut s = session();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap();
|
||||
s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap();
|
||||
@@ -170,7 +171,7 @@ fn a_right_angle_rotation_moves_points() {
|
||||
assert_eq!(p.len(), 1);
|
||||
let pos = p[0]["position"].as_array().unwrap();
|
||||
let (x, y) = (pos[0].as_f64().unwrap(), pos[1].as_f64().unwrap());
|
||||
assert!((x - 39.0).abs() < 1e-6 && (y - 50.0).abs() < 1e-6, "{pos:?}");
|
||||
assert!((x - 39.5).abs() < 1e-6 && (y - 50.5).abs() < 1e-6, "{pos:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +198,7 @@ fn flips_and_right_angle_turns_keep_points_on_their_pixel() {
|
||||
let (x, y) = turn.pixel_map(100, 80)(3, 3);
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1, "{id}");
|
||||
assert_eq!(p[0]["position"], json!([f64::from(x), f64::from(y)]), "{id}");
|
||||
assert_eq!(p[0]["position"], json!([f64::from(x) + 0.5, f64::from(y) + 0.5]), "{id}");
|
||||
assert_eq!(p[0]["rgb"], json!([0, 0, 0]), "{id}");
|
||||
}
|
||||
}
|
||||
@@ -205,7 +206,7 @@ fn flips_and_right_angle_turns_keep_points_on_their_pixel() {
|
||||
#[test]
|
||||
fn an_arbitrary_rotation_keeps_points_on_their_pixel() {
|
||||
// The pixel at (3, 3) of the 100×80 document, and where each turn moves it.
|
||||
for (angle, direction, at) in [(90, "cw", [76.0, 3.0]), (90, "ccw", [3.0, 96.0]), (180, "cw", [96.0, 76.0])] {
|
||||
for (angle, direction, at) in [(90, "cw", [76.5, 3.5]), (90, "ccw", [3.5, 96.5]), (180, "cw", [96.5, 76.5])] {
|
||||
let mut s = session();
|
||||
s.edit("paint", |doc, _| {
|
||||
let surf = doc.layers[0].surface_mut().unwrap();
|
||||
@@ -240,15 +241,15 @@ fn a_rotated_crop_moves_points() {
|
||||
s.execute("image.crop", json!({"x": 40, "y": 30, "width": 20, "height": 20, "angle": 90})).unwrap();
|
||||
let p = points(&mut s);
|
||||
assert_eq!(p.len(), 1);
|
||||
assert_eq!(p[0]["position"], json!([10.0, 9.0]));
|
||||
assert_eq!(p[0]["position"], json!([10.5, 9.5]));
|
||||
assert_eq!(p[0]["rgb"], json!([0, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_to_a_target_size_scales_points() {
|
||||
// The crop resamples the 20 x 20 area to 40 x 40: the point (10, 10 after the shift; 10, 9
|
||||
// turned 90°, as in `a_rotated_crop_moves_points`) doubles.
|
||||
for (angle, at) in [(None, [20.0, 20.0]), (Some(90), [20.0, 18.0])] {
|
||||
// The crop resamples the 20 x 20 area to 40 x 40: the point (10.5, 10.5 after the shift;
|
||||
// 10.5, 9.5 turned 90°, as in `a_rotated_crop_moves_points`) doubles, as Image Size scales it.
|
||||
for (angle, at) in [(None, [21.0, 21.0]), (Some(90), [21.0, 19.0])] {
|
||||
let mut s = session();
|
||||
s.execute("view.colorSamplers.add", json!({"x": 50, "y": 40})).unwrap();
|
||||
let mut p = json!({"x": 40, "y": 30, "width": 20, "height": 20, "targetWidth": 40, "targetHeight": 40});
|
||||
@@ -347,7 +348,7 @@ fn points_work_at_every_depth_and_mode() {
|
||||
fn document_helpers_are_not_disturbed() {
|
||||
let mut d = Document::new("t", Size::new(4, 4), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(Layer::raster("Background", d.pixel_format()));
|
||||
assert_eq!(pixel(&d, 0.0, 0.0), Some([0.0, 0.0]));
|
||||
assert_eq!(pixel(&d, 3.9, 3.9), Some([3.0, 3.0]));
|
||||
assert_eq!(pixel(&d, 0.0, 0.0), Some([0.5, 0.5]));
|
||||
assert_eq!(pixel(&d, 3.9, 3.9), Some([3.5, 3.5]));
|
||||
assert_eq!(pixel(&d, 4.0, 0.0), None);
|
||||
}
|
||||
@@ -655,6 +655,22 @@ pub fn refresh_at(dpi: f32, format: PixelFormat, t: &mut TextLayer) {
|
||||
t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds())));
|
||||
}
|
||||
|
||||
/// Makes `t` paragraph text in the box `[x, y, width, height]`: the box sits at the text-space
|
||||
/// origin and the layer transform moves it to `(x, y)`, keeping its linear part. `x` and `y` are
|
||||
/// document px (the transform's translation); `width` and `height` are text-space px, so under a
|
||||
/// scaled transform they are scaled by its linear part.
|
||||
/// `type.edit`'s `box` param and the Type tool's live box-drag preview both use it, so the preview
|
||||
/// shows exactly the layer the command would make. Non-finite numbers count as 0, as a JSON
|
||||
/// `null` does in the command (a width or height of 0 becomes the 1 px minimum). The stored size
|
||||
/// is clamped to `1.0..=f32::MAX` so a huge input can't become an infinite `f32`.
|
||||
pub fn apply_box(t: &mut TextLayer, b: [f64; 4]) {
|
||||
let [x, y, w, h] = b.map(|v| if v.is_finite() { v } else { 0.0 });
|
||||
let size = |v: f64| v.clamp(1.0, f64::from(f32::MAX)) as f32;
|
||||
let lin = t.transform.m;
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: size(w), height: size(h) };
|
||||
t.transform = Affine { m: [lin[0], lin[1], lin[2], lin[3], x, y] };
|
||||
}
|
||||
|
||||
/// Refuses to change a type layer under Lock All (its own or an enclosing group's), with
|
||||
/// Photoshop's message. Every command that edits a type layer's text calls it first (#3132, #4188).
|
||||
pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> {
|
||||
@@ -989,6 +1005,7 @@ pub fn name_selection_commit(s: &mut Session) {
|
||||
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":0.1..=1296 pt,"color":"#rrggbb"|[r,g,b,a],"tracking":-1000..=10000 (1/1000 em),"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","baseline":"normal|superscript|subscript","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
|
||||
/// The Type commands: create and edit type layers, set styles, fill paths, and the queries about them.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
@@ -1113,9 +1130,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let lin = t.transform.m;
|
||||
if let Some(b) = p.get("box").and_then(Value::as_array).filter(|b| b.len() == 4) {
|
||||
let v: Vec<f64> = b.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: v[2].max(1.0) as f32, height: v[3].max(1.0) as f32 };
|
||||
t.transform = Affine { m: [lin[0], lin[1], lin[2], lin[3], v[0], v[1]] };
|
||||
let mut v = [0.0; 4];
|
||||
for (slot, x) in v.iter_mut().zip(b) {
|
||||
*slot = x.as_f64().unwrap_or(0.0);
|
||||
}
|
||||
apply_box(t, v);
|
||||
}
|
||||
if let Some(pt) = p.get("point").and_then(Value::as_array).filter(|b| b.len() == 2) {
|
||||
t.shape = TextShape::Point;
|
||||
|
||||
@@ -1012,3 +1012,43 @@ fn relative_steps_move_each_character_from_its_own_value() {
|
||||
s.execute("type.setStyle", json!({"layer": empty, "range": [0, 0], "leadingBy": -2})).unwrap();
|
||||
assert_eq!(text_layer(&s, empty).char_runs()[0].style.leading_pt, Some(10.0));
|
||||
}
|
||||
|
||||
/// The `(width, height)` of a layer that is paragraph text in a box.
|
||||
fn box_size(t: &TextLayer) -> (f32, f32) {
|
||||
match t.shape {
|
||||
TextShape::Box { width, height, .. } => (width, height),
|
||||
ref s => panic!("not a box: {s:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A huge `box` once became an infinite `f32` size; it stays finite now.
|
||||
#[test]
|
||||
fn a_huge_box_keeps_a_finite_size() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap();
|
||||
// Ok or a clean error are both fine; a panic is not. On success the box must have been applied.
|
||||
let result = s.execute("type.edit", json!({"layer": id, "box": [0, 0, 1e300, 1e300]}));
|
||||
if result.is_ok() {
|
||||
assert!(matches!(text_layer(&s, id).shape, TextShape::Box { .. }), "a successful box edit makes a box");
|
||||
}
|
||||
if let TextShape::Box { .. } = text_layer(&s, id).shape {
|
||||
let (w, h) = box_size(&text_layer(&s, id));
|
||||
assert!(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0, "{w} x {h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `apply_box` keeps the stored size between 1 px and `f32::MAX`, and non-finite input becomes the minimum.
|
||||
#[test]
|
||||
fn apply_box_clamps_sizes_to_a_finite_minimum_and_maximum() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"text": "Hello", "size": 24})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut t = text_layer(&s, id);
|
||||
apply_box(&mut t, [f64::NAN, f64::INFINITY, f64::NAN, f64::NEG_INFINITY]);
|
||||
assert_eq!(box_size(&t), (1.0, 1.0), "non-finite counts as 0, then the 1 px minimum");
|
||||
assert_eq!((t.transform.m[4], t.transform.m[5]), (0.0, 0.0));
|
||||
apply_box(&mut t, [5.0, 6.0, 1e300, f64::MAX]);
|
||||
assert_eq!(box_size(&t), (f32::MAX, f32::MAX));
|
||||
assert_eq!((t.transform.m[4], t.transform.m[5]), (5.0, 6.0));
|
||||
apply_box(&mut t, [0.0, 0.0, -20.0, 0.25]);
|
||||
assert_eq!(box_size(&t), (1.0, 1.0));
|
||||
}
|
||||
@@ -369,6 +369,7 @@ fn path_source(doc: &Document, p: &Value) -> Result<Subpath> {
|
||||
Ok(sub.clone())
|
||||
}
|
||||
|
||||
/// Attaches the active type layer to a target subpath with optional start offset and flip orientation.
|
||||
fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() };
|
||||
let start = match p.get("start") {
|
||||
@@ -413,7 +414,7 @@ fn attach_to_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
photocraft_text::warp::arc_length_at(&path, flip, q.x, q.y).ok_or_else(|| bad("the path has no length"))? as f32
|
||||
}
|
||||
};
|
||||
t.shape = TextShape::Path { path, start, flip };
|
||||
t.shape = TextShape::Path { path, start, end: None, flip };
|
||||
Ok(start)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "start": start, "flip": flip}))
|
||||
@@ -431,8 +432,8 @@ pub fn type_path(t: &TextLayer) -> Option<Path> {
|
||||
/// Puts an edited [`type_path`] back into `t` (its text space), keeping where the type starts and
|
||||
/// which side it runs on. It must still be one subpath with two or more points.
|
||||
pub(crate) fn set_type_path(t: &mut TextLayer, edited: &Path) -> Result<()> {
|
||||
let TextShape::Path { start, flip, .. } = &t.shape else { return Err(EngineError::Other("the type layer is not on a path".into())) };
|
||||
let (start, flip) = (*start, *flip);
|
||||
let TextShape::Path { start, end, flip, .. } = &t.shape else { return Err(EngineError::Other("the type layer is not on a path".into())) };
|
||||
let (start, end, flip) = (*start, *end, *flip);
|
||||
let [sub] = edited.subpaths.as_slice() else { return Err(EngineError::Other("type on a path follows one path".into())) };
|
||||
if sub.knots.len() < 2 {
|
||||
return Err(EngineError::Other("type on a path needs a path of two or more points".into()));
|
||||
@@ -447,7 +448,7 @@ pub(crate) fn set_type_path(t: &mut TextLayer, edited: &Path) -> Result<()> {
|
||||
if !knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) {
|
||||
return Err(EngineError::Other("the path cannot be mapped into the type layer".into()));
|
||||
}
|
||||
t.shape = TextShape::Path { path: Subpath { closed: sub.closed, knots, op: sub.op }, start, flip };
|
||||
t.shape = TextShape::Path { path: Subpath { closed: sub.closed, knots, op: sub.op }, start, end, flip };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -458,6 +459,103 @@ fn is_path_text(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the active layer contains path text eligible for placement adjustments.
|
||||
fn is_path_placement_text(s: &Session) -> std::result::Result<(), String> {
|
||||
match active_text(s)?.shape {
|
||||
TextShape::Path { .. } => Ok(()),
|
||||
_ => Err("the type layer is not on a path".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the path text placement range (`start`, `end`) and `flip` orientation, validating boundaries against curve geometry.
|
||||
fn set_path_placement(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bad = |msg: &str| EngineError::BadParams { cmd: "type.setPathPlacement".into(), msg: msg.into() };
|
||||
if !p.is_object() {
|
||||
return Err(bad("params must be an object"));
|
||||
}
|
||||
let start = p
|
||||
.get("start")
|
||||
.map(|v| {
|
||||
let n = v.as_f64().ok_or_else(|| bad("start must be a finite number representable as f32"))?;
|
||||
let n = n as f32;
|
||||
if n.is_finite() { Ok(n) } else { Err(bad("start must be a finite number representable as f32")) }
|
||||
})
|
||||
.transpose()?;
|
||||
let end = p
|
||||
.get("end")
|
||||
.map(|v| {
|
||||
if v.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = v.as_f64().ok_or_else(|| bad("end must be null or a finite number representable as f32"))? as f32;
|
||||
if n.is_finite() { Ok(Some(n)) } else { Err(bad("end must be null or a finite number representable as f32")) }
|
||||
})
|
||||
.transpose()?;
|
||||
let flip = p.get("flip").map(|v| v.as_bool().ok_or_else(|| bad("flip must be a boolean"))).transpose()?;
|
||||
let id = layer_param(s, p)?;
|
||||
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
|
||||
let locks = doc.effective_locks(id);
|
||||
if locks.position || locks.all {
|
||||
return Err(EngineError::Other("the type layer's position is locked".into()));
|
||||
}
|
||||
with_text(s, p, "Set Path Placement", |t, _| {
|
||||
let TextShape::Path { path, start: current_start, end: current_end, flip: current_flip } = &mut t.shape else {
|
||||
return Err(EngineError::Other("the type layer is not path text".into()));
|
||||
};
|
||||
let mut new_end = end.unwrap_or(*current_end);
|
||||
let mut new_start = start.unwrap_or(*current_start);
|
||||
let validate_range = start.is_some() || end.is_some();
|
||||
if validate_range && new_end.is_some_and(|end| end < new_start) {
|
||||
return Err(bad("end must not be before start"));
|
||||
}
|
||||
if validate_range {
|
||||
if let Some(mut end_val) = new_end {
|
||||
if !new_start.is_finite() || !end_val.is_finite() {
|
||||
return Err(bad("range must be finite"));
|
||||
}
|
||||
let geometry = photocraft_text::warp::PathGeometry::new(path, false)
|
||||
.filter(|geometry| geometry.length().is_finite() && geometry.length() > 0.0)
|
||||
.ok_or_else(|| bad("path must have a finite nonzero length"))?;
|
||||
if !path.closed {
|
||||
let length = geometry.length();
|
||||
let max_f32 = length as f32;
|
||||
if new_start < 0.0 || new_start > max_f32 || end_val > max_f32 {
|
||||
return Err(bad("range must lie on the path"));
|
||||
}
|
||||
let canonical_max = geometry.canonical_length_f32();
|
||||
if end_val > canonical_max {
|
||||
end_val = canonical_max;
|
||||
}
|
||||
if new_start > canonical_max {
|
||||
new_start = canonical_max;
|
||||
}
|
||||
new_end = Some(end_val);
|
||||
}
|
||||
*current_start = new_start;
|
||||
} else if let Some(start_val) = start {
|
||||
if !path.closed {
|
||||
let geometry = photocraft_text::warp::PathGeometry::new(path, false)
|
||||
.filter(|geometry| geometry.length().is_finite() && geometry.length() > 0.0)
|
||||
.ok_or_else(|| bad("path must have a finite nonzero length"))?;
|
||||
let length = geometry.length();
|
||||
let max_f32 = length as f32;
|
||||
if start_val < 0.0 || start_val > max_f32 {
|
||||
return Err(bad("range must lie on the path"));
|
||||
}
|
||||
let canonical_max = geometry.canonical_length_f32();
|
||||
new_start = start_val.min(canonical_max);
|
||||
}
|
||||
*current_start = new_start;
|
||||
}
|
||||
*current_end = new_end;
|
||||
}
|
||||
if let Some(flip) = flip {
|
||||
*current_flip = flip;
|
||||
}
|
||||
Ok(json!({"start": *current_start, "end": *current_end, "flip": *current_flip}))
|
||||
})
|
||||
}
|
||||
|
||||
fn detach_from_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
with_text(s, p, "Release Type from Path", |t, _| {
|
||||
@@ -879,6 +977,7 @@ fn load_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
// ---------- registry ----------
|
||||
|
||||
/// Registers extra type commands including path attachment, detachment, rasterization, and path placement.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
macro_rules! spec {
|
||||
($id:expr, $label:expr, $menu:expr, $params:expr, $enabled:expr, $run:expr) => {
|
||||
@@ -979,6 +1078,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
attach_to_path
|
||||
),
|
||||
spec!(
|
||||
"type.setPathPlacement",
|
||||
"Set Path Placement",
|
||||
&[],
|
||||
r##"{"layer":id? (default active Type layer),"start":finite f32 arc-length pixels?,"end":finite f32 arc-length pixels|null?,"flip":bool?}. Updates existing path text; omitted values are preserved. Offsets are text-space pixels in the current travel direction. end:null clears the limit and restores legacy placement; an explicit end must be at least start. Position-locked and fully locked layers are rejected."##,
|
||||
is_path_placement_text,
|
||||
set_path_placement
|
||||
),
|
||||
spec!("type.detachFromPath", "Release Type from Path", &[], LP, is_path_text, detach_from_path),
|
||||
spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)),
|
||||
spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts(
|
||||
|
||||
@@ -661,7 +661,7 @@ fn an_edited_type_path_keeps_the_knot_cap() {
|
||||
let id = text_layer(&mut s, "Hi");
|
||||
let knots: Vec<(f64, f64)> = (0..=MAX_TYPE_PATH_KNOTS).map(|i| (i as f64 * 0.001, 50.0)).collect();
|
||||
let mut t = text(&s, id).clone();
|
||||
t.shape = TextShape::Path { path: Subpath::polyline(&knots[..2]), start: 0.0, flip: false };
|
||||
t.shape = TextShape::Path { path: Subpath::polyline(&knots[..2]), start: 0.0, end: None, flip: false };
|
||||
assert!(set_type_path(&mut t, &Path::new(vec![Subpath::polyline(&knots[..MAX_TYPE_PATH_KNOTS])])).is_ok());
|
||||
assert!(set_type_path(&mut t, &Path::new(vec![Subpath::polyline(&knots)])).is_err());
|
||||
}
|
||||
@@ -839,3 +839,221 @@ fn a_document_without_type_never_waits_for_the_text_engine() {
|
||||
text_layer(&mut s, "type");
|
||||
assert!(has_type_layers(&s.active().unwrap().doc));
|
||||
}
|
||||
|
||||
/// Verifies `type.setPathPlacement` commits valid range updates with undo/redo and rejects inverted, infinite, or out-of-bounds inputs.
|
||||
#[test]
|
||||
fn path_placement_updates_and_rejects_invalid_inputs() {
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute("type.setPathPlacement", json!({})).is_err());
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "Path");
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 1})).is_err());
|
||||
s.edit("path", |doc, _| {
|
||||
doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(10.0, 60.0), (200.0, 60.0)])]));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.attachToPath", json!({"start": 4})).unwrap();
|
||||
let original = text(&s, id).shape.clone();
|
||||
let path = crate::type_extra_cmds::type_path(text(&s, id)).unwrap();
|
||||
s.edit("remove work path", |doc, _| {
|
||||
doc.work_path = None;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.setPathPlacement", json!({"layer": id.0, "start": 20, "flip": true})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 20.0, flip: true, .. }));
|
||||
assert_eq!(crate::type_extra_cmds::type_path(text(&s, id)).unwrap(), path);
|
||||
assert!(s.undo());
|
||||
assert_eq!(text(&s, id).shape, original);
|
||||
assert!(s.redo());
|
||||
s.execute("type.setPathPlacement", json!({"start": 30})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 30.0, flip: true, .. }));
|
||||
s.execute("type.setPathPlacement", json!({"flip": false})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 30.0, flip: false, .. }));
|
||||
for p in [json!({"start": "bad"}), json!({"start": 1e100}), json!({"flip": 1}), json!({"layer": "bad"})] {
|
||||
assert!(s.execute("type.setPathPlacement", p).is_err());
|
||||
}
|
||||
s.execute("type.setPathPlacement", json!({"end": 50})).unwrap();
|
||||
s.execute("type.setPathPlacement", json!({"flip": true})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 30.0, end: Some(50.0), .. }));
|
||||
let before = text(&s, id).shape.clone();
|
||||
for params in [json!({"end": 29}), json!({"start": 51}), json!({"end": 1e100}), json!({"end": "bad"}), json!({"start": -1}), json!({"end": 191})] {
|
||||
assert!(s.execute("type.setPathPlacement", params).is_err());
|
||||
assert_eq!(text(&s, id).shape, before);
|
||||
}
|
||||
let path = crate::type_extra_cmds::type_path(text(&s, id)).unwrap();
|
||||
let mut layer = text(&s, id).clone();
|
||||
set_type_path(&mut layer, &path).unwrap();
|
||||
assert!(matches!(layer.shape, TextShape::Path { end: Some(50.0), .. }));
|
||||
s.execute("type.setPathPlacement", json!({"start": 0, "end": 190})).unwrap();
|
||||
s.execute("type.setPathPlacement", json!({"end": null})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { end: None, .. }));
|
||||
s.execute("layer.setProps", json!({"layer": id.0, "locked": true})).unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"layer": id.0, "flip": true})).is_err());
|
||||
}
|
||||
|
||||
/// Ensures command availability is restricted to path text layers and correctly handles legacy unconstrained endpoints or degenerate subpaths.
|
||||
#[test]
|
||||
fn path_placement_availability_and_legacy_ranges() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "Path");
|
||||
let line = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0)]);
|
||||
with_text(&mut s, &json!({}), "area", |t, _| {
|
||||
t.shape = TextShape::Area { path: Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)])]) };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.is_enabled("type.setPathPlacement"));
|
||||
assert!(s.is_enabled("type.detachFromPath"));
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 0})).is_err());
|
||||
s.execute("type.detachFromPath", json!({})).unwrap();
|
||||
with_text(&mut s, &json!({}), "path", |t, _| {
|
||||
t.shape = TextShape::Path { path: line.clone(), start: -5.0, end: Some(10.0), flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.is_enabled("type.setPathPlacement"));
|
||||
s.execute("type.setPathPlacement", json!({"end": null})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: -5.0, end: None, .. }));
|
||||
with_text(&mut s, &json!({}), "degenerate", |t, _| {
|
||||
t.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 0.0)]), start: 0.0, end: None, flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = text(&s, id).shape.clone();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"end": 0, "flip": true})).is_err());
|
||||
assert_eq!(text(&s, id).shape, before);
|
||||
with_text(&mut s, &json!({}), "closed", |t, _| {
|
||||
let mut path = line.clone();
|
||||
path.closed = true;
|
||||
t.shape = TextShape::Path { path, start: 0.0, end: None, flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.setPathPlacement", json!({"start": 190, "end": 210})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 190.0, end: Some(210.0), .. }));
|
||||
|
||||
// Degenerate closed path is rejected on explicit range.
|
||||
with_text(&mut s, &json!({}), "degenerate_closed", |t, _| {
|
||||
t.shape = TextShape::Path { path: Subpath::polygon(&[(0.0, 0.0), (0.0, 0.0)]), start: 0.0, end: None, flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 0.0, "end": 10.0})).is_err());
|
||||
|
||||
// Closed negative seam offsets, huge multi-lap ranges, and stationary ranges are stored gracefully.
|
||||
with_text(&mut s, &json!({}), "closed_ranges", |t, _| {
|
||||
let mut path = line.clone();
|
||||
path.closed = true;
|
||||
t.shape = TextShape::Path { path, start: 0.0, end: None, flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("type.setPathPlacement", json!({"start": -25.0, "end": 1000.0})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: -25.0, end: Some(1000.0), .. }));
|
||||
s.execute("type.setPathPlacement", json!({"start": 40.0, "end": 40.0})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 40.0, end: Some(40.0), .. }));
|
||||
|
||||
// Explicit start on open legacy end:null validated against bounds and canonical normalized.
|
||||
with_text(&mut s, &json!({}), "open_start_null_end", |t, _| {
|
||||
t.shape = TextShape::Path { path: line.clone(), start: 10.0, end: Some(50.0), flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 120.0, "end": null})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": -5.0, "end": null})).is_err());
|
||||
s.execute("type.setPathPlacement", json!({"start": 30.0, "end": null})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 30.0, end: None, .. }));
|
||||
|
||||
// Position-locked and fully locked layers are rejected upfront.
|
||||
s.execute("layer.lockLayers", json!({"position": true})).unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 10.0})).is_err());
|
||||
s.execute("layer.lockLayers", json!({"position": false, "all": true})).unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 10.0})).is_err());
|
||||
s.execute("layer.lockLayers", json!({"all": false})).unwrap();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 10.0})).is_ok());
|
||||
}
|
||||
|
||||
/// Proves path placement validation tolerates `f32` precision upward rounding at endpoints while rejecting true out-of-bounds spans.
|
||||
#[test]
|
||||
fn path_placement_noninteger_line_rounding_upward_and_zero_capacity() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "Path");
|
||||
// Line from (0, 0) to (7, 7) has length 7 * sqrt(2) ≈ 9.899494936611665.
|
||||
// In f32, length rounds upward to 9.8994951248168945.
|
||||
let knots = [(0.0, 0.0), (7.0, 7.0)];
|
||||
let path = Subpath::polyline(&knots);
|
||||
let geometry = photocraft_text::warp::PathGeometry::new(&path, false).unwrap();
|
||||
let length = geometry.length();
|
||||
let length_f32 = length as f32;
|
||||
assert!(f64::from(length_f32) > length, "scale 7 * sqrt2 must round upward in f32");
|
||||
let canonical = geometry.canonical_length_f32();
|
||||
assert!(f64::from(canonical) <= length, "canonical f32 must never exceed geometry");
|
||||
|
||||
with_text(&mut s, &json!({}), "attach", |t, _| {
|
||||
t.shape = TextShape::Path { path: path.clone(), start: 0.0, end: None, flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Genuine out-of-bounds rejected.
|
||||
assert!(s.execute("type.setPathPlacement", json!({"end": 9.95})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"end": length_f32 + 0.1})).is_err());
|
||||
|
||||
// Upward-rounded endpoint is accepted and normalized to canonical maximum.
|
||||
s.execute("type.setPathPlacement", json!({"end": length_f32})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { end: Some(e), .. } if e == canonical));
|
||||
|
||||
// Zero-capacity endpoint range at the path boundary is accepted.
|
||||
s.execute("type.setPathPlacement", json!({"start": length_f32, "end": length_f32})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start, end: Some(end), .. } if start == canonical && end == canonical));
|
||||
|
||||
// Flipped case at the path boundary is accepted.
|
||||
s.execute("type.setPathPlacement", json!({"start": 0.0, "end": length_f32, "flip": true})).unwrap();
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 0.0, end: Some(end), flip: true, .. } if end == canonical));
|
||||
}
|
||||
|
||||
/// Flipping preserves stale endpoints after shortening a path; endpoint edits still validate.
|
||||
#[test]
|
||||
fn path_placement_shortened_path_flip_only_and_endpoint_rejection() {
|
||||
let mut s = session(8);
|
||||
let id = text_layer(&mut s, "Path");
|
||||
let line100 = Subpath::polyline(&[(0.0, 0.0), (100.0, 0.0)]);
|
||||
with_text(&mut s, &json!({}), "attach", |t, _| {
|
||||
t.shape = TextShape::Path { path: line100, start: 10.0, end: Some(80.0), flip: false };
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Shorten the path from length 100 to length 50.
|
||||
let shortened = Path::new(vec![Subpath::polyline(&[(0.0, 0.0), (50.0, 0.0)])]);
|
||||
with_text(&mut s, &json!({}), "shorten", |t, _| set_type_path(t, &shortened)).unwrap();
|
||||
|
||||
// Stored end (80.0) is now past the shortened path length (50.0).
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 10.0, end: Some(80.0), flip: false, .. }));
|
||||
|
||||
// Flip-only call must succeed and preserve the stored range.
|
||||
let r = s.execute("type.setPathPlacement", json!({"flip": true})).unwrap();
|
||||
assert_eq!(r, json!({"start": 10.0, "end": 80.0, "flip": true}));
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 10.0, end: Some(80.0), flip: true, .. }));
|
||||
|
||||
// Flip back without supplying endpoints also succeeds.
|
||||
let r = s.execute("type.setPathPlacement", json!({"flip": false})).unwrap();
|
||||
assert_eq!(r, json!({"start": 10.0, "end": 80.0, "flip": false}));
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 10.0, end: Some(80.0), flip: false, .. }));
|
||||
|
||||
// Supplying out-of-bounds start or end on the shortened path must be rejected.
|
||||
let before = text(&s, id).shape.clone();
|
||||
assert!(s.execute("type.setPathPlacement", json!({"end": 80.0})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 60.0})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": -5.0})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 10.0, "flip": true})).is_err());
|
||||
assert!(s.execute("type.setPathPlacement", json!({"start": 40.0, "end": 30.0})).is_err());
|
||||
assert_eq!(text(&s, id).shape, before);
|
||||
|
||||
// Supplying a valid range on the shortened path succeeds and updates the range.
|
||||
let r = s.execute("type.setPathPlacement", json!({"start": 5.0, "end": 45.0, "flip": true})).unwrap();
|
||||
assert_eq!(r, json!({"start": 5.0, "end": 45.0, "flip": true}));
|
||||
assert!(matches!(text(&s, id).shape, TextShape::Path { start: 5.0, end: Some(45.0), flip: true, .. }));
|
||||
}
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"path.transform",
|
||||
"Free Transform Path",
|
||||
[],
|
||||
r##"{"name":str|"work"|"layer"="work","layer":id? (with layer target),"matrix":[a,b,c,d,e,f] | "quad":[[x,y]×4] (Distort/Perspective: where the frame's corners go, clockwise from top-left) | "style"|"mesh"|"warp":… (Warp, as edit.transform.warp) | "translateX":px?,"translateY":px?,"scaleX":factor?,"scaleY":factor?,"angle":degrees?,"rect":[x0,y0,x1,y1]? (the quad's or warp's frame; default = path control bounds)} (numeric transforms pivot on path bounds center)"##,
|
||||
r##"{"name":str|"work"|"layer"="work","layer":id? (with layer target),"vectorMask":bool=false (with the layer target: its vector mask's path, not a shape layer's path),"matrix":[a,b,c,d,e,f] | "quad":[[x,y]×4] (Distort/Perspective: where the frame's corners go, clockwise from top-left) | "style"|"mesh"|"warp":… (Warp, as edit.transform.warp) | "translateX":px?,"translateY":px?,"scaleX":factor?,"scaleY":factor?,"angle":degrees?,"rect":[x0,y0,x1,y1]? (the quad's or warp's frame; default = path control bounds)} (numeric transforms pivot on path bounds center)"##,
|
||||
has_doc,
|
||||
path_transform
|
||||
),
|
||||
|
||||
@@ -161,12 +161,15 @@ pub(super) fn clipping_path_clear(s: &mut Session, _p: &Value) -> Result<Value>
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The path `path.transform` edits: `layer`'s shape path or vector mask, else the work path or a
|
||||
/// saved path.
|
||||
fn transform_target(s: &Session, name: &str, layer: Option<LayerId>) -> Result<Path> {
|
||||
/// The path `path.transform` edits: `layer`'s shape path or vector mask (its vector mask alone when
|
||||
/// `vector_mask` is set), else the work path or a saved path.
|
||||
fn transform_target(s: &Session, name: &str, layer: Option<LayerId>, vector_mask: bool) -> Result<Path> {
|
||||
let Some(id) = layer else { return resolve_path(s, Some(name)) };
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if vector_mask {
|
||||
return l.vector_mask.as_ref().map(|v| v.path.clone()).ok_or_else(|| EngineError::Other("target layer has no vector mask".into()));
|
||||
}
|
||||
match &l.content {
|
||||
LayerContent::Shape(sh) => Ok(sh.path.clone()),
|
||||
_ => l.vector_mask.as_ref().map(|v| v.path.clone()).ok_or_else(|| EngineError::Other("target layer has no shape path or vector mask".into())),
|
||||
@@ -202,12 +205,27 @@ pub(super) fn path_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "path.transform";
|
||||
let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned();
|
||||
let layer_id = if name == "layer" { Some(layer_id(s, p)?) } else { None };
|
||||
let vector_mask = match p.get("vectorMask") {
|
||||
None => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
Some(_) => return Err(bad(CMD, "`vectorMask` must be true or false")),
|
||||
};
|
||||
// A shape layer's path is its content; only an explicit vector-mask target edits the mask.
|
||||
if vector_mask && layer_id.is_none() {
|
||||
return Err(bad(CMD, "`vectorMask` needs `\"name\":\"layer\"`"));
|
||||
}
|
||||
if let Some(id) = layer_id {
|
||||
// Both explicit and implicit layer-mask paths must respect Lock All on the layer or
|
||||
// its groups. Pixel-only locks still allow mask edits; shape content checks them below.
|
||||
crate::commands::check_all_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
|
||||
}
|
||||
// The box Distort, Perspective and Warp frame: `rect`, else the path's control bounds.
|
||||
let frame = |s: &Session| -> Result<[f64; 4]> {
|
||||
let r = match coords(p, "rect", 4)? {
|
||||
Some(r) => [r[0], r[1], r[2], r[3]],
|
||||
None => {
|
||||
let (x0, y0, x1, y1) = transform_target(s, &name, layer_id)?.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?;
|
||||
let (x0, y0, x1, y1) =
|
||||
transform_target(s, &name, layer_id, vector_mask)?.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?;
|
||||
[x0, y0, x1, y1]
|
||||
}
|
||||
};
|
||||
@@ -262,7 +280,7 @@ pub(super) fn path_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sx, sy) = (number("scaleX", 1.0)?, number("scaleY", 1.0)?);
|
||||
let angle = number("angle", 0.0)?;
|
||||
// An explicit `layer` parameter determines both the transformed path and its pivot (#3062).
|
||||
let path = transform_target(s, &name, layer_id)?;
|
||||
let path = transform_target(s, &name, layer_id, vector_mask)?;
|
||||
let (x0, y0, x1, y1) = path.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?;
|
||||
let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
|
||||
PathMap::Affine(
|
||||
@@ -277,7 +295,8 @@ pub(super) fn path_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
PathMap::Projective(h) => json!({ "name": name, "homography": h.0 }),
|
||||
PathMap::Warp(_) => json!({ "name": name, "warp": true }),
|
||||
};
|
||||
if let Some(id) = layer_id
|
||||
if !vector_mask
|
||||
&& let Some(id) = layer_id
|
||||
&& s.active().and_then(|d| d.doc.layer(id)).is_some_and(|layer| matches!(layer.content, LayerContent::Shape(_)))
|
||||
{
|
||||
with_shape(s, id, "Transform Path", |shape, _| {
|
||||
@@ -533,9 +552,8 @@ fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::P
|
||||
params.remove(k);
|
||||
}
|
||||
let journal = s.journal.len();
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let r = crate::analysis_cmds::compound(s, "Stroke Path", |s| {
|
||||
// The union of what each subpath's command changed; None when one of them didn't say.
|
||||
let mut dmg = Some(Rect::EMPTY);
|
||||
for pl in lines {
|
||||
let pts = stroke_points(&pl.pts, pl.closed, simulate);
|
||||
if pts.is_empty() {
|
||||
@@ -544,25 +562,14 @@ fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::P
|
||||
let mut call = params.clone();
|
||||
call.insert("points".into(), Value::Array(pts.iter().map(|q| json!([q.x, q.y, q.pressure])).collect()));
|
||||
s.execute(cmd, Value::Object(call))?;
|
||||
let r = s.active().and_then(|d| d.last_damage);
|
||||
dmg = dmg.zip(r).map(|(a, b)| {
|
||||
if a.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
a
|
||||
} else {
|
||||
a.union(&b)
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(dmg)
|
||||
Ok(())
|
||||
});
|
||||
s.journal.truncate(journal);
|
||||
let dmg = r?;
|
||||
if let Some(st) = s.active_mut() {
|
||||
st.last_damage = dmg;
|
||||
}
|
||||
let dmg = dmg.unwrap_or_else(|| s.active().map_or(Rect::EMPTY, |d| d.doc.bounds()));
|
||||
r?;
|
||||
// One subpath keeps its command's damage; several are one span, which `Session::dispatch`
|
||||
// reports. The result says what changed either way.
|
||||
let dmg = s.active().and_then(|st| crate::layer_multi_cmds::step_damage(&before, &st.doc).or(Some(st.doc.bounds()))).unwrap_or(Rect::EMPTY);
|
||||
Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] }))
|
||||
}
|
||||
|
||||
|
||||
@@ -508,19 +508,24 @@ pub enum Split {
|
||||
}
|
||||
|
||||
/// The default warp draws rule-of-thirds guides on its single patch. Make those real sections
|
||||
/// before a split, so the cut divides the grid on screen instead of replacing it.
|
||||
fn commit_default_sections(mesh: &mut BezierMesh) {
|
||||
let _ = mesh.split_u(1.0 / 3.0);
|
||||
let _ = mesh.split_u(2.0 / 3.0);
|
||||
let _ = mesh.split_v(1.0 / 3.0);
|
||||
let _ = mesh.split_v(2.0 / 3.0);
|
||||
/// before a split, so the cut divides the grid on screen instead of replacing it. A one-way split
|
||||
/// keeps only the thirds across its line and drops the ones it replaces (#12495).
|
||||
fn commit_default_sections(mesh: &mut BezierMesh, how: Split) {
|
||||
if !matches!(how, Split::Vertical) {
|
||||
let _ = mesh.split_u(1.0 / 3.0);
|
||||
let _ = mesh.split_u(2.0 / 3.0);
|
||||
}
|
||||
if !matches!(how, Split::Horizontal) {
|
||||
let _ = mesh.split_v(1.0 / 3.0);
|
||||
let _ = mesh.split_v(2.0 / 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a split (or removes the split nearest `at`) on `w`'s mesh (presets become custom).
|
||||
pub fn split_warp(w: &Warp, at: Option<[f64; 2]>, how: Split) -> Option<Warp> {
|
||||
let mut mesh = w.to_mesh(1, 1);
|
||||
if !matches!(how, Split::Remove) && w.style == WarpStyle::Custom && mesh.us.len() == 2 && mesh.vs.len() == 2 {
|
||||
commit_default_sections(&mut mesh);
|
||||
commit_default_sections(&mut mesh, how);
|
||||
}
|
||||
let (s, t) = match at {
|
||||
Some(p) => mesh.param_at(p),
|
||||
@@ -751,3 +756,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "warp_split_default_grid_tests.rs"]
|
||||
mod warp_split_default_grid_tests;
|
||||
@@ -308,9 +308,8 @@ fn split_cuts_existing_sections_instead_of_replacing_the_grid() {
|
||||
let plain = Warp::custom(BezierMesh::identity(b, 1, 1), b);
|
||||
let cut = split_warp(&plain, Some([b[0] + 0.25 * 90.0, 30.0]), Split::Vertical).unwrap();
|
||||
let m = cut.mesh.unwrap();
|
||||
for u in [0.0, 0.25, 1.0 / 3.0, 2.0 / 3.0, 1.0] {
|
||||
assert!(m.us.iter().any(|k| (k - u).abs() < 1e-6), "default guides stay, cut at 0.25: {:?}", m.us);
|
||||
}
|
||||
assert_eq!(m.us.len(), 3, "the cut at 0.25 replaces the vertical thirds: {:?}", m.us);
|
||||
assert!((m.us[1] - 0.25).abs() < 1e-6, "{:?}", m.us);
|
||||
assert_eq!(m.vs, vec![0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0]);
|
||||
let grid = BezierMesh::identity(b, 3, 3);
|
||||
let cut = split_warp(&Warp::custom(grid.clone(), b), Some([45.0, 10.0]), Split::Vertical).unwrap();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Which rule-of-thirds guides a split on the Default warp grid turns into real sections (#12495).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The 400 x 300 layer box of the issue, at (200, 150).
|
||||
const BOX: [f64; 4] = [200.0, 150.0, 600.0, 450.0];
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 800, "height": 600})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Runs a split command on the warp being edited, as the Free Transform warp session does.
|
||||
fn split_cmd(s: &mut Session, cmd: &str, warp: Value, at: [f64; 2]) -> BezierMesh {
|
||||
let r = s.execute(cmd, json!({"warp": warp, "at": at})).unwrap();
|
||||
let out: Warp = serde_json::from_value(r["warp"].clone()).unwrap();
|
||||
out.mesh.unwrap()
|
||||
}
|
||||
|
||||
fn default_grid() -> Value {
|
||||
serde_json::to_value(Warp::custom(BezierMesh::identity(BOX, 1, 1), BOX)).unwrap()
|
||||
}
|
||||
|
||||
/// The anchor columns (x along the top row) and anchor rows (y down the left column).
|
||||
fn anchors(m: &BezierMesh) -> (Vec<f64>, Vec<f64>) {
|
||||
let xs = (0..m.nx()).step_by(3).map(|i| m.point(i, 0)[0]).collect();
|
||||
let ys = (0..m.ny()).step_by(3).map(|j| m.point(0, j)[1]).collect();
|
||||
(xs, ys)
|
||||
}
|
||||
|
||||
fn assert_near(got: &[f64], want: &[f64], what: &str) {
|
||||
assert_eq!(got.len(), want.len(), "{what}: {got:?}, want {want:?}");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!((g - w).abs() < 0.01, "{what}: {got:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
const THIRDS_X: [f64; 4] = [200.0, 200.0 + 400.0 / 3.0, 200.0 + 800.0 / 3.0, 600.0];
|
||||
const THIRDS_Y: [f64; 4] = [150.0, 250.0, 350.0, 450.0];
|
||||
|
||||
#[test]
|
||||
fn horizontal_split_of_the_default_grid_is_three_columns_by_two_rows() {
|
||||
let mut s = session();
|
||||
let m = split_cmd(&mut s, "edit.transform.splitWarpHorizontally", default_grid(), [400.0, 320.0]);
|
||||
let (xs, ys) = anchors(&m);
|
||||
assert_near(&xs, &THIRDS_X, "the vertical thirds become anchor columns");
|
||||
assert_near(&ys, &[150.0, 320.0, 450.0], "one row line at the click, the horizontal thirds are gone");
|
||||
assert!(m.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_split_of_the_default_grid_is_two_columns_by_three_rows() {
|
||||
let mut s = session();
|
||||
let m = split_cmd(&mut s, "edit.transform.splitWarpVertically", default_grid(), [300.0, 200.0]);
|
||||
let (xs, ys) = anchors(&m);
|
||||
assert_near(&xs, &[200.0, 300.0, 600.0], "one column line at the click, the vertical thirds are gone");
|
||||
assert_near(&ys, &THIRDS_Y, "the horizontal thirds become anchor rows");
|
||||
assert!(m.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosswise_split_of_the_default_grid_keeps_both_thirds() {
|
||||
let mut s = session();
|
||||
let m = split_cmd(&mut s, "edit.transform.splitWarpCrosswise", default_grid(), [300.0, 320.0]);
|
||||
let (xs, ys) = anchors(&m);
|
||||
assert_near(&xs, &[THIRDS_X[0], 300.0, THIRDS_X[1], THIRDS_X[2], THIRDS_X[3]], "columns");
|
||||
assert_near(&ys, &[150.0, 250.0, 320.0, 350.0, 450.0], "rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_of_an_already_split_grid_adds_only_its_line() {
|
||||
let mut s = session();
|
||||
let rows = split_cmd(&mut s, "edit.transform.splitWarpHorizontally", default_grid(), [400.0, 320.0]);
|
||||
let warp = serde_json::to_value(Warp::custom(rows, BOX)).unwrap();
|
||||
let m = split_cmd(&mut s, "edit.transform.splitWarpVertically", warp, [300.0, 200.0]);
|
||||
let (xs, ys) = anchors(&m);
|
||||
assert_near(&xs, &[THIRDS_X[0], 300.0, THIRDS_X[1], THIRDS_X[2], THIRDS_X[3]], "one more column");
|
||||
assert_near(&ys, &[150.0, 320.0, 450.0], "rows are not rebuilt");
|
||||
// A 3 x 3 grid that is not the Default one: a horizontal split keeps every section.
|
||||
let grid = serde_json::to_value(Warp::custom(BezierMesh::identity(BOX, 3, 3), BOX)).unwrap();
|
||||
let m = split_cmd(&mut s, "edit.transform.splitWarpHorizontally", grid, [400.0, 320.0]);
|
||||
let (xs, ys) = anchors(&m);
|
||||
assert_near(&xs, &THIRDS_X, "columns");
|
||||
assert_near(&ys, &[150.0, 250.0, 320.0, 350.0, 450.0], "rows");
|
||||
}
|
||||
@@ -24,8 +24,13 @@ pub struct DocState {
|
||||
pub pcraft_cache: photocraft_io::PcraftExportCache,
|
||||
/// How much of `revision` came from view-only changes (channel eyes, targeting): pixel caches can ignore those.
|
||||
pub view_revision: u64,
|
||||
/// Area changed by the latest revision (None = assume everything changed).
|
||||
/// Area changed since revision [`DocState::damage_from`] (None = assume everything changed):
|
||||
/// the latest revision's, or a whole command's when it made several.
|
||||
pub last_damage: Option<photocraft_geom::Rect>,
|
||||
/// `(from, to)`: the command that moved `revision` from `from` to `to` in more than one step,
|
||||
/// whose whole span `last_damage` covers. Written only by `Session::dispatch`; any later
|
||||
/// revision makes it stale, so [`DocState::damage_from`] falls back to one revision.
|
||||
pub(crate) damage_span: Option<(u64, u64)>,
|
||||
/// Coalescing key of the latest history step (see [`Session::execute`]'s `coalesce` param).
|
||||
pub coalesce: Option<String>,
|
||||
/// Channels panel: targeted channel and eye toggles (view state, not history).
|
||||
@@ -108,6 +113,7 @@ impl DocState {
|
||||
pcraft_cache: Default::default(),
|
||||
view_revision: 0,
|
||||
last_damage: None,
|
||||
damage_span: None,
|
||||
coalesce: None,
|
||||
channel_view,
|
||||
isolated_layers: Vec::new(),
|
||||
@@ -144,6 +150,15 @@ impl DocState {
|
||||
pub(crate) fn keeps_clean(&self, before: &Document) -> bool {
|
||||
self.saved_revision == self.revision && layer_multi_cmds::selection_only_change(before, &self.doc)
|
||||
}
|
||||
/// The revision [`DocState::last_damage`] is measured from: the revision before the command
|
||||
/// that made it, or the previous revision. A canvas that showed any revision from this one
|
||||
/// up to (not including) `revision` needs only `last_damage` redrawn.
|
||||
pub fn damage_from(&self) -> u64 {
|
||||
match self.damage_span {
|
||||
Some((from, to)) if to == self.revision => from,
|
||||
_ => self.revision.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
/// Move to the next revision; `keep_clean` (from [`Self::keeps_clean`]) moves the saved
|
||||
/// revision with it.
|
||||
pub(crate) fn bump_revision(&mut self, keep_clean: bool) {
|
||||
|
||||
@@ -1790,7 +1790,7 @@ impl Session {
|
||||
// action it plays (a script step) must not flush the steps it ran so far into the action
|
||||
// being recorded, nor move the recording mark past the entries it is about to replace.
|
||||
let recording = if spec.journal && crate::actions_cmds::replayable(id) { self.actions.recording.take() } else { None };
|
||||
let before = self.active().map(|st| st.doc.clone());
|
||||
let before = self.active().map(|st| (st.doc.clone(), st.revision));
|
||||
let docs_before: Vec<_> = self.docs.iter().map(|st| st.doc.id).collect();
|
||||
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
|
||||
// error instead of taking the app down. `edit` only commits a document after its closure
|
||||
@@ -1811,6 +1811,11 @@ impl Session {
|
||||
self.hint_before = None;
|
||||
}
|
||||
let pending = self.jobs.pending.take();
|
||||
if pending.is_none()
|
||||
&& let Some((doc, revision)) = &before
|
||||
{
|
||||
self.note_command_damage(id, doc, *revision);
|
||||
}
|
||||
// Shape Blur by an installed shape's id or name records the geometry it ran with, as the
|
||||
// filter's own prepared params carry it: the journal (Last Filter, recorded actions)
|
||||
// replays where that shape is not installed. The run resolved it already, so it can't fail.
|
||||
@@ -1837,7 +1842,7 @@ impl Session {
|
||||
Ok(Started::Job(jid))
|
||||
}
|
||||
(Ok(v), None) => {
|
||||
if let Some(before) = &before {
|
||||
if let Some((before, _)) = &before {
|
||||
self.add_relayout_damage(before);
|
||||
}
|
||||
if let Some((doc_id, layer_id)) = new_fx_target
|
||||
@@ -1866,6 +1871,29 @@ impl Session {
|
||||
out
|
||||
}
|
||||
|
||||
/// The one place a command's redraw area spans more than one revision. A command that moved
|
||||
/// the active document's revision more than once (Paste Into, Stroke Path, a compound, a
|
||||
/// script) reports what changed since it began, [`layer_multi_cmds::step_damage`] of the
|
||||
/// document before and after, measured from the revision before it. A single-revision command
|
||||
/// keeps the damage it reported. Debug and test builds also count a whole-document redraw of
|
||||
/// a small change ([`Session::whole_redraws`]).
|
||||
pub(crate) fn note_command_damage(&mut self, id: &str, before: &Arc<Document>, from: u64) {
|
||||
let Some(st) = self.active_mut().filter(|st| st.doc.id == before.id && st.revision > from) else { return };
|
||||
if st.revision - from > 1 {
|
||||
st.last_damage = crate::layer_multi_cmds::step_damage(before, &st.doc);
|
||||
st.damage_span = Some((from, st.revision));
|
||||
}
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
if st.last_damage.is_none()
|
||||
&& let Some(pct) = crate::layer_multi_cmds::small_change_pct(before, &st.doc)
|
||||
{
|
||||
eprintln!("warning: {id}: redraws the whole document but changed {pct}% of it");
|
||||
self.whole_redraws += 1;
|
||||
}
|
||||
#[cfg(not(any(test, debug_assertions)))]
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
/// Grows the damage a command reported for the pixels it changed by what effects laid out
|
||||
/// over a layer's frame repaint when it moved ([`photocraft_compose::relayout_damage`]): a
|
||||
/// stroke on a layer with a Gradient Overlay reports the stroke, but the gradient is laid out
|
||||
|
||||
@@ -13,6 +13,52 @@ fn effects(s: &Session) -> Vec<Effect> {
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone()
|
||||
}
|
||||
|
||||
/// #8639: Stroke Emboss bevels the outline of the layer with its Stroke effect, shading only
|
||||
/// the stroke, not the layer; without a stroke it shows nothing, as in Photoshop.
|
||||
#[test]
|
||||
fn stroke_emboss_shades_the_stroke_band_only() {
|
||||
let render = |stroke: bool, bevel: Option<&str>| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 120, "height": 90, "background": "#d8d8d8"})).unwrap();
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [30, 25, 60, 40], "fill": "#3f6fd8"})).unwrap();
|
||||
if stroke {
|
||||
s.execute("layer.layerStyle.stroke", json!({"size": 10, "position": "outside", "color": "#c0c0c0"})).unwrap();
|
||||
}
|
||||
if let Some(style) = bevel {
|
||||
s.execute("layer.layerStyle.bevelEmboss", json!({"style": style, "size": 6, "depth": 200})).unwrap();
|
||||
}
|
||||
let d = &s.active().unwrap().doc;
|
||||
photocraft_compose::render(d, d.bounds()).into_px()
|
||||
};
|
||||
let at = |px: &[[f32; 4]], x: usize, y: usize| px[y * 120 + x];
|
||||
let (plain, embossed) = (render(true, None), render(true, Some("stroke")));
|
||||
// The stroked outline is bevelled inward, inside the stroke only (the stroke covers rows
|
||||
// 15..25 above the shape and 65..75 below it; flat it is 0.753): its top edge faces the
|
||||
// light, its bottom edge away from it, and the stroke's inner part stays flat.
|
||||
for ((x, y), change) in [((60, 16), 1.0), ((60, 73), -1.0), ((60, 23), 0.0), ((60, 66), 0.0)] {
|
||||
let (e, p) = (at(&embossed, x, y)[0], at(&plain, x, y)[0]);
|
||||
let ok = if change > 0.0 {
|
||||
e > p + 0.1
|
||||
} else if change < 0.0 {
|
||||
e < p - 0.1
|
||||
} else {
|
||||
(e - p).abs() < 1e-3
|
||||
};
|
||||
assert!(ok, "({x}, {y}): {e} vs {p}");
|
||||
}
|
||||
// …while the layer's own pixels and the backdrop stay as they were (an Inner Bevel would
|
||||
// shade the rectangle's edge).
|
||||
for (x, y) in [(32, 30), (60, 45), (87, 62), (5, 5)] {
|
||||
assert_eq!(at(&embossed, x, y), at(&plain, x, y), "({x}, {y})");
|
||||
}
|
||||
assert_ne!(at(&render(true, Some("inner")), 32, 30), at(&plain, 32, 30), "the old fallback shaded the layer");
|
||||
// No stroke, nothing to emboss.
|
||||
let (a, b) = (render(false, Some("stroke")), render(false, None));
|
||||
let worst = a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0f32, f32::max);
|
||||
// Equal but for float rounding: an effect sends the layer through the effects compositor.
|
||||
assert!(worst < 1e-6, "{worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_command_sets_technique_contour_and_texture() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -338,6 +338,9 @@ pub struct Session {
|
||||
/// command reporting too narrow a redraw area (#4053). Counted in debug and test builds only;
|
||||
/// tests read it.
|
||||
pub damage_misses: usize,
|
||||
/// Commands that left the whole document to redraw (`last_damage` None) while changing under
|
||||
/// 25 % of it ([`Session::dispatch`]). Counted in debug and test builds only; tests read it.
|
||||
pub whole_redraws: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -291,7 +291,9 @@ impl Session {
|
||||
// state: not a history step. A mapped edit (the four above) gives the exact map; any other
|
||||
// size change falls back to dropping whatever is now outside.
|
||||
let mut samplers_removed = 0;
|
||||
if let Some(a) = sampler_map {
|
||||
// The points sit on pixel centres (#11725), so they follow the geometry map, the one the
|
||||
// symmetry gets: `sampler_map` is that map shifted for pixel indices (a flip or rotation).
|
||||
if let Some(a) = sampler_map.and(symmetry_map) {
|
||||
let bounds = st.doc.bounds();
|
||||
let n = st.color_samplers.len();
|
||||
st.color_samplers.retain_mut(|p| {
|
||||
|
||||
@@ -1539,8 +1539,8 @@ fn named_red_eye_target_does_not_bypass_channel_protection_on_the_active_layer()
|
||||
.unwrap();
|
||||
s.execute("channel.target", json!({"colors":[1,2]})).unwrap();
|
||||
let before = surface(&s);
|
||||
let error = s.execute("paint.redEye", json!({"x":32,"y":32,"layer":"other"})).unwrap_err();
|
||||
assert!(error.to_string().contains("no red-eye pixels"));
|
||||
let result = s.execute("paint.redEye", json!({"x":32,"y":32,"layer":"other"})).unwrap();
|
||||
assert_eq!(result["pixels"], 0, "{result}");
|
||||
assert!(surface(&s) == before);
|
||||
}
|
||||
|
||||
|
||||
@@ -682,7 +682,7 @@ fn text_kerning_roundtrips_and_defaults() {
|
||||
assert!(s.contains("\"kern\":0.0"), "kern defaults to 0");
|
||||
}
|
||||
|
||||
/// Type on a path (#218): the path, start offset and flip survive .pcraft.
|
||||
/// Type on a path (#218): the path, start and end offsets, and flip survive .pcraft.
|
||||
#[test]
|
||||
fn path_text_shape_roundtrips() {
|
||||
use photocraft_doc::text::TextShape;
|
||||
@@ -692,7 +692,7 @@ fn path_text_shape_roundtrips() {
|
||||
let ids: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
|
||||
for id in ids {
|
||||
if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true };
|
||||
t.shape = TextShape::Path { path: path.clone(), start: 12.5, end: Some(83.0), flip: true };
|
||||
}
|
||||
}
|
||||
let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap();
|
||||
@@ -700,7 +700,7 @@ fn path_text_shape_roundtrips() {
|
||||
LayerContent::Text(t) => Some(t.shape.clone()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true }));
|
||||
assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, end: Some(83.0), flip: true }));
|
||||
}
|
||||
|
||||
/// Area type (#218): the whole path, ops and fill rule included, survives .pcraft.
|
||||
|
||||
@@ -807,6 +807,10 @@ impl<'a> Planner<'a> {
|
||||
for c in clipped {
|
||||
content = self.atop(c, content)?;
|
||||
}
|
||||
// Stroke Emboss shades the stroke's band, which only the CPU compositor builds (#8639).
|
||||
if layer.effects.items.iter().any(|e| matches!(e, Effect::BevelEmboss(b) if e.enabled() && b.style == photocraft_doc::BevelStyle::StrokeEmboss)) {
|
||||
return Err(Unsupported(format!("Stroke Emboss on `{}` (composited on the CPU)", layer.name)));
|
||||
}
|
||||
let fx = self.fx.len();
|
||||
self.fx.push(FxLayer { layer, region, bounds: sb });
|
||||
let outline = photocraft_compose::effect_outline(layer).is_some();
|
||||
|
||||
@@ -183,6 +183,47 @@ fn small_layers_composite_only_over_their_bounds() {
|
||||
assert!(p.passes.iter().all(|p| p.clip.is_none()));
|
||||
}
|
||||
|
||||
/// #8639: Stroke Emboss shades the stroke's band, which only the CPU compositor builds: a
|
||||
/// document using it is composited on the CPU, so both show the same pixels.
|
||||
#[test]
|
||||
fn stroke_emboss_defers_to_the_cpu() {
|
||||
use photocraft_doc::{Bevel, BevelStyle, BevelTechnique, Contour, FxCommon};
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("embossed", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(2, 2, 5, 5), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let bevel = |style| Bevel {
|
||||
enabled: true,
|
||||
style,
|
||||
technique: BevelTechnique::Smooth,
|
||||
depth: 1.0,
|
||||
up: true,
|
||||
size: 3.0,
|
||||
soften: 0.0,
|
||||
angle: 90.0,
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: Contour::Linear,
|
||||
gloss_anti_alias: false,
|
||||
highlight: FxCommon::new(BlendMode::Screen, 0.75),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
};
|
||||
l.effects.items.push(photocraft_doc::Effect::BevelEmboss(bevel(BevelStyle::InnerBevel)));
|
||||
d.layers.push(l);
|
||||
assert!(plan(&d).is_ok(), "other bevel styles stay on the GPU");
|
||||
d.layers[1].effects.items[0] = photocraft_doc::Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss));
|
||||
let err = plan(&d).unwrap_err();
|
||||
assert!(err.0.contains("Stroke Emboss"), "{err}");
|
||||
// Turned off, it no longer matters.
|
||||
if let photocraft_doc::Effect::BevelEmboss(b) = &mut d.layers[1].effects.items[0] {
|
||||
b.enabled = false;
|
||||
}
|
||||
assert!(plan(&d).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_are_planned_in_place() {
|
||||
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
|
||||
@@ -1738,7 +1738,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
||||
vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })],
|
||||
),
|
||||
("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]),
|
||||
("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]),
|
||||
// Stroke Emboss is composited on the CPU (`plan::tests::stroke_emboss_defers_to_the_cpu`, #8639).
|
||||
(
|
||||
"bevel texture",
|
||||
vec![Effect::BevelEmboss(Bevel {
|
||||
|
||||
@@ -387,11 +387,12 @@ mod tests {
|
||||
assert!(text_ops(&transparent, ColorMode::Rgb, 72.0).is_none());
|
||||
}
|
||||
|
||||
/// Ensures PDF text export rejects path text shapes and synthetic bold styles that cannot be represented natively.
|
||||
#[test]
|
||||
fn rejects_path_text_and_synthetic_bold() {
|
||||
let on_path = TextLayer {
|
||||
text: "Path".into(),
|
||||
shape: photocraft_doc::text::TextShape::Path { path: Default::default(), start: 0.0, flip: false },
|
||||
shape: photocraft_doc::text::TextShape::Path { path: Default::default(), start: 0.0, end: None, flip: false },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(text_ops(&on_path, ColorMode::Rgb, 72.0).is_none());
|
||||
|
||||
+35
-8
@@ -284,6 +284,25 @@ fn develop_fitted(bytes: &[u8], opts: &DevelopOptions) -> Result<photocraft_raw:
|
||||
photocraft_raw::develop_sensor(&sensor, &opts)
|
||||
}
|
||||
|
||||
/// The camera's embedded JPEG, upright, for opening in place of sensor data that can't be
|
||||
/// developed. A preview without an ICC profile of its own is tagged with the colour space the
|
||||
/// raw's EXIF states (a Sony body set to Adobe RGB writes no profile into the JPEG), as the colour
|
||||
/// fit reads it ([`preview_profile`]); otherwise it opens as untagged sRGB.
|
||||
fn preview_image(raw: &[u8], jpeg: &[u8], limits: &photocraft_raw::Limits) -> Result<Image, IoError> {
|
||||
let mut img = upright_preview(raw, jpeg, limits)?;
|
||||
if img.icc.is_none() && photocraft_raw::embedded_preview_color_space(raw) == Some(PreviewColorSpace::AdobeRgb) {
|
||||
img.icc = Some(Builtin::AdobeRgbCompat.profile().to_bytes().to_vec());
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// A preview opened in place of the raw, with `note` saying why first among the warnings.
|
||||
fn preview_document(name: &str, img: &Image, note: String) -> Result<ImportResult, IoError> {
|
||||
let mut r = image_to_document(name, img)?;
|
||||
r.warnings.insert(0, note);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// In the import note of a raw developed from its sensor data with the default settings (not of
|
||||
/// a preview fallback): what the open-time Camera Raw dialog looks for.
|
||||
pub const DEVELOPED_NOTE: &str = "developed with default settings";
|
||||
@@ -318,18 +337,26 @@ pub fn import_raw_with(name: &str, bytes: &[u8], opts: &DevelopOptions) -> Resul
|
||||
let size = (p.width, p.height);
|
||||
(jxl_preview_image(bytes, p)?, "JPEG XL", size)
|
||||
}
|
||||
(None, Some(p)) => (upright_preview(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
|
||||
(None, Some(p)) => (preview_image(bytes, p.jpeg, &opts.limits)?, "JPEG", (p.width, p.height)),
|
||||
(None, None) => return Err(IoError::Raw(RawError::Unsupported(reason))),
|
||||
};
|
||||
let mut r = image_to_document(name, &img)?;
|
||||
let depth = if img.sample_type() == SampleType::U8 { "8-bit" } else { "16-bit" };
|
||||
r.warnings.insert(
|
||||
0,
|
||||
format!(
|
||||
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
|
||||
),
|
||||
let note = format!(
|
||||
"{format}: {reason} is not supported yet; opened the camera's embedded {w}x{h} {kind} preview instead ({depth}, not the raw sensor data)"
|
||||
);
|
||||
Ok(r)
|
||||
preview_document(name, &img, note)
|
||||
}
|
||||
// Damaged sensor data: the camera's JPEG is often still intact, so the photo opens.
|
||||
Err(RawError::Malformed(reason)) => {
|
||||
match photocraft_raw::embedded_preview(bytes).map(|p| (p.width, p.height, preview_image(bytes, p.jpeg, &opts.limits))) {
|
||||
Some((w, h, Ok(img))) => {
|
||||
let note = format!(
|
||||
"{format}: the raw sensor data is damaged ({reason}); opened the camera's embedded {w}x{h} JPEG preview instead (8-bit, not the raw sensor data)"
|
||||
);
|
||||
preview_document(name, &img, note)
|
||||
}
|
||||
_ => Err(IoError::Raw(RawError::Malformed(reason))),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(IoError::Raw(e)),
|
||||
}
|
||||
|
||||
@@ -553,3 +553,26 @@ fn photoshop_kerning_oracles() {
|
||||
assert!(worst <= worst_max && mean <= mean_max, "{name}: mean {mean}, worst {worst}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer's range on its path (a moved start and an end limit) survives a PSD save and
|
||||
/// open, without an export warning.
|
||||
#[test]
|
||||
fn psd_keeps_a_text_path_range() {
|
||||
use photocraft_doc::text::TextShape;
|
||||
use photocraft_doc::vector::Subpath;
|
||||
|
||||
let mut doc = Document::new("range", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
let mut text = layer("hello", &[]);
|
||||
text.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (60.0, 0.0)]), start: 5.0, end: Some(20.0), flip: false };
|
||||
doc.layers.push(Layer::new("limited", LayerContent::Text(text)));
|
||||
let result = photocraft_io::export(&doc, "range.psd", &Default::default()).unwrap();
|
||||
assert!(!result.warnings.iter().any(|w| w.contains("limited")), "{:?}", result.warnings);
|
||||
let back = photocraft_io::import("range.psd", &result.bytes).unwrap().document;
|
||||
let shape = back.layers.iter().find_map(|l| match &l.content {
|
||||
LayerContent::Text(t) => Some(t.shape.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let Some(TextShape::Path { path, start, end: Some(end), flip: false }) = shape else { panic!("{shape:?}") };
|
||||
assert!(!path.closed && path.knots.len() == 2, "the whole path is saved: {path:?}");
|
||||
assert!((start - 5.0).abs() < 0.01 && (end - 20.0).abs() < 0.01, "{start} {end}");
|
||||
}
|
||||
@@ -183,6 +183,12 @@ fn a_developed_raw_keeps_its_camera_when_the_exif_directory_empties() {
|
||||
/// A NEF-like file with Nikon's (undocumented) compression and a full-size
|
||||
/// baseline JPEG preview in IFD0.
|
||||
fn nef_with_preview() -> Vec<u8> {
|
||||
nef_with_preview_exif(false)
|
||||
}
|
||||
|
||||
/// The same; `adobe_rgb`: the EXIF says the camera was set to Adobe RGB (ColorSpace
|
||||
/// Uncalibrated, InteropIndex "R03"), as on a Sony body, and the JPEG carries no profile.
|
||||
fn nef_with_preview_exif(adobe_rgb: bool) -> Vec<u8> {
|
||||
let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![180; 32 * 20 * 3]).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
let mut t = TiffBuilder::default();
|
||||
@@ -199,16 +205,32 @@ fn nef_with_preview() -> Vec<u8> {
|
||||
(33421, Val::Short(vec![2, 2])),
|
||||
(33422, Val::Byte(vec![0, 1, 1, 2])),
|
||||
]);
|
||||
let ifd0 = t.ifd(vec![
|
||||
let mut tags = vec![
|
||||
(271, Val::Ascii("NIKON CORPORATION".into())),
|
||||
(330, Val::Ifds(vec![raw])),
|
||||
(513, Val::Blobs(vec![preview])),
|
||||
(514, Val::Long(vec![jpeg.len() as u32])),
|
||||
]);
|
||||
];
|
||||
if adobe_rgb {
|
||||
let interop = t.ifd(vec![(0x0001, Val::Ascii("R03".into()))]);
|
||||
let exif = t.ifd(vec![(0xA001, Val::Short(vec![0xFFFF])), (0xA005, Val::Ifds(vec![interop]))]);
|
||||
tags.push((34665, Val::Ifds(vec![exif])));
|
||||
}
|
||||
let ifd0 = t.ifd(tags);
|
||||
t.chain = vec![ifd0];
|
||||
t.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_fallback_keeps_the_colour_space_the_raw_states() {
|
||||
let tagged = import("DSC_0001.NEF", &nef_with_preview_exif(true)).unwrap();
|
||||
let icc = tagged.document.icc_profile.as_deref().expect("an Adobe RGB preview opens tagged");
|
||||
let profile = photocraft_cms::Profile::parse(icc).unwrap();
|
||||
assert!(profile.same_colors(photocraft_cms::Builtin::AdobeRgbCompat.profile()));
|
||||
// Unstated: untagged sRGB, as before.
|
||||
assert!(import("DSC_0001.NEF", &nef_with_preview()).unwrap().document.icc_profile.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_raw_falls_back_to_the_embedded_preview() {
|
||||
let r = import("DSC_0001.NEF", &nef_with_preview()).unwrap();
|
||||
@@ -313,6 +335,38 @@ fn cr3_opens_its_upright_camera_jpeg_with_explicit_preview_warning() {
|
||||
assert!(r.warnings[0].contains("CR3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_raw_opens_its_camera_jpeg_with_a_warning() {
|
||||
use photocraft_raw::testgen::{RafPacking, RafSpec, XTRANS};
|
||||
let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![120; 32 * 20 * 3]).unwrap();
|
||||
let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
// A lossless compressed RAF (all-zero samples) with a real camera JPEG.
|
||||
let mut spec = RafSpec::new(780, 18, vec![0; 780 * 18], XTRANS.to_vec());
|
||||
spec.packing = RafPacking::Compressed { lossy: false };
|
||||
spec.black = vec![0];
|
||||
spec.preview_jpeg = Some(jpeg);
|
||||
let mut b = spec.build();
|
||||
assert_eq!(import("DSCF0001.RAF", &b).unwrap().document.depth, SampleType::U16, "intact: developed");
|
||||
|
||||
// Zero bits inside the first stripe overrun the residual escape: malformed sensor data.
|
||||
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
|
||||
b[at + 96..at + 160].fill(0);
|
||||
let r = import("DSCF0001.RAF", &b).unwrap();
|
||||
assert_eq!((r.document.size.width, r.document.size.height, r.document.depth), (32, 20, SampleType::U8));
|
||||
assert!(r.warnings[0].contains("damaged") && r.warnings[0].contains("8-bit, not the raw sensor data"), "{:?}", r.warnings);
|
||||
|
||||
// Without a usable preview it stays an error.
|
||||
spec.preview_jpeg = None;
|
||||
let mut b = spec.build();
|
||||
let at = b.windows(5).position(|w| w == b"IS\x01\x10\x0e").unwrap();
|
||||
b[at + 96..at + 160].fill(0);
|
||||
match import("DSCF0001.RAF", &b) {
|
||||
Err(e @ IoError::Raw(_)) => assert!(e.to_string().contains("malformed"), "{e}"),
|
||||
Err(e) => panic!("expected a raw error, got {e}"),
|
||||
Ok(_) => panic!("damaged sensor data without a preview must not open"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_raws_do_not_panic() {
|
||||
let b = nef_with_preview();
|
||||
|
||||
@@ -196,7 +196,7 @@ fn corpus_photoshop_path_text_opens_on_its_path() {
|
||||
let layer = all.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap();
|
||||
let LayerContent::Text(t) = &layer.content else { unreachable!() };
|
||||
let doc_anchors = |t: &photocraft_doc::TextLayer| -> Vec<Point> {
|
||||
let TextShape::Path { path, start, flip } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) };
|
||||
let TextShape::Path { path, start, flip, .. } = &t.shape else { panic!("{file}: not path text: {:?}", t.shape) };
|
||||
assert_eq!((path.closed, *start, *flip), (closed, 0.0, false), "{file}");
|
||||
path.knots.iter().map(|k| t.transform.apply(k.anchor)).collect()
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth),
|
||||
//! so nothing is converted through RGB; only alpha changes.
|
||||
|
||||
use photocraft_color::{SampleType, read_sample, write_sample};
|
||||
use photocraft_color::{ColorMode, SampleType, read_sample, write_sample};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::{Surface, from_rgba};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -33,6 +33,15 @@ pub struct BgEraseSettings {
|
||||
pub foreground: [f32; 4],
|
||||
/// Straight RGBA background swatch (for [`Sampling::BackgroundSwatch`]).
|
||||
pub background: [f32; 4],
|
||||
/// The inks `foreground` was picked as (its sRGB colour, the CMYK values), for a CMYK target:
|
||||
/// Protect Foreground Color compares against exactly these, since an sRGB round trip does not
|
||||
/// give them back (#12352). Only valid while `foreground` is that sRGB colour.
|
||||
#[serde(skip)]
|
||||
pub foreground_cmyk: Option<([f32; 3], [f32; 4])>,
|
||||
/// The inks `background` was picked as, like [`Self::foreground_cmyk`] (for
|
||||
/// [`Sampling::BackgroundSwatch`]).
|
||||
#[serde(skip)]
|
||||
pub background_cmyk: Option<([f32; 3], [f32; 4])>,
|
||||
}
|
||||
|
||||
impl Default for BgEraseSettings {
|
||||
@@ -44,6 +53,8 @@ impl Default for BgEraseSettings {
|
||||
protect_foreground: false,
|
||||
foreground: [0.0, 0.0, 0.0, 1.0],
|
||||
background: [1.0; 4],
|
||||
foreground_cmyk: None,
|
||||
background_cmyk: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,9 +132,18 @@ pub fn background_eraser_effect(pre: &Surface, bs: &BgEraseSettings) -> Option<i
|
||||
let a = alpha_index(&fmt)?;
|
||||
let nc = a;
|
||||
let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let native = |c: [f32; 4]| -> Vec<f32> { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() };
|
||||
let swatch = native(bs.background);
|
||||
let fg: Vec<f32> = native(bs.foreground).into_iter().map(|v| at_sample_precision(fmt.sample, v)).collect();
|
||||
// A colour picked as CMYK is compared by its inks in a CMYK target (#12352), at the precision
|
||||
// the document stores them.
|
||||
let native = |c: [f32; 4], picked: Option<([f32; 3], [f32; 4])>| -> Vec<f32> {
|
||||
match picked {
|
||||
Some((rgb, inks)) if fmt.mode == ColorMode::Cmyk && rgb == [c[0], c[1], c[2]] => {
|
||||
inks.iter().take(nc).map(|&v| at_sample_precision(fmt.sample, v)).collect()
|
||||
}
|
||||
_ => from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec(),
|
||||
}
|
||||
};
|
||||
let swatch = native(bs.background, bs.background_cmyk);
|
||||
let fg: Vec<f32> = native(bs.foreground, bs.foreground_cmyk).into_iter().map(|v| at_sample_precision(fmt.sample, v)).collect();
|
||||
let (sampling, limits, protect_foreground) = (bs.sampling, bs.limits, bs.protect_foreground);
|
||||
let float = fmt.sample == SampleType::F32;
|
||||
let mut once: Option<Option<Vec<f32>>> = None;
|
||||
@@ -366,6 +386,62 @@ mod tests {
|
||||
assert_eq!(t, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmyk_targets_compare_the_picked_inks() {
|
||||
// #12352: cyan inks whose sRGB stand-in converts back to other inks.
|
||||
const STAND_IN: [f32; 4] = [0.0, 0.68, 0.94, 1.0];
|
||||
let picked = Some(([STAND_IN[0], STAND_IN[1], STAND_IN[2]], [1.0, 0.0, 0.0, 0.0]));
|
||||
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 80, 40), &from_rgba(&fmt, [1.0; 4]));
|
||||
s.fill_rect(Rect::new(30, 0, 50, 40), &[1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let st = stroke(&[(10.0, 20.0), (70.0, 20.0)]);
|
||||
let base = BgEraseSettings { limits: Limits::Discontiguous, tolerance: 0.01, ..Default::default() };
|
||||
let protect = BgEraseSettings { protect_foreground: true, foreground: STAND_IN, foreground_cmyk: picked, ..base.clone() };
|
||||
let mut t = s.clone();
|
||||
apply_background_eraser(&mut t, &st, &protect, None);
|
||||
assert_eq!(alpha(&t, 40, 20), 1.0, "the picked foreground inks are protected");
|
||||
assert_eq!(alpha(&t, 10, 20), 0.0, "white erased");
|
||||
let swatch = BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: STAND_IN, background_cmyk: picked, ..base.clone() };
|
||||
let mut t = s.clone();
|
||||
apply_background_eraser(&mut t, &st, &swatch, None);
|
||||
assert_eq!(alpha(&t, 40, 20), 0.0, "the picked background inks are erased fully");
|
||||
assert_eq!(alpha(&t, 10, 20), 1.0, "white kept");
|
||||
// Inks picked for another colour, and non-CMYK targets, use the stand-in as before.
|
||||
let stale = BgEraseSettings { background_cmyk: Some(([0.5; 3], [1.0, 0.0, 0.0, 0.0])), ..swatch.clone() };
|
||||
let mut t = s.clone();
|
||||
apply_background_eraser(&mut t, &st, &stale, None);
|
||||
assert_eq!(alpha(&t, 40, 20), 1.0, "stale inks are ignored");
|
||||
let rgb = PixelFormat::RGBA8;
|
||||
let mut r = Surface::new(rgb);
|
||||
r.fill_rect(Rect::new(0, 0, 80, 40), &from_rgba(&rgb, STAND_IN));
|
||||
let (mut with, mut without) = (r.clone(), r.clone());
|
||||
apply_background_eraser(&mut with, &st, &swatch, None);
|
||||
apply_background_eraser(&mut without, &st, &BgEraseSettings { background_cmyk: None, ..swatch }, None);
|
||||
assert_eq!(with, without, "an RGB target ignores the inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picked_swatch_inks_match_their_stored_levels() {
|
||||
// #12352: typed inks between two 8-bit levels (30% is stored as 77/255) still match the
|
||||
// pixels filled with them, as the foreground already does.
|
||||
const INKS: [f32; 4] = [0.3, 0.0, 0.0, 0.0];
|
||||
const STAND_IN: [f32; 4] = [0.7, 1.0, 1.0, 1.0];
|
||||
let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
s.fill_rect(Rect::new(0, 0, 80, 40), &[INKS[0], INKS[1], INKS[2], INKS[3], 1.0]);
|
||||
let bs = BgEraseSettings {
|
||||
sampling: Sampling::BackgroundSwatch,
|
||||
limits: Limits::Discontiguous,
|
||||
tolerance: 0.01,
|
||||
background: STAND_IN,
|
||||
background_cmyk: Some(([STAND_IN[0], STAND_IN[1], STAND_IN[2]], INKS)),
|
||||
..Default::default()
|
||||
};
|
||||
apply_background_eraser(&mut s, &stroke(&[(10.0, 20.0), (70.0, 20.0)]), &bs, None);
|
||||
assert_eq!(alpha(&s, 40, 20), 0.0, "the stored inks are erased fully");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pass_clears_the_whole_stroke_when_dabs_overlap_erased_pixels() {
|
||||
// #1280: later dabs land their hotspot on pixels earlier dabs already erased.
|
||||
|
||||
@@ -538,7 +538,7 @@ pub struct Smoothing {
|
||||
|
||||
impl Default for Smoothing {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: true, adjust_for_zoom: true }
|
||||
Self { enabled: true, amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: false, adjust_for_zoom: true }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ fn pencil_1px_line_is_the_same_whichever_way_and_however_smoothed() {
|
||||
let pixels = |a: (f64, f64), e: (f64, f64), amount: f32| {
|
||||
let mut b = BrushSettings { aliased: true, hardness: 1.0, size: 1.0, ..brush() };
|
||||
b.smoothing.amount = amount;
|
||||
b.smoothing.catch_up_on_end = true;
|
||||
let s = paint(&b, &[StrokePoint::new(a.0, a.1, 1.0), StrokePoint::new(e.0, e.1, 1.0)], 24, 24);
|
||||
(0..24).flat_map(|y| (0..24).map(move |x| (x, y))).filter(|&(x, y)| s.rgba(x, y)[3] > 0.0).collect::<Vec<_>>()
|
||||
};
|
||||
@@ -325,6 +326,7 @@ fn hard_round_presets_have_smooth_large_stroke_outlines() {
|
||||
let mut b = preset.brush.clone();
|
||||
b.size = 150.0;
|
||||
b.smoothing.amount = smoothing;
|
||||
b.smoothing.catch_up_on_end = true;
|
||||
let mut s = Surface::new(fmt);
|
||||
render_stroke(&mut s, &b, &line(100.0, 800.0, 120.0), None, false, 1.0);
|
||||
let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
|
||||
@@ -869,7 +871,7 @@ fn smoothing_modes() {
|
||||
let zig: Vec<StrokePoint> = (0..40).map(|i| StrokePoint::new(i as f64 * 5.0, if i % 2 == 0 { 0.0 } else { 6.0 }, 1.0)).collect();
|
||||
let dev = |pts: &[StrokePoint]| pts.iter().map(|p| (p.y - 3.0).abs()).sum::<f64>() / pts.len() as f64;
|
||||
// Exponential smoothing reduces the zig-zag.
|
||||
let cfg = Smoothing { amount: 0.8, ..Default::default() };
|
||||
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
|
||||
let sm = smooth_points(&cfg, 1.0, &zig);
|
||||
assert!(dev(&sm[5..sm.len() - 1]) < dev(&zig) * 0.5);
|
||||
// Catch-up on end finishes at the last input point; without it, the stroke ends short.
|
||||
@@ -908,7 +910,7 @@ fn smoothing_modes() {
|
||||
fn disabled_smoothing_keeps_its_settings_and_paints_unsmoothed_at_every_depth() {
|
||||
let pts = vec![StrokePoint::new(10.0, 20.0, 1.0), StrokePoint::new(30.0, 8.0, 0.5), StrokePoint::new(50.0, 20.0, 0.8)];
|
||||
for pulled_string in [false, true] {
|
||||
let smoothing = Smoothing { enabled: false, amount: 0.8, pulled_string, ..Default::default() };
|
||||
let smoothing = Smoothing { enabled: false, amount: 0.8, pulled_string, catch_up_on_end: true, ..Default::default() };
|
||||
let got = smooth_points(&smoothing, 1.0, &pts);
|
||||
assert_eq!(got.len(), pts.len());
|
||||
for (a, b) in got.iter().zip(&pts) {
|
||||
@@ -940,7 +942,7 @@ fn disabled_smoothing_keeps_its_settings_and_paints_unsmoothed_at_every_depth()
|
||||
#[test]
|
||||
fn smoothing_string_runs_from_the_lagging_brush_to_the_pointer() {
|
||||
// #3479: the canvas draws the line between the brush and the pointer.
|
||||
let cfg = Smoothing { amount: 0.8, ..Default::default() };
|
||||
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
|
||||
let mut s = Smoother::new(&cfg, 1.0);
|
||||
let mut out = Vec::new();
|
||||
assert_eq!(s.string(), None, "no stroke yet");
|
||||
@@ -1005,7 +1007,7 @@ fn a_smoothed_brush_keeps_the_pressure_of_where_it_is_when_the_pen_lifts() {
|
||||
// a hairline from the trailing brush to the pointer.
|
||||
let pts: Vec<StrokePoint> =
|
||||
(0..=20).map(|i| StrokePoint { time: f64::from(i) * 4.0, ..StrokePoint::new(f64::from(i) * 10.0, 0.0, if i < 20 { 1.0 } else { 0.05 }) }).collect();
|
||||
let out = smooth_points(&Smoothing { amount: 0.8, ..Default::default() }, 1.0, &pts);
|
||||
let out = smooth_points(&Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() }, 1.0, &pts);
|
||||
let (last, rest) = out.split_last().unwrap();
|
||||
assert_eq!((last.x, last.pressure), (200.0, 0.05), "the catch-up still ends at the pen's last sample");
|
||||
let lifted = rest.last().unwrap();
|
||||
@@ -1034,7 +1036,7 @@ fn a_pulled_string_brush_takes_the_pose_along_the_string() {
|
||||
fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() {
|
||||
// #7747: only the pressure changes while the pointer stands still. The brush lags in pose,
|
||||
// so catch-up must keep running and the stroke end at the pen's last pressure.
|
||||
let cfg = Smoothing { amount: 0.8, ..Default::default() };
|
||||
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
|
||||
let mut s = Smoother::new(&cfg, 1.0);
|
||||
let mut out = Vec::new();
|
||||
s.push(StrokePoint::new(50.0, 50.0, 1.0), &mut out);
|
||||
@@ -1053,7 +1055,7 @@ fn a_smoothed_brush_catches_up_with_a_pen_lifted_in_place() {
|
||||
#[test]
|
||||
fn catch_up_on_stroke_end_finishes_the_pose_of_a_still_pen() {
|
||||
// #7747: tilt, rotation and wheel changed in place; the end catch-up lands on all of them.
|
||||
let cfg = Smoothing { amount: 0.8, ..Default::default() };
|
||||
let cfg = Smoothing { amount: 0.8, catch_up_on_end: true, ..Default::default() };
|
||||
let a = StrokePoint { rotation: 350.0, ..StrokePoint::new(50.0, 50.0, 1.0) };
|
||||
let b = StrokePoint { time: 16.0, tilt_x: 40.0, tilt_y: -20.0, rotation: 10.0, wheel: 0.5, ..StrokePoint::new(50.0, 50.0, 1.0) };
|
||||
let mut s = Smoother::new(&cfg, 1.0);
|
||||
@@ -1314,7 +1316,7 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
// Live previews draw the smoothing catch-up tail before the stroke ends: compositing the
|
||||
// tail preview over the incremental composite gives exactly the finished stroke.
|
||||
let fmt = PixelFormat::new(ColorMode::Rgb, SampleType::U16, true);
|
||||
let b = BrushSettings { size: 9.0, smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..brush() };
|
||||
let b = BrushSettings { size: 9.0, smoothing: Smoothing { amount: 0.7, catch_up_on_end: true, ..Default::default() }, ..brush() };
|
||||
let pts: Vec<StrokePoint> = (0..12).map(|i| StrokePoint::new(5.0 + i as f64 * 9.0, 20.0 + (i % 3) as f64 * 7.0, 1.0)).collect();
|
||||
let pre = Surface::new(fmt);
|
||||
let mut live = pre.clone();
|
||||
@@ -1339,7 +1341,7 @@ fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk()
|
||||
// every pass and merging them with `composite_union_many` gives (the commit takes these pixels).
|
||||
let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() };
|
||||
let mut brushes = vec![base.clone()];
|
||||
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() });
|
||||
brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, catch_up_on_end: true, ..Default::default() }, ..base.clone() });
|
||||
let mut b = base.clone();
|
||||
b.dual_brush.enabled = true;
|
||||
b.dual_brush.size = 6.0;
|
||||
|
||||
@@ -408,3 +408,6 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod brush_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod smoothing_catch_up_default_tests;
|
||||
@@ -31,11 +31,15 @@ pub struct MixerSettings {
|
||||
pub load_after_stroke: bool,
|
||||
/// Empty the brush after each stroke (the automatic Clean option).
|
||||
pub clean_after_stroke: bool,
|
||||
/// Load Solid Colors Only (the load menu's checkable entry, `solidColorsOnly` in
|
||||
/// `tools.setBrush`): ⌥-click loads the one colour under the hotspot instead of the sampled tip
|
||||
/// image (#11952). Off by default. Load Brush and the load after each stroke ignore it.
|
||||
pub solid_colors_only: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, clean_after_stroke: true, load_after_stroke: true }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false, clean_after_stroke: true, load_after_stroke: true, solid_colors_only: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Catch-up on Stroke End starts unchecked, as in Photoshop (#11887).
|
||||
|
||||
use crate::StrokePoint;
|
||||
use crate::brush::{BrushSettings, Smoothing};
|
||||
use crate::dynamics::smooth_points;
|
||||
|
||||
/// A quick drag from x = 100 to x = 300 in 40 steps.
|
||||
fn drag() -> Vec<StrokePoint> {
|
||||
(0..=40).map(|i| StrokePoint::new(100.0 + f64::from(i) * 5.0, 100.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catch_up_on_end_defaults_off_and_the_others_stay() {
|
||||
let s = Smoothing::default();
|
||||
assert!(!s.catch_up_on_end);
|
||||
assert!(!s.pulled_string && s.catch_up && s.adjust_for_zoom);
|
||||
assert!(!BrushSettings::default().smoothing.catch_up_on_end);
|
||||
let json = serde_json::to_value(&BrushSettings::default().smoothing).unwrap();
|
||||
assert_eq!(json["catchUpOnEnd"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_smoothed_stroke_ends_short_of_the_release_point() {
|
||||
let cfg = Smoothing { amount: 0.5, ..Default::default() };
|
||||
let out = smooth_points(&cfg, 1.0, &drag());
|
||||
let end = out.last().unwrap();
|
||||
assert!(end.x < 299.0, "ends at {}", end.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catch_up_on_end_on_reaches_the_release_point() {
|
||||
let cfg = Smoothing { amount: 0.5, catch_up_on_end: true, ..Default::default() };
|
||||
let out = smooth_points(&cfg, 1.0, &drag());
|
||||
let end = out.last().unwrap();
|
||||
assert_eq!((end.x, end.y), (300.0, 100.0));
|
||||
}
|
||||
@@ -195,6 +195,13 @@ impl Drop for Reservation {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `RESERVED` is process-wide, and `cargo test` runs this module's tests on threads of one
|
||||
/// process (nextest gives each test its own), so the tests that reserve take turns: one's
|
||||
/// reservation must not show up in another's before/after count.
|
||||
static RESERVING: Mutex<()> = Mutex::new(());
|
||||
fn reserving() -> std::sync::MutexGuard<'static, ()> {
|
||||
RESERVING.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
#[test]
|
||||
fn ceiling_includes_non_tile_memory_and_other_apps() {
|
||||
let s = Snapshot { total_bytes: 64 << 30, available_bytes: 10 << 30, process_resident_bytes: 20 << 30, process_virtual_bytes: 100 << 30 };
|
||||
@@ -217,6 +224,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn an_operation_that_fits_alone_waits_for_a_concurrent_one() {
|
||||
let _turn = reserving();
|
||||
let available = refresh().transient_bytes;
|
||||
let first = try_reserve_operation(available / 2 + 1).unwrap();
|
||||
let releaser = std::thread::spawn(move || {
|
||||
@@ -230,6 +238,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn reservations_are_bounded_and_returned() {
|
||||
let _turn = reserving();
|
||||
let before = stats().2;
|
||||
let r = try_reserve(1024).unwrap();
|
||||
assert_eq!(stats().2, before + 1024);
|
||||
|
||||
Loaded 100 of 309 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user