Merge main into feat/ui-217-4-columns-unit

This commit is contained in:
FredApps committed 2026-11-10 18:41:49 +01:00
commit 96581039a8
309 files changed
+20746 -1121

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -5767,7 +5767,7 @@ dependencies = [
"photocraft-psd",
"photocraft-raster",
"rayon",
"skrifa 0.44.0",
"skrifa 0.47.0",
]
[[package]]
+1 -1
View File
@@ -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))
+117 -3
View File
@@ -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]
+1 -1
View File
@@ -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));
+2 -2
View File
@@ -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}");
}
}
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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

+7
View File
@@ -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
+46 -6
View File
@@ -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)]);
}
}
+5 -5
View File
@@ -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() {
+3 -3
View File
@@ -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();
+3 -1
View File
@@ -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, …).
+95 -25
View File
@@ -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.
+144
View File
@@ -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";
+1
View File
@@ -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;
+45 -2
View File
@@ -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));
+297
View File
@@ -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);
}
+66 -3
View File
@@ -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,
})
+66
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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 {
+5 -5
View File
@@ -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"))?
+4 -2
View File
@@ -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)
}
};
+12 -7
View File
@@ -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");
}
}
+4
View File
@@ -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;
+3 -1
View File
@@ -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"))));
}
+2 -2
View File
@@ -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)))
}
+212
View File
@@ -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", &params, "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());
+8 -3
View File
@@ -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();
+5 -1
View File
@@ -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.
+1 -1
View File
@@ -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 {
+4
View File
@@ -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);
+34 -5
View File
@@ -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)
}
+245
View File
@@ -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);
+11 -1
View File
@@ -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))
}
+28 -2
View File
@@ -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()
}
+1 -2
View File
@@ -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");
}
}
+2 -1
View File
@@ -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);
}
+26 -7
View File
@@ -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;
+63 -22
View File
@@ -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();
+26 -5
View File
@@ -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");
}
+27 -4
View File
@@ -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);
}
+22
View File
@@ -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");
}
}
+37 -4
View File
@@ -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);
+18 -3
View File
@@ -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:?}");
}
+257 -1
View File
@@ -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);
}
+8 -3
View File
@@ -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);
}
+21 -20
View File
@@ -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);
}
+22 -3
View File
@@ -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;
+40
View File
@@ -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));
}
+111 -4
View File
@@ -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(
+219 -1
View File
@@ -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, .. }));
}
+1 -1
View File
@@ -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
),
+31 -24
View File
@@ -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()] }))
}
+16 -7
View File
@@ -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;
+2 -3
View File
@@ -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");
}
+16 -1
View File
@@ -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) {
+30 -2
View File
@@ -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
+46
View File
@@ -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();
+3
View File
@@ -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)]
+3 -1
View File
@@ -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| {
+2 -2
View File
@@ -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);
}
+3 -3
View File
@@ -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.
+4
View File
@@ -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();
+41
View File
@@ -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);
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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
View File
@@ -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)),
}
+23
View File
@@ -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}");
}
+56 -2
View File
@@ -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();
+1 -1
View File
@@ -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()
};
+80 -4
View File
@@ -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.
+1 -1
View File
@@ -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 }
}
}
+10 -8
View File
@@ -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;
+3
View File
@@ -408,3 +408,6 @@ mod tests {
#[cfg(test)]
mod brush_tests;
#[cfg(test)]
mod smoothing_catch_up_default_tests;
+5 -1
View File
@@ -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));
}
+9
View File
@@ -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