Merge remote-tracking branch 'origin/main' into pr-9077

# Conflicts:
#	docs/gaps.md
#	docs/target-app-parity.md
This commit is contained in:
David Mann
2026-11-08 03:04:43 +01:00
270 changed files with 19740 additions and 796 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ jobs:
PHOTOCRAFT_NO_PRINT=1
LPDEST=photocraft-no-such-printer
timeout-minutes: 100
# fmt, clippy, layers, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
# fmt, clippy, layers, panel-commands, scorecard, tests, panic_hunt, doctests and the wasm32 check, with
# `--locked --features heif`, then the missing-translations report (xtask/src/main.rs).
command: python3 scripts/test-ci-gpu-lavapipe.py && cargo xtask ci && python3 scripts/ci-gpu-lavapipe.py
+1
View File
@@ -148,6 +148,7 @@ When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower
cargo test -p <crates you touched>
cargo clippy -p <crates> --all-targets -- -D warnings
cargo xtask layers
cargo xtask panel-commands # if you touched ui-egui: click handlers dispatch command ids (rule 1)
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity-checklist.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
+1
View File
@@ -38,6 +38,7 @@ next to them.
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian UI translations | @tronicum with Claude Code, AI-assisted (machine-translated); not yet reviewed by a native speaker | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/th.tsv` | Thai UI translations | @Thanawatttt and PhotoCraft contributors, with opencode (DeepSeek) as an AI contributor | Machine-assisted first draft from PhotoCraft's English labels, reviewed for UI terminology | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ta.tsv` | Tamil UI translations | @newsbaba24x7 and PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/ka.tsv` | Georgian UI translations | @devius with Claude Code, AI-assisted | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `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) |
Generated
+1
View File
@@ -5242,6 +5242,7 @@ dependencies = [
"clap_complete",
"clap_mangen",
"photocraft-automation",
"photocraft-cms",
"photocraft-codecs",
"photocraft-doc",
"photocraft-engine",
+2 -2
View File
@@ -185,8 +185,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
<table>
<tr>
<td width="33%" valign="top">
<h4>🧰 68 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
<h4>🧰 69 tools</h4>
Move · Artboard · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Perspective Crop · Slice · Slice Select · Frame · Eyedropper · Color Sampler · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Art History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Convert Point · Horizontal and Vertical Type · Horizontal and Vertical Type Mask · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Star · Line · Custom Shape · Hand · Rotate View · Zoom
</td>
<td width="33%" valign="top">
<h4>🖌️ A real brush engine</h4>
+2
View File
@@ -47,6 +47,8 @@ serde = { workspace = true }
tempfile = "3"
# Reading back a saved PSD's merged-image flag (#8064).
photocraft-psd = { workspace = true }
# A real ICC profile for the --metadata test (#9466).
photocraft-cms = { workspace = true }
# Synthetic camera raws (#7789).
photocraft-raw = { workspace = true, features = ["testgen"] }
# Shell completions and man pages (examples/man-and-completions.rs, for the packages); not in
+17 -1
View File
@@ -17,7 +17,7 @@ use clap::parser::ValueSource;
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use photocraft_automation::args::{AutomationArgs, ControlArgs, blank_env_is_unset, conflict, control_port, name_env_vars};
use photocraft_codecs::{JpegFormat, TiffCompression};
use photocraft_io::{DdsCompression, DpxBits, TgaBits};
use photocraft_io::{DdsCompression, DpxBits, TgaBits, XmpEmbed};
use serde_json::Value;
/// photocraft-cli: headless PhotoCraft
@@ -93,6 +93,12 @@ pub(crate) struct ExportArgs {
/// real image
#[arg(long)]
pub(crate) no_maximize_compatibility: bool,
/// The document metadata a flat export embeds: all (the default) keeps the document's XMP
/// packet and free-form text where the format holds them, as Save As does; none leaves them
/// out, as Export As's Metadata: None. Nothing else changes: the ICC profile, EXIF and
/// resolution are written as they are without it. PSD, PSB and .pcraft always keep the XMP
#[arg(long, value_name = "none|all", value_parser = metadata)]
pub(crate) metadata: Option<XmpEmbed>,
}
/// Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …)
@@ -312,6 +318,8 @@ enum ValueError {
TiffCompression,
#[error("expected baseline, optimized or progressive")]
JpegFormat,
#[error("expected none or all")]
Metadata,
#[error("expected on or off")]
OnOff,
/// Why a `--bridge` value, or a PHOTOCRAFT_CONTROL_PORT value for `mcp`, is refused.
@@ -386,6 +394,14 @@ fn jpeg_format(s: &str) -> Result<JpegFormat, ValueError> {
JpegFormat::parse(s).ok_or(ValueError::JpegFormat)
}
fn metadata(s: &str) -> Result<XmpEmbed, ValueError> {
match s {
"none" => Ok(XmpEmbed::None),
"all" => Ok(XmpEmbed::All),
_ => Err(ValueError::Metadata),
}
}
/// Where `mcp --bridge` connects. Shown as the address: `127.0.0.1:<PORT>` for a port.
#[derive(Clone)]
pub(crate) enum Bridge {
+4
View File
@@ -34,6 +34,10 @@ impl From<&ExportArgs> for ExportOptions {
}
o.encode.dds.mipmaps = a.dds_mipmaps;
o.psd_maximize_compatibility = !a.no_maximize_compatibility;
// Conversions keep the whole packet unless asked, as Save As does (#733, #9466).
if let Some(m) = a.metadata {
o.xmp = m;
}
o
}
}
+82
View File
@@ -980,6 +980,88 @@ fn jpeg_format_chooses_the_scans() {
}
}
/// #9466: `--metadata none` leaves the document's XMP packet and free-form text out of a flat
/// export from `convert`, `run --out` and `batch`, as Export As's Metadata: None does, and changes
/// nothing else (pixels, ICC profile, resolution). The default, and `all`, keep them as before (the
/// text where the format holds it, as PNG does), and a PSD or `.pcraft` keeps the XMP either way.
/// A value it doesn't name is a usage error and nothing is written.
#[test]
fn metadata_none_leaves_out_the_document_xmp() {
const XMP: &str = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
<rdf:Description rdf:about=\"\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\">\
<photoshop:DocumentAncestors><rdf:Bag><rdf:li>xmp.did:11111111-2222-3333-4444-555555555555</rdf:li></rdf:Bag></photoshop:DocumentAncestors>\
</rdf:Description></rdf:RDF></x:xmpmeta>";
let d = tmp("metadata");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let a = input.join("a.png");
let data: Vec<u8> = (0..8 * 6 * 4).map(|i| (i as u8).wrapping_mul(7).wrapping_add(3)).collect();
let mut img = photocraft_codecs::Image::from_u8(8, 6, photocraft_codecs::ChannelLayout::Rgba, data).unwrap();
img.meta.xmp = Some(XMP.into());
img.meta.dpi = Some((300.0, 300.0));
img.meta.text = vec![("Comment".into(), "free-form-note".into())];
// Display P3 rather than sRGB, so no export can pass for keeping it by writing its default.
let p3 = photocraft_cms::Builtin::DisplayP3.profile().to_bytes().to_vec();
img.icc = Some(p3.clone());
std::fs::write(&a, photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()).unwrap();
let ancestors = |p: &Path| String::from_utf8_lossy(&std::fs::read(p).unwrap()).contains("DocumentAncestors");
let decoded = |p: &Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap();
let note = |p: &Path| decoded(p).meta.text.iter().any(|(_, v)| v == "free-form-note");
assert!(ancestors(&a) && note(&a), "the input carries the packet and the text");
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for ext in ["png", "webp", "jpg", "tif"] {
let mut converted_files = Vec::new();
for flag in [None, Some("all"), Some("none")] {
let extra: Vec<&str> = flag.map(|f| vec!["--metadata", f]).unwrap_or_default();
let name = format!("{ext}-{}", flag.unwrap_or("default"));
let converted = d.join(format!("convert-{name}.{ext}"));
ok(bin().arg("convert").arg(&a).arg(&converted).args(&extra));
let ran = d.join(format!("run-{name}.{ext}"));
ok(bin().arg("run").arg(&a).arg("--out").arg(&ran).args(&extra));
let o = batch_with(&d, &input, &format!("batch-{name}"), invert, &[&["--format", ext][..], &extra[..]].concat());
assert!(o.status.success(), "batch {name}: {}", String::from_utf8_lossy(&o.stderr));
let batched = d.join(format!("batch-{name}")).join(format!("a.{ext}"));
for out in [&converted, &ran, &batched] {
assert_eq!(ancestors(out), flag != Some("none"), "{name}: {}", out.display());
// PNG holds free-form text; JPEG, WebP and TIFF (but for its named tags) don't.
assert_eq!(note(out), ext == "png" && flag != Some("none"), "{name} text: {}", out.display());
}
converted_files.push(converted);
}
// Only the XMP and text go: the pixels, profile and resolution are the default export's.
let (default, none) = (decoded(&converted_files[0]), decoded(&converted_files[2]));
assert_eq!(none.data(), default.data(), "{ext}: pixels");
assert_eq!(default.icc.as_deref(), Some(&p3[..]), "{ext}: the default export keeps the input's Display P3 profile");
assert_eq!(none.icc, default.icc, "{ext}: ICC profile");
assert_eq!(none.meta.dpi, default.meta.dpi, "{ext}: resolution");
assert!(none.meta.dpi.is_some_and(|(x, y)| (x - 300.0).abs() < 0.5 && (y - 300.0).abs() < 0.5), "{ext}: {:?}", none.meta.dpi);
}
// A layered save keeps the document's XMP whatever the option.
for ext in ["psd", "pcraft"] {
let out = d.join(format!("layered.{ext}"));
ok(bin().arg("convert").arg(&a).arg(&out).args(["--metadata", "none"]));
let back = d.join(format!("layered-{ext}.png"));
ok(bin().arg("convert").arg(&out).arg(&back));
assert!(ancestors(&back), "{ext} kept the packet");
}
for bad in ["None", "exif", "0", ""] {
let out = d.join("bad.png");
let (mut convert, mut run) = (bin(), bin());
convert.arg("convert").arg(&a).arg(&out).args(["--metadata", bad]);
run.arg("run").arg(&a).arg("--out").arg(&out).args(["--metadata", bad]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "--metadata {bad}: {err}");
assert!(err.contains("--metadata") && err.contains("expected none or all"), "--metadata {bad}: {err}");
assert!(!out.exists(), "--metadata {bad} wrote a file");
}
let o = batch_with(&d, &input, "bad-batch", invert, &["--metadata", bad]);
assert_eq!(o.status.code(), Some(2), "batch --metadata {bad}");
assert!(!d.join("bad-batch").exists(), "batch --metadata {bad} wrote a folder");
}
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
+21
View File
@@ -35,6 +35,27 @@ Warnings do not change the saved document's font declarations and do not stop
the operation. To list or replace the missing families intentionally, use
`type.resolveMissingFonts` on the document.
### Document metadata
`convert`, `run --out` and `batch` keep the document's XMP packet in a flat
export whose format holds one (PNG, JPEG, WebP, TIFF, …), and its free-form
text where the format holds that too (PNG text chunks, TIFF's named text tags
such as ImageDescription; JPEG and WebP have no place for it), as Save As
does. `--metadata none` leaves them out, as Export As's Metadata: None does:
a Photoshop file's XMP can list every placed document's id
(`DocumentAncestors`) and the text of every type layer, and can be far larger
than the image. `--metadata all` is the default. The option changes nothing
else: the ICC profile, EXIF and resolution are written as they are without
it, where the format holds them. An Indexed Color document saved as a palette
PNG carries no XMP, text, ICC profile or EXIF with either value, only its
resolution. PSD, PSB and `.pcraft` saves always keep the XMP. It is not a full
metadata scrub.
```sh
photocraft-cli batch --actions resize.json --in masters/ --out web/ \
--format webp --metadata none
```
### Export selected adjustment layers as a .cube LUT
The engine command `file.export.colorLookupTables` creates a 3D LUT from an
+88
View File
@@ -1117,6 +1117,50 @@ pub fn puppet_warp(src: &Surface, bounds: Rect, w: &PuppetWarp, interp: Interp)
puppet_warp_with_mask(src, bounds, w, interp, None).map(|(out, _)| out)
}
/// How many times over a stored mesh's triangles may cover its deformed box, counting each
/// triangle's bounding box: a real mesh tiles the box about twice (a fold adds a layer), a
/// crafted one stacks large triangles to multiply the drawing work.
const MAX_MESH_COVERAGE: f64 = 16.0;
/// Draws `src` (content inside `bounds`) through a mesh solved elsewhere, as a Photoshop file
/// stores its Puppet Warp: `mesh.verts` move to `deformed`, the triangles drawn in their stored
/// order. `None` for a malformed mesh (sizes that don't match, an index out of range, a vertex
/// that isn't finite), one whose deformed box spans more than [`MAX_WARP_PIXELS`] or whose
/// triangles cover that box more than [`MAX_MESH_COVERAGE`] times.
pub fn warp_with_mesh(src: &Surface, bounds: Rect, mesh: &PuppetMesh, deformed: &[[f64; 2]], interp: Interp) -> Option<Surface> {
let n = mesh.verts.len();
if mesh.tris.is_empty() || deformed.len() != n || mesh.tris.iter().flatten().any(|&i| i >= n) {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in mesh.verts.iter().chain(deformed) {
if !(p[0].is_finite() && p[1].is_finite()) {
return None;
}
}
for p in deformed {
(x0, y0, x1, y1) = (x0.min(p[0]), y0.min(p[1]), x1.max(p[0]), y1.max(p[1]));
}
let area = (x1 - x0 + 1.0) * (y1 - y0 + 1.0);
if area > MAX_WARP_PIXELS {
return None;
}
let covered: f64 = mesh
.tris
.iter()
.map(|t| {
let (xs, ys) = (t.map(|i| deformed[i][0]), t.map(|i| deformed[i][1]));
let span = |v: [f64; 3]| v.iter().fold(f64::MIN, |m, &a| m.max(a)) - v.iter().fold(f64::MAX, |m, &a| m.min(a)) + 1.0;
span(xs) * span(ys)
})
.sum();
if covered > MAX_MESH_COVERAGE * area {
return None;
}
let order: Vec<usize> = (0..mesh.tris.len()).collect();
Some(render(src, bounds, mesh, deformed, &order, interp))
}
/// [`puppet_warp`], also moving `mask` (a one-channel layer mask linked to `src`) with the same
/// mesh, as Photoshop moves a linked mask with its layer (#4910). The mask is `None` in the
/// result when none was given.
@@ -1267,6 +1311,50 @@ mod tests {
assert!(!in_mesh(&m, [40.0, 50.0]) && !in_mesh(&m, [60.0, 20.0]));
}
#[test]
fn a_stored_mesh_is_drawn_as_it_is() {
// A Photoshop file's Puppet Warp keeps its solved mesh: two triangles over a 40 × 40 box,
// sheared and moved. A dot lands where the mesh's affine map puts it; pixels outside the
// mesh are dropped.
let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::F32, true));
for y in 0..48 {
for x in 0..48 {
let dot = (f64::from(x) + 0.5 - 12.0).hypot(f64::from(y) + 0.5 - 20.0) <= 2.0;
s.write_pixel(x, y, &if dot { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] });
}
}
let mesh = PuppetMesh { verts: vec![[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]], tris: vec![[0, 1, 2], [0, 2, 3]], spacing: 20.0 };
let map = |p: [f64; 2]| [p[0] + 0.25 * p[1] + 3.0, p[1] + 5.0];
let deformed: Vec<[f64; 2]> = mesh.verts.iter().map(|v| map(*v)).collect();
let b = s.content_bounds();
let out = warp_with_mesh(&s, b, &mesh, &deformed, Interp::Bilinear).expect("a valid mesh draws");
let ob = out.content_bounds();
let (mut sw, mut sx, mut sy) = (0.0, 0.0, 0.0);
for (i, px) in out.read_region(ob).as_chunks::<4>().0.iter().enumerate() {
let w = f64::from(px[0] * px[3]);
let (x, y) = (f64::from(ob.x0) + (i % ob.width() as usize) as f64 + 0.5, f64::from(ob.y0) + (i / ob.width() as usize) as f64 + 0.5);
(sw, sx, sy) = (sw + w, sx + w * x, sy + w * y);
}
let want = map([12.0, 20.0]);
assert!((sx / sw - want[0]).abs() < 0.05 && (sy / sw - want[1]).abs() < 0.05, "dot at {:?}, want {want:?}", [sx / sw, sy / sw]);
// The source column x = 44 is outside the mesh: nothing lands right of the sheared box.
assert!(ob.x1 <= 54 && ob.y1 <= 45, "{ob:?}");
// Malformed meshes are refused rather than drawn.
assert!(warp_with_mesh(&s, b, &mesh, &deformed[..3], Interp::Bilinear).is_none());
let bad = PuppetMesh { tris: vec![[0, 1, 4]], ..mesh.clone() };
assert!(warp_with_mesh(&s, b, &bad, &deformed, Interp::Bilinear).is_none());
let mut far = deformed.clone();
far[2] = [f64::NAN, 0.0];
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
far[2] = [1.0e6, 1.0e6];
assert!(warp_with_mesh(&s, b, &mesh, &far, Interp::Bilinear).is_none());
// The same two triangles stacked 40 times: far more drawing than the box needs.
let stacked = PuppetMesh { tris: mesh.tris.repeat(40), ..mesh.clone() };
assert!(warp_with_mesh(&s, b, &stacked, &deformed, Interp::Bilinear).is_none());
let none = PuppetMesh { tris: Vec::new(), ..mesh };
assert!(warp_with_mesh(&s, b, &none, &deformed, Interp::Bilinear).is_none());
}
#[test]
fn no_pins_or_unmoved_pins_are_identity() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+2
View File
@@ -732,7 +732,9 @@ fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
| "path.info"
| "path.addToSelection"
| "path.subtractFromSelection"
| "path.intersectWithSelection"
| "path.set"
| "actions.insertPath"
| "path.transform"
| "path.moveAnchors"
| "path.moveHandle"
+36 -1
View File
@@ -60,7 +60,11 @@ fn over(b: Cmyka, s: Cmyka, opacity: f32) -> Cmyka {
}
let mut o = [0.0; 5];
for i in 0..4 {
o[i] = (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a;
// Over nothing, under a covering source, or where both inks agree, the result is the
// source's ink exactly: `s * sa / sa` can be one ulp off, which a later mode or
// rounding step can turn into a whole level.
let shows_through = b[4] > 0.0 && sa < 1.0;
o[i] = if !shows_through || b[i] == s[i] { s[i] } else { (s[i] * sa + b[i] * b[4] * (1.0 - sa)) / a };
}
o[4] = a;
o
@@ -762,6 +766,37 @@ fn clips_onto_pass_through(layers: &[LayerSpec]) -> bool {
}) || layers.iter().any(|l| matches!(l, LayerSpec::Group { children, .. } if clips_onto_pass_through(children)))
}
/// A proptest case from main's CI: a masked shape below 100% Fill, then Posterize in Color
/// Burn (Paint.NET) inside an isolated group. The burned brightness is exactly 73.5/255.
#[test]
fn posterize_in_paint_net_color_burn_on_a_rounding_tie_matches_the_reference() {
let shape = LayerSpec::Pixels {
kind: Kind::Shape,
blend: BlendMode::Normal,
fills: vec![(Rect::new(0, 0, 1, 1), [0.47422728, 0.0, 0.0, 0.0, 1.0])],
opacity: 1.0,
fill: 0.876696,
clipped: false,
visible: true,
mask: Some(MaskSpec { rects: vec![], default: 0.06425183, density: 1.0, enabled: true }),
};
let posterize = LayerSpec::Pixels {
kind: Kind::Adjust(AdjSpec::Posterize(4)),
blend: BlendMode::PaintNetColorBurn,
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
opacity: 1.0,
fill: 1.0,
clipped: false,
visible: true,
mask: None,
};
let group = LayerSpec::Group { children: vec![shape, posterize], pass_through: false, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None };
let s = Spec { w: 8, h: 6, layers: vec![group] };
let mut d = doc(s.w as u32, s.h as u32);
d.layers = s.layers.iter().map(build).collect();
assert_matches_reference(&d);
}
proptest! {
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
+31 -1
View File
@@ -344,6 +344,9 @@ impl Tile {
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
const MAX_TAPS: usize = 8;
/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite.
pub const MIN_PLACEMENT_SCALE: f64 = 1e-6;
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
fn pixel_taps(inv_scale: f64) -> usize {
@@ -387,7 +390,9 @@ impl Placement {
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
// Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern
// well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %.
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
@@ -608,6 +613,31 @@ mod tests {
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
}
/// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which
/// used to fall back to 100 % and show the stripes full size.
#[test]
fn placement_keeps_scales_below_a_tenth_of_a_percent() {
let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0);
let (u, v) = place.map(1.0, 2.0);
assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}");
// 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel
// covers 1250 of them, so every pixel is their mean.
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
for x in (0..8).step_by(2) {
s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]);
}
let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap();
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
assert!((p[0] - 0.5).abs() < 0.1, "{p:?}");
}
// 0, negative and non-finite scales still draw at 100 %.
for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] {
let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0);
assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}");
}
}
#[test]
fn sampling_at_hostile_phases_does_not_overflow() {
let patterns = [checker()];
+4
View File
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
use crate::adjust::CurvePoint;
/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern
/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog.
pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0);
/// Gradient geometry (fill layers, gradient overlays, gradient strokes).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientStyle {
+6
View File
@@ -564,6 +564,12 @@ impl SmartObject {
perspective: None,
}
}
/// Where the placed source lands (transform, perspective and warp), as JSON: the placement a
/// smart filter stored in document pixels (an imported Puppet Warp mesh) was made for.
pub fn placement_json(&self) -> serde_json::Value {
serde_json::json!({"transform": self.transform.m, "perspective": self.perspective, "warp": self.warp})
}
}
#[derive(Clone, Debug, PartialEq)]
+89 -40
View File
@@ -94,6 +94,9 @@ pub const MAX_PLAYBACK_STEPS: u64 = 100_000;
#[serde(default)]
pub struct ActionState {
pub list: Vec<Action>,
/// Painting-tool strokes are recorded only when the user enables them for this session.
#[serde(skip)]
pub record_tools: bool,
/// Journal length when recording started, and the action index being recorded into.
#[serde(skip)]
pub recording: Option<(usize, usize)>,
@@ -165,7 +168,7 @@ pub fn playback_params(s: &Session, id: &str, p: &Value) -> Result<Value> {
}
}
}
if id.starts_with("filter.gallery.")
if (id.starts_with("filter.gallery.") || id == crate::atn_cmds::MENU_ITEM)
&& let Some(object) = p.as_object_mut()
{
object.remove(crate::atn_cmds::RECORDED_EVENT);
@@ -276,6 +279,63 @@ pub fn playback_plan(s: &Session, p: &Value) -> Result<(Action, usize)> {
Ok((action, from))
}
/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched.
fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) {
let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect();
while let Some((id, params)) = pending.pop() {
let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null());
if id == "file.automate.batch" {
let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") };
if let Some(key) = inline {
if scoped {
continue;
}
// A present invalid `steps` still wins over `action`, just as at runtime.
let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue };
for step in steps.iter_mut().rev() {
let call = match step {
Value::Array(a) => {
let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue };
a.get_mut(1).map(|params| (id, params))
}
Value::Object(o) => {
let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue };
o.get_mut("params").map(|params| (id, params))
}
// String steps and omitted params use `{}`, with no action target.
_ => None,
};
if let Some(call) = call {
pending.push(call);
}
}
continue;
}
}
f(&id, params, scoped);
}
}
/// Shift numeric selectors in the session list, including calls in unscoped inline Batch steps.
pub(crate) fn shift_action_calls(list: &mut [Action], at: usize, count: usize) {
for action in list {
visit_calls(&mut action.steps, &mut |id, params, _| {
if !matches!(id, "actions.play" | "file.automate.batch") {
return;
}
let Some(value) = params.get("action") else { return };
let Some(index) = num(value).and_then(|i| usize::try_from(i).ok()) else { return };
if index >= at {
params["action"] = if let Some(fractional) = value.as_f64().filter(|_| value.is_f64()) {
json!(fractional + count as f64)
} else {
json!(index.saturating_add(count))
};
}
});
}
}
/// Capture transitive Play Action, Conditional and Batch dependencies for a standalone droplet
/// (#2794, #6804). Ordinary references become names; normalized copies keep source-list order.
/// A named Batch with its own action list selects an untouched source copy at an exported index,
@@ -286,42 +346,6 @@ pub fn called_actions(list: &[Action], steps: &mut [(String, Value)]) -> Vec<Act
fn target(list: &[Action], params: &Value, id: &str) -> Result<usize> {
if id == "file.automate.batch" { crate::file_cmds::batch_action(list, params) } else { resolve(list, params, id) }
}
/// Visit calls in the current namespace, leaving embedded inline Batch scopes untouched.
fn visit_calls(steps: &mut [(String, Value)], f: &mut impl FnMut(&str, &mut Value, bool)) {
let mut pending: Vec<_> = steps.iter_mut().rev().map(|(id, params)| (id.clone(), params)).collect();
while let Some((id, params)) = pending.pop() {
let scoped = id == "file.automate.batch" && params.get("calledActions").is_some_and(|v| !v.is_null());
if id == "file.automate.batch" {
let inline = if params.get("steps").is_some() { Some("steps") } else { params.get("action").is_some_and(Value::is_array).then_some("action") };
if let Some(key) = inline {
if scoped {
continue;
}
// A present invalid `steps` still wins over `action`, just as at runtime.
let Some(steps) = params.get_mut(key).and_then(Value::as_array_mut) else { continue };
for step in steps.iter_mut().rev() {
let call = match step {
Value::Array(a) => {
let Some(id) = a.first().and_then(Value::as_str).map(str::to_owned) else { continue };
a.get_mut(1).map(|params| (id, params))
}
Value::Object(o) => {
let Some(id) = o.get("command").or_else(|| o.get("id")).and_then(Value::as_str).map(str::to_owned) else { continue };
o.get_mut("params").map(|params| (id, params))
}
// String steps and omitted params use `{}`, with no action target.
_ => None,
};
if let Some(call) = call {
pending.push(call);
}
}
continue;
}
}
f(&id, params, scoped);
}
}
/// Capture ordinary calls transitively, but retain an untouched source for scoped named Batch.
fn targets(list: &[Action], steps: &mut [(String, Value)], depth: usize, queue: &mut VecDeque<(usize, usize)>, scoped: &mut BTreeSet<usize>) {
if depth > 16 {
@@ -482,7 +506,7 @@ fn from_step(p: &Value, len: usize) -> Result<usize> {
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let actions: Vec<Value> = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect();
let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone()));
Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording}))
Ok(json!({"actions": actions, "sets": crate::actions_edit_cmds::set_names(s), "recording": recording, "toolRecording": s.actions.record_tools}))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
@@ -545,7 +569,32 @@ const RECORDED_WITH_DIALOG: &[&str] = &["edit.checkSpelling"];
/// Replayable journal entries not yet committed to this action while recording.
pub fn pending_steps(s: &Session, action: usize) -> impl Iterator<Item = &(String, Value)> {
let from = s.actions.recording.filter(|(_, i)| *i == action).map_or(s.journal.len(), |(from, _)| from);
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p))
s.journal.iter().skip(from).filter(|(id, p)| recorded(id, p) && (s.actions.record_tools || !tool_stroke(id)))
}
/// The point-based painting tools committed by the canvas; fills and gradients remain commands.
pub(crate) fn tool_stroke(id: &str) -> bool {
matches!(
id,
"paint.stroke"
| "paint.pencil"
| "paint.mixerBrush"
| "paint.colorReplacement"
| "paint.blur"
| "paint.sharpen"
| "paint.smudge"
| "paint.dodge"
| "paint.burn"
| "paint.sponge"
| "paint.cloneStamp"
| "paint.healingBrush"
| "paint.historyBrush"
| "paint.artHistoryBrush"
| "paint.patternStamp"
| "paint.backgroundEraser"
| "paint.spotHealing"
| "paint.remove"
)
}
pub(crate) fn flush_recording(s: &mut Session) -> Result<()> {
@@ -1094,7 +1143,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "List Actions",
menu: &[],
shortcut: None,
params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null}",
params: "{} → {actions:[{name, steps:count, set:name|null}], sets:[name…] (every set in panel order, empty ones too), recording:name|null, toolRecording:bool}",
enabled: always,
run: list,
journal: false,
+185 -6
View File
@@ -13,6 +13,11 @@ use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
mod insert_path;
mod record_again;
pub use insert_path::selected_path as selected_recording_path;
pub use record_again::{RecordAgainTarget, target as record_again_target};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
@@ -21,6 +26,35 @@ fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
/// Flush under the old mode so changing the toggle never retroactively records or loses a stroke.
fn allow_tool_recording(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "actions.allowToolRecording";
not_playing(s, cmd)?;
let enabled = match p.get("enabled") {
None | Some(Value::Null) => !s.actions.record_tools,
Some(Value::Bool(enabled)) => *enabled,
Some(_) => return Err(bad(cmd, "`enabled` must be true or false")),
};
if enabled != s.actions.record_tools {
if s.actions.recording.is_some() && s.tool_stroke_job_running() {
return Err(bad(cmd, "wait for the current tool stroke before changing recording"));
}
flush_recording(s)?;
s.actions.record_tools = enabled;
}
Ok(json!({"enabled": enabled}))
}
/// Insert a menu event with absent settings, reusing the ordinary validated step insertion.
fn insert_menu_item(s: &mut Session, p: &Value) -> Result<Value> {
if p.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "action" | "at" | "id" | "eventKind"))) {
return Err(bad("actions.insertMenuItem", "menu items accept no recorded settings"));
}
let params = json!({"id":p.get("id"),"eventKind":p.get("eventKind").cloned().unwrap_or(json!("name"))});
crate::atn_cmds::menu_item_command(&params)?;
s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":crate::atn_cmds::MENU_ITEM,"params":params}))
}
fn index(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
@@ -66,18 +100,113 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
if s.actions.recording.is_some_and(|(_, i)| i == idx) {
flush_recording(s)?;
}
let action = &mut s.actions.list[idx];
action.steps.insert(at, (id.clone(), params));
action.remap_steps(|i| Some(if i >= at { i + 1 } else { i }));
// An inserted Stop has its dialog toggle on, so it shows its message in the app.
if id == crate::atn_cmds::STOP_MESSAGE {
action.dialogs.insert(at);
let is_stop = id == crate::atn_cmds::STOP_MESSAGE;
let result = insert_step(s, idx, at, (id, params));
// A newly inserted Stop shows its message in the app. Duplicates keep their own flags.
if is_stop {
s.actions.list[idx].dialogs.insert(at);
}
Ok(result)
}
/// Insert an already validated step without normalizing a copied step's payload.
fn insert_step(s: &mut Session, idx: usize, at: usize, step: (String, Value)) -> Value {
let action = &mut s.actions.list[idx];
let id = step.0.clone();
action.steps.insert(at, step);
action.remap_steps(|i| Some(if i >= at { i + 1 } else { i }));
let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()});
s.actions.touch();
result
}
/// Duplicate a step or action below its source, preserving settings and per-step flags.
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "actions.duplicate";
not_playing(s, cmd)?;
if let Some(set) = p.get("set") {
if p.get("action").is_some() || p.get("step").is_some() {
return Err(bad(cmd, "select a set or an action step, not both"));
}
return duplicate_set(s, set.as_str().ok_or_else(|| bad(cmd, "set must be a name"))?);
}
let idx = resolve(&s.actions.list, p, cmd)?;
if let Some(step) = index(p, "step", cmd)? {
let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count());
if step >= len {
return Err(bad(cmd, "step is out of range"));
}
if s.actions.recording.is_some_and(|(_, i)| i == idx) {
flush_recording(s)?;
}
let action = &s.actions.list[idx];
let copied = action.steps.get(step).cloned().ok_or_else(|| bad(cmd, "step is out of range"))?;
let (disabled, dialog) = (action.disabled.contains(&step), action.dialogs.contains(&step));
let at = step + 1;
let mut result = insert_step(s, idx, at, copied);
let action = &mut s.actions.list[idx];
if disabled {
action.disabled.insert(at);
}
if dialog {
action.dialogs.insert(at);
}
result["index"] = json!(idx);
return Ok(result);
}
if s.actions.recording.is_some() {
return Err(bad(cmd, "stop recording before duplicating an action"));
}
let mut copied = s.actions.list[idx].clone();
let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|a| a.name.clone()));
copied.name = names.claim(&format!("{} copy", copied.name));
let at = idx + 1;
let result = json!({"action": copied.name, "index": at, "steps": copied.steps.len()});
s.actions.list.insert(at, copied);
crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, 1);
s.actions.touch();
Ok(result)
}
/// Copy the named group represented by main, including a session-only empty New Set.
fn duplicate_set(s: &mut Session, source: &str) -> Result<Value> {
let cmd = "actions.duplicate";
if s.actions.recording.is_some() {
return Err(bad(cmd, "stop recording before duplicating a set"));
}
let sets = set_names(s);
if !sets.iter().any(|name| name == source) {
return Err(bad(cmd, "the selected set no longer exists"));
}
let name = crate::atn_cmds::UniqueNames::new(sets).claim(&format!("{source} copy"));
if let Some(first) = s.actions.list.iter().position(|action| action.set.as_deref() == Some(source)) {
let mut names = crate::atn_cmds::UniqueNames::new(s.actions.list.iter().map(|action| action.name.clone()));
let copies: Vec<_> = s
.actions
.list
.iter()
.filter(|action| action.set.as_deref() == Some(source))
.cloned()
.map(|mut action| {
action.name = names.claim(&format!("{} copy", action.name));
action.set = Some(name.clone());
action
})
.collect();
// Classic groups rows by name even when a set's actions are interleaved.
let at = first + s.actions.list[first..].iter().take_while(|action| action.set.as_deref() == Some(source)).count();
let count = copies.len();
s.actions.list.splice(at..at, copies);
crate::actions_cmds::shift_action_calls(&mut s.actions.list, at, count);
s.actions.touch();
return Ok(json!({"set": name, "index": at, "actions": count}));
}
let at = s.actions.sets.iter().position(|set| set == source).ok_or_else(|| bad(cmd, "the selected set no longer exists"))? + 1;
s.actions.sets.insert(at, name.clone());
s.actions.touch();
Ok(json!({"set": name, "actions": 0}))
}
/// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without
/// it, every step (all on unless all already are, like clicking the action's own toggle).
fn dialog(s: &mut Session, p: &Value) -> Result<Value> {
@@ -277,6 +406,56 @@ fn conditional(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec {
id: "actions.insertMenuItem",
label: "Insert Menu Item…",
menu: &[],
shortcut: None,
params: r##"{"action":name|index,"id":menu command,"at":index?,"eventKind":"name"|"code"="name"} → {action,step,id,steps}. Records an unambiguous supported menu event without settings. Image Size and mapped filter menu events are supported. Desktop playback opens the existing editor when settings are needed; no-settings items run immediately."##,
enabled: always,
run: insert_menu_item,
journal: false,
},
CommandSpec {
id: "actions.insertPath",
label: "Insert Path",
menu: &[],
shortcut: None,
params: r##"{"action":name|index,"name":"work"|"layer"|saved path name,"at":index?} → {action,step,id,steps}. Snapshot exactly the selected path as a path.set work-path step, preserving cubic handles and path options. No fallback from a missing Work Path to a layer path. The measured Work Path ATN descriptor supports absolute coordinates, cubic handles, even-odd subpaths and Add/Subtract/XOR. Paths outside that measured form still play natively and are reported as unsupported by ATN export."##,
enabled: always,
run: insert_path::run,
journal: false,
},
CommandSpec {
id: "actions.allowToolRecording",
label: "Allow Tool Recording",
menu: &[],
shortcut: None,
params: r##"{"enabled":bool?=toggle} → {enabled:bool}. Opt in to recording painting-tool strokes (off initially). Existing pending steps are committed under the previous mode. Painting, history and the journal are unchanged. actions.list reports toolRecording."##,
enabled: always,
run: allow_tool_recording,
journal: false,
},
CommandSpec {
id: "actions.recordAgain",
label: "Record Again",
menu: &[],
shortcut: None,
params: r##"{"action":name|index,"step":index,"params":{new settings},"revision":u64?} → {action,step,id,result}. Replays the selected command and stores its new settings only after success, preserving its enabled/dialog flags. The panel opens the command's editor at the recorded settings. Stop recording or playback first; revision optionally rejects stale library state."##,
enabled: always,
run: record_again::run,
journal: false,
},
CommandSpec {
id: "actions.duplicate",
label: "Duplicate Action",
menu: &[],
shortcut: None,
params: r##"{"action":name|index,"step":index?} or {"set":name} → {action?,index?,step?,steps?,set?,actions?}. Copy an action, step or named set below the source. Copies retain settings and flags, and leave shortcuts with the originals."##,
enabled: always,
run: duplicate,
journal: false,
},
CommandSpec {
id: "actions.insert",
label: "Insert Step",
@@ -0,0 +1,26 @@
//! Capture full native geometry without substituting another path for a stale selection.
use super::{Result, Session, Value, bad, json};
use photocraft_doc::{LayerContent, Path};
/// Borrow exactly the path the panel selected, without Work Path's ordinary layer fallback.
pub fn selected_path<'a>(s: &'a Session, name: &str) -> Result<&'a Path> {
let st = s.active().ok_or(crate::EngineError::NoDocument)?;
let path = match name {
"work" => st.doc.work_path.as_ref(),
"layer" => st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| match &l.content {
LayerContent::Shape(shape) => Some(&shape.path),
_ => l.vector_mask.as_ref().map(|v| &v.path),
}),
name => st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path),
};
path.ok_or_else(|| bad("actions.insertPath", "the selected path no longer exists"))
}
/// The native path step is also the source for a future independently verified ATN wire mapping.
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("actions.insertPath", "select a path"))?;
let path = selected_path(s, name)?;
photocraft_vector::flatten::validate_path(path).map_err(|e| bad("actions.insertPath", e))?;
let path = crate::vector_cmds::path_json(path);
s.execute("actions.insert", json!({"action":p.get("action"),"at":p.get("at"),"id":"path.set","params":{"name":"work","path":path}}))
}
@@ -0,0 +1,55 @@
//! Record Again executes the selected command before replacing its stored settings.
use super::{Result, Session, Value, bad, index, json, not_playing, resolve};
const COMMAND: &str = "actions.recordAgain";
/// A validated selection shared by the engine and the panel's seeded editor.
pub struct RecordAgainTarget {
pub action: usize,
pub step: usize,
pub command: String,
pub params: Value,
}
/// Resolve only executable steps, without flushing recording or editing the library.
pub fn target(s: &Session, p: &Value) -> Result<RecordAgainTarget> {
not_playing(s, COMMAND)?;
if s.actions.recording.is_some() {
return Err(bad(COMMAND, "stop recording before recording a step again"));
}
if let Some(revision) = p.get("revision") {
let revision = revision.as_u64().ok_or_else(|| bad(COMMAND, "`revision` must be an unsigned integer"))?;
if revision != s.actions.rev {
return Err(bad(COMMAND, "the actions changed; select the step again"));
}
}
let action = resolve(&s.actions.list, p, COMMAND)?;
let step = index(p, "step", COMMAND)?.ok_or_else(|| bad(COMMAND, "select a step"))?;
let (id, params) = s.actions.list.get(action).and_then(|a| a.steps.get(step)).ok_or_else(|| bad(COMMAND, "step is out of range"))?;
let (command, params) =
if id == crate::atn_cmds::MENU_ITEM { (crate::atn_cmds::menu_item_command(params)?.to_string(), json!({})) } else { (id.clone(), params.clone()) };
if command.starts_with("actions.") || !crate::commands::find(&command).is_some_and(|c| c.journal) {
return Err(bad(COMMAND, "the selected step has no editable engine command"));
}
Ok(RecordAgainTarget { action, step, command, params })
}
/// Keep the original definition on invalid settings, denied execution or changed ownership.
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let selected = target(s, p)?;
let params = p.get("params").filter(|p| p.is_object()).cloned().ok_or_else(|| bad(COMMAND, "supply the step's new settings in `params`"))?;
let original = s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)).cloned().ok_or_else(|| bad(COMMAND, "step is out of range"))?;
let revision = s.actions.rev;
// Resolve portable recorded targets for execution; keep their portable form in the step.
let resolved = crate::actions_cmds::playback_params(s, &selected.command, &params)?;
let result = s.execute(&selected.command, resolved)?;
if s.actions.rev != revision || s.actions.list.get(selected.action).and_then(|a| a.steps.get(selected.step)) != Some(&original) {
return Err(bad(COMMAND, "the action changed during execution; its step was not overwritten"));
}
let action = s.actions.list.get_mut(selected.action).ok_or_else(|| bad(COMMAND, "the action no longer exists"))?;
let step = action.steps.get_mut(selected.step).ok_or_else(|| bad(COMMAND, "the step no longer exists"))?;
*step = (selected.command.clone(), params);
let name = action.name.clone();
s.actions.touch();
Ok(json!({"action": name, "step": selected.step, "id": selected.command, "result": result}))
}
+32
View File
@@ -19,6 +19,7 @@ use crate::{EngineError, Result, Session};
/// Artboard size presets (name, width, height) in pixels, Photoshop's common devices and pages.
pub const PRESETS: &[(&str, u32, u32)] = &[
("iPhone 8/7/6", 750, 1334),
("iPhone 14", 390, 844),
("iPhone 14 Pro Max", 430, 932),
("Android 1080p", 360, 640),
@@ -414,6 +415,9 @@ fn artboard_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
/// Changing X/Y moves the board with its contents; changing W/H resizes it from the top left.
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.artboard.set";
if p.get("layers").is_some() {
return set_backgrounds(s, p);
}
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = crate::commands::explicit_layer(s, p)?.or_else(|| active_artboard(s)).ok_or_else(|| bad(cmd, "no artboard selected"))?;
let old = d.doc.layer(id).and_then(Layer::artboard).cloned().ok_or_else(|| bad(cmd, "the layer is not an artboard"))?;
@@ -469,6 +473,34 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0, "rect": [rect.x0, rect.y0, rect.width(), rect.height()]}))
}
/// `layer.artboard.set` with a `layers` array: the background of every listed board in one undo
/// step, "Edit Artboard" for one board and "Edit Artboards" for several (the options bar's
/// background control with boards selected). Only `background`/`color` apply to several boards.
fn set_backgrounds(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "layer.artboard.set";
let d = s.active().ok_or(EngineError::NoDocument)?;
let mut ids: Vec<LayerId> = Vec::new();
for v in p.get("layers").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))? {
let id = LayerId(v.as_u64().ok_or_else(|| bad(cmd, "`layers` must be an array of layer ids"))?);
d.doc.layer(id).and_then(Layer::artboard).ok_or_else(|| bad(cmd, format!("layer {} is not an artboard", id.0)))?;
if !ids.contains(&id) {
ids.push(id);
}
}
if ids.is_empty() {
return Err(bad(cmd, "`layers` is empty"));
}
let background = background_param(p, cmd)?.ok_or_else(|| bad(cmd, "`layers` needs a `background`"))?;
let label = if ids.len() == 1 { "Edit Artboard" } else { "Edit Artboards" };
s.edit(label, |doc, _| {
for id in &ids {
doc.layer_mut(*id).and_then(Layer::artboard_mut).ok_or(EngineError::NoLayer(*id))?.background = background;
}
Ok(())
})?;
Ok(json!({"layers": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
}
/// The Artboard tool's "+" buttons: a board the size of board `layer` on one of its sides, a
/// [`GAP`] away (further out past any board in the way), empty or (`duplicate`, the ⌥-click) a
/// copy with its contents. A board left of or above the canvas origin shifts the whole document
@@ -1080,3 +1080,28 @@ fn a_layer_centred_just_left_of_a_board_at_negative_coordinates_stays_off_it() {
s.execute("layer.translate", json!({"layer": id.0, "dx": 1, "dy": 0, "nest": true})).unwrap();
assert_eq!(doc(&s).artboard_of(id), Some(b));
}
#[test]
fn a_layers_array_sets_every_listed_boards_background_in_one_step() {
let mut s = session(8);
let a = LayerId(s.execute("layer.new.artboard", json!({"rect": [0, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap());
let b = LayerId(s.execute("layer.new.artboard", json!({"rect": [200, 0, 50, 50]})).unwrap()["layer"].as_u64().unwrap());
let steps = s.active().unwrap().history.entries().len();
s.execute("layer.artboard.set", json!({"layers": [a.0, b.0], "background": "black"})).unwrap();
let entries = s.active().unwrap().history.entries();
assert_eq!(entries.len(), steps + 1);
assert_eq!(entries.last().map(String::as_str), Some("Edit Artboards"));
for id in [a, b] {
assert_eq!(doc(&s).layer(id).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black);
}
s.execute("layer.artboard.set", json!({"layers": [a.0], "background": "custom", "color": "#ff0000"})).unwrap();
assert_eq!(s.active().unwrap().history.entries().last().map(String::as_str), Some("Edit Artboard"));
// Undo is one step; errors change nothing.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(doc(&s).layer(a).and_then(Layer::artboard).unwrap().background, ArtboardBackground::Black);
let n = s.active().unwrap().history.entries().len();
assert!(s.execute("layer.artboard.set", json!({"layers": [a.0, 9999], "background": "white"})).is_err());
assert!(s.execute("layer.artboard.set", json!({"layers": [], "background": "white"})).is_err());
assert!(s.execute("layer.artboard.set", json!({"layers": [a.0]})).is_err());
assert_eq!(s.active().unwrap().history.entries().len(), n);
}
+18
View File
@@ -32,6 +32,7 @@ mod descriptors;
mod events;
mod fields;
mod files;
mod menu_item;
mod steps_artboard;
mod steps_batch;
@@ -41,6 +42,7 @@ mod steps_content_aware;
mod steps_direct_adjustments;
mod steps_distort;
mod steps_doc;
mod steps_droplet;
mod steps_gallery;
mod steps_gradient;
mod steps_image;
@@ -48,6 +50,7 @@ mod steps_layer;
mod steps_layer_style;
mod steps_lookup;
mod steps_make;
mod steps_path;
mod steps_perspective_warp;
mod steps_type_path;
@@ -62,6 +65,7 @@ mod tables;
#[cfg(test)]
use adjustments::{levels_from, selective_from};
pub(crate) use convert::UniqueNames;
pub use convert::{Imported, Skipped, Unsupported, export, function_key_name, import, import_with_encoding, parse_function_key};
#[cfg(test)]
use convert::{MAX_NAME_CHARS, export_item, normalise};
@@ -76,6 +80,7 @@ use events::{EVENTS, event_and_descriptor, event_for};
#[cfg(test)]
use fields::Mapped;
use files::{load, save};
pub use menu_item::{MENU_ITEM, menu_item_command, menu_item_commands, menu_item_needs_settings};
#[cfg(test)]
use steps_doc::crop_from;
pub(crate) use steps_gallery::RECORDED_EVENT;
@@ -150,6 +155,16 @@ pub fn specs() -> Vec<CommandSpec> {
run: stop_message,
journal: true,
},
CommandSpec {
id: MENU_ITEM,
label: "Menu Item",
menu: &[],
shortcut: None,
params: r##"{"id":menu command,"eventKind":"name"|"code"="name"} (a descriptor-free Image Size or unambiguous mapped filter menu event; desktop playback asks for settings when needed, headless playback only runs stateless menu items)"##,
enabled: always,
run: menu_item::run,
journal: true,
},
CommandSpec {
id: PHOTOSHOP_STEP,
label: "Photoshop Step",
@@ -177,3 +192,6 @@ mod selection_tests;
#[cfg(test)]
mod render_tests;
#[cfg(test)]
mod recorded_tools_tests;
+13 -8
View File
@@ -69,10 +69,12 @@ fn convert_action(file: &AtnFile, a: &AtnAction, set: &str, unsupported: &mut Ve
let mut action = Action::new(name.clone(), Vec::new());
action.set = Some(clip_name(set));
for (i, item) in a.items.iter().enumerate() {
let mapped = match (event_for(&item.event), &item.descriptor) {
(Some(e), Some(d)) => (e.from)(d),
(Some(e), None) => (e.from)(&Descriptor::default()),
(None, _) => Mapped::Unsupported { harmless: false },
let menu = item.descriptor.is_none().then(|| super::menu_item::from_event(&item.event)).flatten();
let mapped = match (menu, event_for(&item.event), &item.descriptor) {
(Some(params), _, _) => Mapped::Steps(vec![(super::MENU_ITEM.into(), params)]),
(_, Some(e), Some(d)) => (e.from)(d),
(_, Some(e), None) => (e.from)(&Descriptor::default()),
_ => Mapped::Unsupported { harmless: false },
};
let (new_steps, enabled) = match mapped {
Mapped::Steps(mut s) => {
@@ -136,6 +138,9 @@ pub struct Skipped {
}
pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem> {
if id == super::MENU_ITEM {
return super::menu_item::export(params);
}
if id == PHOTOSHOP_STEP {
let event = jstr(params, "event")?;
let event = match params.get("eventKind") {
@@ -166,7 +171,7 @@ pub(super) fn export_item(id: &str, params: &Value, set: &str) -> Option<AtnItem
{
return Some(AtnItem { event, dictionary: name, descriptor: Some(descriptor), ..Default::default() });
}
let event = if e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
let event = if id == "path.set" || e.code.is_empty() { AtnEvent::name(e.name) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) };
let omit_descriptor = super::steps_stack::without_descriptor(id, &params) || super::steps_video::omitted(id, &params);
let descriptor = (!omit_descriptor).then_some(descriptor);
Some(AtnItem { event, dictionary: e.label.as_bytes().to_vec(), descriptor, ..Default::default() })
@@ -268,18 +273,18 @@ pub fn export(
/// Assigns names not already used, as `name`, `name 2`, `name 3`… A plain linear scan of the
/// taken list per candidate is cubic when an action file repeats one base name many times, so
/// membership uses a set and the next suffix to try is cached per base name.
pub(super) struct UniqueNames {
pub(crate) struct UniqueNames {
taken: std::collections::HashSet<String>,
next_suffix: std::collections::HashMap<String, u32>,
}
impl UniqueNames {
pub(super) fn new<I: IntoIterator<Item = String>>(existing: I) -> Self {
pub(crate) fn new<I: IntoIterator<Item = String>>(existing: I) -> Self {
UniqueNames { taken: existing.into_iter().collect(), next_suffix: std::collections::HashMap::new() }
}
/// Reserves and returns a unique name derived from `name`, recording it as taken.
pub(super) fn claim(&mut self, name: &str) -> String {
pub(crate) fn claim(&mut self, name: &str) -> String {
if self.taken.insert(name.to_string()) {
return name.to_string();
}
+28 -6
View File
@@ -88,18 +88,21 @@ fn applies_at_canvas_bounds(p: &Value) -> bool {
}
/// Settings a mapped event has no field for, by command: `(param, the value the event plays as)`.
/// Photoshop's event has nowhere to put these, so a step that sets one cannot be written: it
/// The exported event has nowhere to put these, so a step that sets one cannot be written: it
/// would import as a different edit, silently. Such a step is refused, the way [`pixel_target`]
/// refuses a filter aimed at a mask -- and no key Photoshop does not define is invented for it,
/// which the clean-room rule forbids and which would make the file wrong in another reader.
///
/// A step that leaves the setting at the value the event plays as exports as before.
/// How a setting the event cannot carry behaves once the step is replayed without it. Which of
/// the two a setting takes follows the command's own reading of it, not the parameter spec.
/// these a setting takes follows the command's own reading of it, not the parameter spec.
enum Lost {
/// Replay uses this value, so only a step already at it survives. Numbers and strings are
/// both compared as written, since the commands below read these settings as written.
/// Replay uses this value, so only a step already at it survives. Numbers, strings and
/// booleans are compared as written, since the commands below read these settings as written.
ReplaysAs(&'static str),
/// Replay uses this boolean, and the command refuses any other type, so only a step at this
/// boolean survives: a string such as `"true"` is refused.
ReplaysAsBool(bool),
/// The command reads only this one spelling as anything other than what replay does, so every
/// other value survives -- a differently cased one included, because it too plays as replay's.
DiffersAt(&'static str),
@@ -114,6 +117,8 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[
// the exact string "roundness" -- see `preserve` in filters.rs.
("filter.other.minimum", &[("preserve", Lost::DiffersAt("roundness"))]),
("filter.other.maximum", &[("preserve", Lost::DiffersAt("roundness"))]),
// Eqlz is exported with no fields; replay equalizes the selected area only.
("image.adjustments.equalize", &[("selectedAreaOnly", Lost::ReplaysAsBool(true))]),
];
/// Whether every setting of `p` that its event cannot hold would play the same without it
@@ -121,8 +126,15 @@ const UNHOLDABLE: &[(&str, &[(&str, Lost)])] = &[
fn holds_its_settings(id: &str, p: &Value) -> bool {
let Some((_, settings)) = UNHOLDABLE.iter().find(|(cmd, _)| *cmd == id) else { return true };
settings.iter().all(|(param, lost)| match (p.get(param), lost) {
(None | Some(Value::Null), _) => true,
(Some(v), Lost::ReplaysAs(played)) => v.as_str() == Some(played) || v.as_f64().is_some_and(|n| played.parse::<f64>().is_ok_and(|d| n == d)),
(None, _) => true,
// The command refuses null here too, so a null step is refused like any other non-boolean.
(Some(v), Lost::ReplaysAsBool(played)) => v.as_bool() == Some(*played),
(Some(Value::Null), _) => true,
(Some(v), Lost::ReplaysAs(played)) => {
v.as_str() == Some(played)
|| v.as_f64().is_some_and(|n| played.parse::<f64>().is_ok_and(|d| n == d))
|| v.as_bool().is_some_and(|b| played.parse::<bool>() == Ok(b))
}
(Some(v), Lost::DiffersAt(spelling)) => v.as_str() != Some(spelling),
})
}
@@ -898,6 +910,7 @@ pub(super) const EVENTS: &[Event] = &[
"layer.new.layerFromBackground",
"layer.layerStyle.blendingOptions",
"layer.smartFilter.blendingOptions",
"path.set",
],
from: set_from,
to: set_to,
@@ -1187,6 +1200,15 @@ pub(super) const EVENTS: &[Event] = &[
from: super::steps_batch::batch_from,
to: super::steps_batch::batch_to,
},
// Create Droplet (#10710): Photoshop has no string ID for it.
Event {
code: "CrtD",
name: "CrtD",
label: "Create Droplet",
ids: &["file.automate.createDroplet"],
from: super::steps_droplet::droplet_from,
to: super::steps_droplet::droplet_to,
},
Event { code: "", name: "applyComp", label: "Apply Layer Comp", ids: &["layerComp.apply"], from: apply_comp_from, to: apply_comp_to },
Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to },
];
+8 -7
View File
@@ -51,21 +51,22 @@ pub(super) fn load(s: &mut Session, p: &Value) -> Result<Value> {
names.push(name);
added.push(a);
}
// Play, Conditional and Batch steps call actions by name: follow the renames in the actions just
// loaded, each target looked up once by the name the file gave it.
// Play, Conditional, Batch and Create Droplet steps call actions by name: follow the renames in
// the actions just loaded, each target looked up once by the name the file gave it.
for a in &mut added {
for (id, params) in &mut a.steps {
// A qualified Batch reference belongs to the file's original set, not to a
// foreign set which happens to have the destination override's name.
if id == "file.automate.batch" && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) {
// A qualified Batch or Create Droplet reference belongs to the file's original set, not
// to a foreign set which happens to have the destination override's name.
let by_set = matches!(id.as_str(), "file.automate.batch" | "file.automate.createDroplet");
if by_set && params.get("set").and_then(Value::as_str) == Some(original_set.as_str()) {
params["set"] = json!(imp.set);
} else if id == "file.automate.batch" && params.get("set").is_some_and(|set| !set.is_null()) {
} else if by_set && params.get("set").is_some_and(|set| !set.is_null()) {
continue;
}
let keys: &[&str] = match id.as_str() {
"actions.play" => &["action"],
"actions.conditional" => &["then", "else"],
"file.automate.batch" => &["action"],
"file.automate.batch" | "file.automate.createDroplet" => &["action"],
_ => &[],
};
for key in keys {
@@ -0,0 +1,90 @@
//! Descriptor-free menu intent is distinct from a command with recorded settings.
//! Image Size and unambiguous mapped filter events keep absent settings distinct from empty ones.
use super::events::{EVENTS, Event, event_for};
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.")) {
return None;
}
EVENTS.iter().find(|e| {
e.ids == [id]
&& !e.name.is_empty()
&& event_for(&AtnEvent::name(e.name)).is_some_and(|mapped| mapped.code == e.code && mapped.ids == e.ids)
&& crate::commands::find(id).is_some()
})
}
/// Menu choices use existing event mappings rather than inventing event names from command ids.
pub fn menu_item_commands() -> Vec<&'static str> {
let mut seen = std::collections::HashSet::new();
EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| menu_event(id).is_some() && seen.insert(*id)).collect()
}
/// Stateless filter menu items execute immediately; settings-bearing ones require the editor.
pub fn menu_item_needs_settings(id: &str) -> bool {
crate::commands::find(id).is_none_or(|c| !c.params.trim_start().starts_with("{}"))
}
/// An Insert Menu Item step with no recorded command descriptor.
pub const MENU_ITEM: &str = "actions.menuItem";
/// Validate menu identity before the shell gates and opens its actual command.
pub fn menu_item_command(p: &Value) -> Result<&str> {
let object = p.as_object().ok_or_else(|| bad(MENU_ITEM, "parameters must be an object"))?;
if object.keys().any(|key| !matches!(key.as_str(), "id" | "eventKind" | super::RECORDED_EVENT)) {
return Err(bad(MENU_ITEM, "only `id` and `eventKind` are supported; a menu item has no recorded settings"));
}
if object.get("eventKind").is_some_and(|kind| !matches!(kind.as_str(), Some("code" | "name"))) {
return Err(bad(MENU_ITEM, "`eventKind` must be name or code"));
}
let id = p.get("id").and_then(Value::as_str).ok_or_else(|| bad(MENU_ITEM, "select a menu command"))?;
let event = menu_event(id).ok_or_else(|| bad(MENU_ITEM, "the command has no unambiguous supported menu event"))?;
if p.get("eventKind").and_then(Value::as_str) == Some("code") {
let code = event.code.as_bytes().try_into().ok().map(AtnEvent::Code);
if !code.as_ref().and_then(event_for).is_some_and(|mapped| mapped.name == event.name && mapped.ids == event.ids) {
return Err(bad(MENU_ITEM, "this menu event has no unambiguous four-character code"));
}
}
Ok(id)
}
/// Preserve the exact code/name kind only for the supported descriptor-free event.
pub(super) fn from_event(event: &AtnEvent) -> Option<Value> {
let mapped = event_for(event)?;
let id = *mapped.ids.first()?;
let canonical = menu_event(id)?;
if canonical.name != mapped.name || canonical.code != mapped.code {
return None;
}
let kind = match event {
AtnEvent::Code(code) if mapped.code.as_bytes() == code => "code",
AtnEvent::Name(name) if mapped.name.as_bytes() == name => "name",
_ => return None,
};
Some(json!({"id":id,"eventKind":kind}))
}
/// Write absent settings as `None`, never as an empty or current-target descriptor.
pub(super) fn export(p: &Value) -> Option<AtnItem> {
let mapped = menu_event(menu_item_command(p).ok()?)?;
let event = if p.get("eventKind").and_then(Value::as_str) == Some("code") {
AtnEvent::Code(mapped.code.as_bytes().try_into().ok()?)
} else {
AtnEvent::name(mapped.name)
};
let (event, dictionary) = super::steps_gallery::recorded_event(super::MENU_ITEM, p).unwrap_or_else(|| (event, mapped.label.as_bytes().to_vec()));
Some(AtnItem { event, dictionary, descriptor: None, ..Default::default() })
}
/// Headless execution runs stateless items and refuses settings deliberately left for a dialog.
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let id = menu_item_command(p)?;
if !menu_item_needs_settings(id) {
return s.execute(id, json!({}));
}
Err(EngineError::Other(format!("interactive-required: `{id}` needs its menu item dialog; no recorded settings were supplied")))
}
@@ -0,0 +1,227 @@
use super::super::{Action, AtnAction, AtnEvent, AtnFile, AtnItem, UnicodeString, atn};
use super::*;
/// Construct a pixel point directly from the measured #10706 wire format.
fn wire_point(x: f64, y: f64) -> D {
D::Descriptor(Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: x }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: y }))
}
/// Encode a fixture corner without cubic handles.
fn corner(x: f64, y: f64) -> D {
D::Descriptor(Descriptor::new("Pthp").with("Anch", wire_point(x, y)))
}
/// Build the measured outer and inner contours independently of the exporter.
fn fixture(operation: &str, joined: bool, reversed: bool) -> Descriptor {
let mut outer = vec![corner(50.0, 250.0), corner(50.0, 100.0), corner(350.0, 100.0), corner(350.0, 250.0)];
if !joined {
outer[1] = D::Descriptor(
Descriptor::new("Pthp")
.with("Anch", wire_point(50.0, 100.0))
.with("Fwd ", wire_point(50.0, 100.0))
.with("Bwd ", wire_point(100.0, 20.0))
.with("Smoo", D::Boolean(false)),
);
outer[2] = D::Descriptor(
Descriptor::new("Pthp")
.with("Anch", wire_point(350.0, 100.0))
.with("Fwd ", wire_point(300.0, 20.0))
.with("Bwd ", wire_point(350.0, 100.0))
.with("Smoo", D::Boolean(false)),
);
}
let mut inner = vec![corner(150.0, 150.0), corner(250.0, 150.0), corner(250.0, 220.0), corner(150.0, 220.0)];
if reversed {
inner.reverse();
}
let sub = |points| D::Descriptor(Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(points)));
let component = |op, subs| {
D::Descriptor(
Descriptor::new("PaCm")
.with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new(op) })
.with("SbpL", D::List(subs)),
)
};
let components = if joined {
vec![component("Add ", vec![sub(outer), sub(inner)])]
} else {
vec![component("Add ", vec![sub(outer)]), component(operation, vec![sub(inner)])]
};
Descriptor::new(if joined { "null" } else { "setd" })
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }]))
.with("T ", D::List(components))
}
/// Extract the single executable Work Path assignment from a measured descriptor.
fn params(d: &Descriptor) -> Value {
let Mapped::Steps(steps) = from(d) else { panic!("measured path must map") };
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].0, "path.set");
steps[0].1.clone()
}
/// Sample the filled contour, its hole and the untouched exterior independently.
fn check_area(path: &Path) {
let pixels = photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::new(0, 0, 400, 300));
for (x, y, expected) in [(100, 185, 1.0), (200, 185, 0.0), (10, 20, 0.0)] {
assert_eq!(pixels[y * 400 + x], expected, "sample {x},{y}");
}
}
#[test]
fn measured_curves_and_component_operations_round_trip_and_cut_holes() {
for (operation, expected) in [("Sbtr", PathOp::Subtract), ("xor", PathOp::Exclude)] {
let d = fixture(operation, false, false);
let p = params(&d);
let path = crate::vector_cmds::parse_path(&p["path"]).unwrap();
assert_eq!(path.fill_rule, FillRule::EvenOdd);
assert_eq!(path.subpaths[1].op, expected);
let knots = &path.subpaths[0].knots;
assert_eq!(knots[1].out_ctrl, Point::new(50.0, 100.0));
assert_eq!(knots[1].in_ctrl, Point::new(100.0, 20.0));
assert_eq!(knots[2].out_ctrl, Point::new(300.0, 20.0));
assert_eq!(knots[2].in_ctrl, Point::new(350.0, 100.0));
assert_eq!(to(&p), Some(d));
check_area(&path);
}
}
#[test]
fn one_component_has_even_odd_holes_with_either_inner_winding() {
for reversed in [false, true] {
let d = fixture("Add ", true, reversed);
let p = params(&d);
let path = crate::vector_cmds::parse_path(&p["path"]).unwrap();
assert_eq!(path.components(), vec![0..2]);
assert_eq!(path.subpaths[1].op, PathOp::Join);
check_area(&path);
assert_eq!(to(&p), Some(d));
}
}
#[test]
fn whole_atn_and_native_insert_path_replay_keep_geometry() {
let original = fixture("Sbtr", false, false);
let file = AtnFile {
actions: vec![AtnAction {
items: vec![AtnItem { event: AtnEvent::name("set"), descriptor: Some(original.clone()), ..Default::default() }],
..Default::default()
}],
..Default::default()
};
let imported = super::super::import(&atn::write(&file).unwrap()).unwrap();
assert!(imported.unsupported.is_empty());
let (bytes, skipped) = super::super::export("S", &[&imported.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
assert_eq!(atn::parse(&bytes).unwrap().actions[0].items[0].descriptor, Some(original.clone()));
let expected = path_from(&original).unwrap();
let mut s = crate::Session::new();
s.execute("file.new", json!({"width":400,"height":300})).unwrap();
s.execute("path.set", json!({"name":"Source","path":crate::vector_cmds::path_json(&expected)})).unwrap();
s.actions.list.push(Action::new("Captured", Vec::new()));
let revision = s.active().unwrap().revision;
s.execute("actions.insertPath", json!({"action":0,"name":"Source"})).unwrap();
assert_eq!(s.active().unwrap().revision, revision);
let (bytes, skipped) = super::super::export("S", &[&s.actions.list[0]], &[], false).unwrap();
assert!(skipped.is_empty());
let imported = super::super::import(&bytes).unwrap();
assert!(imported.unsupported.is_empty());
s.actions.list = imported.actions;
s.execute("path.set", json!({"name":"Source","path":{"subpaths":[]}})).unwrap();
let played = s.execute("actions.play", json!({"action":0})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.active().unwrap().doc.work_path.as_ref(), Some(&expected));
check_area(s.active().unwrap().doc.work_path.as_ref().unwrap());
}
#[test]
fn source_wire_identity_is_retained_only_for_unchanged_geometry() {
let mut d = fixture("xor", false, false);
d.name = UnicodeString::new_nul("Set");
d.items.reverse();
let mut p = params(&d);
assert_eq!(to(&p).unwrap().to_bytes(), d.to_bytes());
p["path"]["subpaths"][0]["knots"][0]["anchor"][0] = json!(51.0);
assert!(to(&p).is_none());
}
#[test]
fn unknown_and_unrepresented_settings_are_not_silently_lost() {
let base = fixture("Sbtr", false, false);
let mut empty_components = base.clone();
let (_, components) = empty_components.items.iter_mut().find(|(key, _)| key.is("T ")).unwrap();
*components = D::List(Vec::new());
for d in [base.clone().with("extra", D::Boolean(true)), empty_components, fixture("Intr", false, false)] {
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
}
let p = params(&base);
let mut empty = p.clone();
empty["path"]["subpaths"] = json!([]);
empty.as_object_mut().unwrap().remove(SOURCE);
assert!(to(&empty).is_none());
for key in ["inverted", "fillRule"] {
let mut changed = p.clone();
changed["path"][key] = if key == "inverted" { json!(true) } else { json!("nonzero") };
changed.as_object_mut().unwrap().remove(SOURCE);
assert!(to(&changed).is_none());
}
let mut changed = p.clone();
changed["path"]["unrepresented"] = json!(1);
assert!(to(&changed).is_none());
let mut changed = p;
changed[SOURCE] = json!("not base64");
assert!(to(&changed).is_none());
}
#[test]
fn malformed_coordinates_and_oversized_paths_stay_unsupported() {
fn list(value: &mut D) -> &mut Vec<D> {
match value {
D::List(v) => v,
_ => panic!("fixture list"),
}
}
fn object(value: &mut D) -> &mut Descriptor {
match value {
D::Descriptor(v) => v,
_ => panic!("fixture object"),
}
}
for bad in [D::UnitFloat { unit: *b"#Prc", value: 50.0 }, D::UnitFloat { unit: *b"#Pxl", value: f64::NAN }, D::Double(50.0)] {
let mut d = fixture("Sbtr", false, false);
let components = list(&mut d.items[1].1);
let subs = list(&mut object(&mut components[0]).items[1].1);
let points = list(&mut object(&mut subs[0]).items[1].1);
let anchor = object(&mut points[0]);
let coordinates = object(&mut anchor.items[0].1);
coordinates.items[0].1 = bad;
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
}
let mut p = params(&fixture("Sbtr", false, false));
p.as_object_mut().unwrap().remove(SOURCE);
let knot = p["path"]["subpaths"][0]["knots"][0].clone();
p["path"]["subpaths"][0]["knots"] = json!(vec![knot.clone(); MAX_PARTS - 4]);
assert!(to(&p).is_some(), "the existing 4096-knot ceiling remains representable");
p["path"]["subpaths"][0]["knots"] = json!(vec![knot; MAX_PARTS + 1]);
assert!(to(&p).is_none());
}
#[test]
fn oversized_descriptor_names_are_rejected_before_retained_wire_serialization() {
// Each name meets the local limit, but their aggregate exceeds the carrier.
// Keep this typed input independent of the serializer under test.
let mut point =
Descriptor::new("Pnt ").with("Hrzn", D::UnitFloat { unit: *b"#Pxl", value: 50.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Pxl", value: 50.0 });
point.name = UnicodeString(vec![b'x' as u16; 4096]);
let mut knot = Descriptor::new("Pthp").with("Anch", D::Descriptor(point));
knot.name = UnicodeString(vec![b'x' as u16; 4096]);
let sub = Descriptor::new("Sbpl").with("Clsp", D::Boolean(true)).with("Pts ", D::List(vec![D::Descriptor(knot); 300]));
let component = Descriptor::new("PaCm")
.with("shapeOperation", D::Enumerated { type_id: Id::new("shapeOperation"), value: Id::new("Add ") })
.with("SbpL", D::List(vec![D::Descriptor(sub)]));
let d = Descriptor::new("setd")
.with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }]))
.with("T ", D::List(vec![D::Descriptor(component)]));
assert!(path_from(&d).is_none(), "recognition must reject before from() serializes the retained descriptor");
assert!(matches!(from(&d), Mapped::Unsupported { harmless: false }));
}
@@ -0,0 +1,214 @@
//! #10710: Actions steps recorded with the Gradient tool and Create Droplet, as the issue's table
//! gives them (measured in `docs/atn.md` › "Measured through dialogs and recordings"; once mapped,
//! the events are listed with the supported events instead).
use super::*;
fn action(items: Vec<AtnItem>) -> Vec<u8> {
atn::write(&AtnFile {
name: UnicodeString::new_nul("Recorded"),
actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }],
..Default::default()
})
.unwrap()
}
fn step(event: &[u8; 4], label: &str, d: Descriptor) -> AtnItem {
AtnItem { event: AtnEvent::Code(*event), dictionary: label.as_bytes().to_vec(), descriptor: Some(d), ..Default::default() }
}
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 gradient object the Gradient tool records: dither, classic interpolation, angle, type,
/// scale and offset in percent, then the two-stop gradient.
fn tool_gradient(angle: f64, scale: f64, offset: (f64, f64)) -> Descriptor {
let stop = |hex: &str, at: i32| {
D::Descriptor(
desc("Clrt")
.with("Clr ", D::Descriptor(color_desc(hex).unwrap()))
.with("Type", enm("Clry", "UsrS"))
.with("Lctn", D::Integer(at))
.with("Mdpn", D::Integer(50)),
)
};
let trn = |at: i32| D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", D::Integer(at)).with("Mdpn", D::Integer(50)));
let grad = desc("Grdn")
.with("Nm ", text("Custom"))
.with("GrdF", enm("GrdF", "CstS"))
.with("Intr", D::Double(4096.0))
.with("Clrs", D::List(vec![stop("#102030", 0), stop("#f0e0d0", 4096)]))
.with("Trns", D::List(vec![trn(0), trn(4096)]));
desc("gradientLayer")
.with("Dthr", D::Boolean(true))
.with("gradientsInterpolationMethod", enm("gradientInterpolationMethodType", "Gcls"))
.with("Angl", ang(angle))
.with("Type", enm("GrdT", "Lnr "))
.with("Scl ", pct(scale))
.with("Ofst", D::Descriptor(desc("Pnt ").with("Hrzn", pct(offset.0)).with("Vrtc", pct(offset.1))))
.with("Grad", D::Descriptor(grad))
}
/// The Gradient tool (default mode) records a Make of a gradient fill layer at the first click and
/// a Set with the finished drag (`Algn` false). Both load as their commands, play, and save back
/// as recorded.
#[test]
fn gradient_tool_steps_load_play_and_save_back() {
let make = desc("null")
.with("null", class_ref("contentLayer"))
.with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(tool_gradient(90.0, 100.0, (0.0, 0.0))))));
let set = desc("null")
.with("null", target_ref("contentLayer"))
.with("T ", D::Descriptor(tool_gradient(-35.0, 140.0, (12.5, -8.0)).with("Algn", D::Boolean(false))));
let items = vec![step(b"Mk ", "Make", make.clone()), step(b"setd", "Set", set.clone())];
let mut s = Session::new();
let loaded = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(items))})).unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, ["layer.newFillLayer.gradient", "gradient.fill.set"]);
let set_params = &s.actions.list[0].steps[1].1;
assert_eq!(
(set_params["angle"].as_f64(), set_params["scale"].as_f64(), set_params["align"].as_bool()),
(Some(-35.0), Some(140.0), Some(false)),
"{set_params}"
);
s.execute("file.new", json!({"width": 20, "height": 12})).unwrap();
let played = s.execute("actions.play", json!({"action": "A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(played["ran"], 2, "{played}");
let back = saved(&mut s);
assert_eq!(back.len(), 2);
assert_eq!((back[0].event.id(), back[0].descriptor.as_ref()), ("Mk ".to_string(), Some(&make)));
assert_eq!((back[1].event.id(), back[1].descriptor.as_ref()), ("setd".to_string(), Some(&set)));
}
/// A Create Droplet step as the reference application records it with a Folder destination and
/// Override Action "Save As" on.
fn droplet(file: &str, output: &str, extra: &[(&str, D)]) -> Descriptor {
let reference = D::Reference(vec![
ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") },
ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Tools") },
]);
let mut d = desc("null").with("In ", path_value(file)).with("Fldr", D::Boolean(false)).with("Usng", reference);
for (k, v) in extra.iter().take_while(|(k, _)| *k == "OvrO") {
d = d.with(k, v.clone());
}
d = d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true));
for (k, v) in extra.iter().filter(|(k, _)| *k != "OvrO") {
d = d.with(k, v.clone());
}
d
}
/// Create Droplet loads as `file.automate.createDroplet`, plays (writes the droplet with the
/// action's steps) and saves back as recorded.
#[test]
fn create_droplet_step_loads_plays_and_saves_back() {
let dir = std::env::temp_dir().join(format!("pc-10710-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Sharpen.pcdroplet").to_string_lossy().into_owned();
let out = dir.join("out").to_string_lossy().into_owned();
let recorded = droplet(&file, &out, &[("OvrO", D::Boolean(true))]);
let mut s = Session::new();
// The action the droplet runs.
s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))]));
s.actions.list[0].set = Some("Tools".into());
let loaded = s
.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&action(vec![step(b"CrtD", "Create Droplet", recorded.clone())]))}))
.unwrap();
assert_eq!(loaded["unsupported"], json!([]), "{loaded}");
let a = s.actions.list.iter().find(|a| a.name == "A").unwrap();
assert_eq!(
a.steps,
[("file.automate.createDroplet".to_string(), json!({"path": file, "action": "Sharpen", "set": "Tools", "output": out, "overrideOpen": true}))]
);
let r = s.execute("file.automate.createDroplet", a.steps[0].1.clone()).unwrap();
assert_eq!(r["path"], json!(file));
let written: Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
assert_eq!(written["action"]["steps"], json!([["filter.sharpen.sharpen", {}]]));
assert_eq!((written["options"]["output"].as_str(), written["options"]["overrideOpen"].as_bool()), (Some(out.as_str()), Some(true)));
let r = s.execute("actions.save", json!({"action": "A"})).unwrap();
let back = atn::parse(&photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
let item = back.actions.iter().find(|a| a.name.to_string_lossy() == "A").unwrap().items[0].clone();
assert_eq!((item.event.id(), item.descriptor), ("CrtD".to_string(), Some(recorded)));
let _ = std::fs::remove_dir_all(&dir);
}
/// What the droplet cannot do keeps the Photoshop step: Include All Subfolders, Save and Close,
/// no destination, Save As steps left to save where recorded, suppressed dialogs, custom naming
/// and an error log.
#[test]
fn create_droplet_steps_the_droplet_cannot_play_are_kept() {
let kept = |d: Descriptor| matches!(steps_droplet::droplet_from(&d), Mapped::Unsupported { harmless: false });
let base = || droplet("/d/S.pcdroplet", "/d/out", &[]);
let replaced = |key: &str, v: D| {
let mut d = base();
d.items.retain(|(k, _)| !k.is(key));
d.with(key, v)
};
assert!(!kept(base()), "the plain form maps");
assert!(kept(replaced("Fldr", D::Boolean(true))));
assert!(kept(replaced("Ovrd", D::Boolean(false))));
let mut no_destination = base();
no_destination.items.retain(|(k, _)| !k.is("T "));
assert!(kept(no_destination.clone()));
assert!(kept(no_destination.with("SvAn", D::Boolean(true))));
assert!(kept(base().with("suppressOpenOptions", D::Boolean(true))));
assert!(kept(base().with("fileNamingRules", D::Descriptor(desc("fileNamingRules")))));
assert!(kept(base().with("Log ", path_value("/d/logs"))));
}
/// Settings the event has no key for refuse the step on save rather than replay differently.
#[test]
fn create_droplet_steps_that_cannot_be_written_are_refused() {
let p = json!({"path": "/d/S.pcdroplet", "action": "Sharpen", "output": "/d/out"});
assert!(export_item("file.automate.createDroplet", &p, "S").is_some());
for extra in [json!({"format": "png"}), json!({"quality": 8}), json!({"steps": [["filter.sharpen.sharpen", {}]]}), json!({"name": "Other"})] {
let mut q = p.clone();
q.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
assert!(export_item("file.automate.createDroplet", &q, "S").is_none(), "{q}");
}
let mut no_output = p.clone();
no_output.as_object_mut().unwrap().remove("output");
assert!(export_item("file.automate.createDroplet", &no_output, "S").is_none());
}
/// Loading a set whose action clashes with one already in the panel renames the loaded copy; its
/// Create Droplet steps follow the rename, as Batch steps do, whether the reference names the
/// action alone or the action in the file's own set (CodeRabbit review on steps_droplet.rs:59).
#[test]
fn create_droplet_steps_follow_a_renamed_loaded_action() {
let with_ref = |items: Vec<ReferenceItem>| {
let mut d = droplet("/d/S.pcdroplet", "/d/out", &[]);
d.items.retain(|(k, _)| !k.is("Usng"));
d.with("Usng", D::Reference(items))
};
let actn = || ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Sharpen") };
let own_set = ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("Recorded") };
let file = atn::write(&AtnFile {
name: UnicodeString::new_nul("Recorded"),
actions: vec![
AtnAction { name: UnicodeString::new_nul("Sharpen"), ..Default::default() },
AtnAction {
name: UnicodeString::new_nul("A"),
items: vec![step(b"CrtD", "Create Droplet", with_ref(vec![actn()])), step(b"CrtD", "Create Droplet", with_ref(vec![actn(), own_set]))],
..Default::default()
},
],
..Default::default()
})
.unwrap();
let mut s = Session::new();
s.actions.list.push(crate::actions_cmds::Action::new("Sharpen", vec![]));
s.actions.list[0].set = Some("Tools".into());
let r = s.execute("actions.load", json!({"data": photocraft_paint::tile::b64_encode(&file), "set": "Loaded"})).unwrap();
assert_eq!(r["actions"], json!(["Sharpen 2", "A"]), "{r}");
let a = s.actions.list.iter().find(|a| a.name == "A").unwrap();
assert_eq!(a.steps[0].1["action"], "Sharpen 2", "{:?}", a.steps[0]);
assert_eq!((&a.steps[1].1["action"], &a.steps[1].1["set"]), (&json!("Sharpen 2"), &json!("Loaded")), "{:?}", a.steps[1]);
}
@@ -0,0 +1,85 @@
//! File › Automate › Create Droplet (`CrtD`, #10710): the droplet file (`In `), the action
//! (`Usng`) and the Batch keys, as #10710 lists them from the reference application. Only the form
//! `file.automate.createDroplet` plays the same way maps; any other stays a Photoshop step.
use super::descriptors::{class, desc, jstr, path_text, path_value};
use super::fields::{Mapped, step};
use super::{D, Descriptor, Map, ReferenceItem, UnicodeString, Value, json};
/// The keys a recorded Create Droplet step can carry (the Batch keys, with `In ` for the source).
const KEYS: &[&str] = &["In ", "Fldr", "Usng", "OvrO", "suppressWarnings", "suppressOpenOptions", "T ", "SvAn", "Ovrd", "fileNamingRules", "Log ", "Nm "];
fn flag(d: &Descriptor, key: &str) -> Option<bool> {
match d.get(key) {
None => Some(false),
Some(D::Boolean(b)) => Some(*b),
Some(_) => None,
}
}
/// A droplet that saves each file into a folder and overrides the action's Save As steps maps:
/// that is what `file.automate.createDroplet` writes (its output folder replaces recorded saves).
/// Include All Subfolders (the droplet lists a folder's own images only), Save and Close or no
/// destination, Save As steps left to save where they were recorded, suppressed warnings or open
/// options, custom file naming and an error log have no field in the droplet, so they keep the
/// Photoshop step.
pub(super) fn droplet_from(d: &Descriptor) -> Mapped {
read(d).map_or(Mapped::Unsupported { harmless: false }, |p| step("file.automate.createDroplet", Value::Object(p)))
}
fn read(d: &Descriptor) -> Option<Map<String, Value>> {
if !d.items.iter().all(|(k, _)| KEYS.iter().any(|s| k.is(s))) {
return None;
}
let off = |k: &str| flag(d, k) == Some(false);
if !off("Fldr") || !off("SvAn") || !off("suppressWarnings") || !off("suppressOpenOptions") || flag(d, "Ovrd") != Some(true) {
return None;
}
if ["fileNamingRules", "Log ", "Nm "].iter().any(|k| d.get(k).is_some()) {
return None;
}
let mut p = Map::new();
p.insert("path".into(), json!(path_text(d.get("In ")?)?));
let D::Reference(items) = d.get("Usng")? else { return None };
match items.as_slice() {
[ReferenceItem::Name { class: action, name }, ReferenceItem::Name { class: set, name: set_name }]
if action.class_id.is("Actn") && set.class_id.is("ASet") =>
{
p.insert("action".into(), json!(name.to_string_lossy()));
p.insert("set".into(), json!(set_name.to_string_lossy()));
}
[ReferenceItem::Name { class: action, name }] if action.class_id.is("Actn") => {
p.insert("action".into(), json!(name.to_string_lossy()));
}
_ => return None,
}
p.insert("output".into(), json!(path_text(d.get("T ")?)?));
if flag(d, "OvrO")? {
p.insert("overrideOpen".into(), json!(true));
}
Some(p)
}
/// Written as `read` reads it: the droplet file, Include All Subfolders off, the action by name
/// (in its set when the step names one), Override Action "Open" only when on, the destination
/// folder and Override Action "Save As" on. Any other setting refuses the step.
pub(super) fn droplet_to(_: &str, p: &Value) -> Option<Descriptor> {
let allowed = ["path", "action", "set", "output", "overrideOpen"];
if p.as_object()?.iter().any(|(k, v)| !v.is_null() && !allowed.contains(&k.as_str())) {
return None;
}
let path = jstr(p, "path").filter(|s| !s.is_empty())?;
let action = jstr(p, "action").filter(|s| !s.is_empty())?;
let output = jstr(p, "output").filter(|s| !s.is_empty())?;
let mut reference = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(action) }];
if let Some(set) = p.get("set").filter(|v| !v.is_null()) {
reference.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set.as_str()?) });
}
let mut d = desc("null").with("In ", path_value(path)).with("Fldr", D::Boolean(false)).with("Usng", D::Reference(reference));
match p.get("overrideOpen") {
None | Some(Value::Null) | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) => d = d.with("OvrO", D::Boolean(true)),
Some(_) => return None,
}
Some(d.with("T ", path_value(output)).with("Ovrd", D::Boolean(true)))
}
@@ -600,11 +600,12 @@ pub(crate) const RECORDED_EVENT: &str = "_atnEvent";
/// Remember the event of a step loaded from an effect's own event (`AccE`, `accentedEdges`).
/// A `GEfc` step carries nothing and saves as `GEfc`, as does a step made in PhotoCraft.
pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) {
if !is_effect(&item.event) {
return;
}
let [(id, Value::Object(p))] = steps else { return };
if effect_command(&item.event) != Some(id.as_str()) {
let menu = id == super::MENU_ITEM
&& item.descriptor.is_none()
&& super::menu_item::from_event(&item.event)
.is_some_and(|expected| p.get("id") == expected.get("id") && p.get("eventKind") == expected.get("eventKind"));
if !menu && effect_command(&item.event) != Some(id.as_str()) {
return;
}
let kind = match item.event {
@@ -617,8 +618,8 @@ pub(super) fn remember_event(item: &AtnItem, steps: &mut [(String, Value)]) {
p.insert(RECORDED_EVENT.into(), json!({"event": item.event.id(), "eventKind": kind, "name": item.dictionary(), "dictionary": dictionary}));
}
/// The event and panel name bytes a step of command `id` remembered by [`remember_event`] saves
/// as; `None` (save as `GEfc`) when it has none or it is not the own event of `id`'s effect.
/// Return remembered event/name bytes only while they still match the current command and kind.
/// Otherwise the exporter uses that command's canonical event and name.
pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec<u8>)> {
let r = params.get(RECORDED_EVENT)?;
let event = jstr(r, "event")?;
@@ -631,7 +632,13 @@ pub(super) fn recorded_event(id: &str, params: &Value) -> Option<(AtnEvent, Vec<
Some(bytes) => bytes,
None => jstr(r, "name").unwrap_or("").as_bytes().to_vec(),
};
(effect_command(&event) == Some(id)).then_some((event, name))
let matches = if id == super::MENU_ITEM {
super::menu_item::from_event(&event)
.is_some_and(|expected| params.get("id") == expected.get("id") && params.get("eventKind") == expected.get("eventKind"))
} else {
effect_command(&event) == Some(id)
};
matches.then_some((event, name))
}
/// A Filter Gallery step: the effect `GEfk` names, every key clamped or looked up, Photoshop's
@@ -1164,6 +1164,7 @@ pub(super) fn set_from(d: &Descriptor) -> Mapped {
let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } };
let unsupported = Mapped::Unsupported { harmless: false };
match (ref_class(r).as_deref(), target(r)) {
(Some("Path"), Target::Property(p)) if p == "WrPt" => super::steps_path::from(d),
(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})),
@@ -1270,6 +1271,7 @@ pub(super) fn set_to(id: &str, p: &Value) -> Option<Descriptor> {
let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]);
let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t);
Some(match id {
"path.set" => return super::steps_path::to(p),
"select.all" => sel(enm("Ordn", "Al ")),
"select.deselect" => sel(enm("Ordn", "None")),
"select.reselect" => sel(enm("Ordn", "Prvs")),
@@ -0,0 +1,212 @@
//! Measured Insert Path: per-component operations, with even-odd subpaths in each component.
use super::descriptors::{PX, class, desc, enm, get_list, get_ref, jstr, px};
use super::fields::{Mapped, step};
use super::{D, Descriptor, Id, ReferenceItem, Value, json};
use photocraft_doc::{FillRule, Knot, Path, PathOp, Subpath};
use photocraft_geom::Point;
pub(super) const SOURCE: &str = "_atnInsertPathDescriptor";
// The native path validator admits at most 4096 knots/subpaths. Larger wire
// records remain complete kept steps rather than secondary geometry allocations.
const MAX_PARTS: usize = 4096;
const MAX_SOURCE_BYTES: usize = 4 * 1024 * 1024;
#[cfg(test)]
#[path = "path_tests.rs"]
mod tests;
/// Bound the retained wire form before serializing it into a second allocation.
/// Names use UTF-16; 64 bytes per measured field conservatively covers its fixed
/// key/type/scalar or list header. Nested descriptors charge their own fields.
fn keys(d: &Descriptor, required: &[&str], optional: &[&str], budget: &mut usize) -> bool {
let size = d.name.0.len().checked_mul(2).and_then(|names| d.items.len().checked_mul(64)?.checked_add(names)?.checked_add(32));
let Some(remaining) = size.and_then(|size| budget.checked_sub(size)) else { return false };
*budget = remaining;
d.name.0.len() <= 4096
&& d.items.iter().all(|(k, _)| required.iter().chain(optional).any(|key| k.is(key)))
&& required.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() == 1)
&& optional.iter().all(|key| d.items.iter().filter(|(k, _)| k.is(key)).count() <= 1)
}
/// Require the recorded object class before interpreting its fields as geometry.
fn object<'a>(value: &'a D, class_id: &str) -> Option<&'a Descriptor> {
match value {
D::Descriptor(d) if d.class_id.is(class_id) => Some(d),
_ => None,
}
}
/// Read a pixel point within the native coordinate range and charge its fields to the wire budget.
fn point(value: &D, budget: &mut usize) -> Option<Point> {
let d = object(value, "Pnt ")?;
if !keys(d, &["Hrzn", "Vrtc"], &[], budget) {
return None;
}
let coordinate = |key| match d.get(key)? {
D::UnitFloat { unit, value } if *unit == PX && value.is_finite() && value.abs() <= f64::from(f32::MAX) => Some(*value),
_ => None,
};
Some(Point::new(coordinate("Hrzn")?, coordinate("Vrtc")?))
}
/// A corner stores only its anchor. A cubic knot must supply both handles and its smooth flag.
fn knot(value: &D, budget: &mut usize) -> Option<Knot> {
let d = object(value, "Pthp")?;
if !keys(d, &["Anch"], &["Fwd ", "Bwd ", "Smoo"], budget) {
return None;
}
let anchor = point(d.get("Anch")?, budget)?;
match (d.get("Fwd "), d.get("Bwd "), d.get("Smoo")) {
(None, None, None) => Some(Knot::corner(anchor.x, anchor.y)),
(Some(out), Some(input), Some(D::Boolean(smooth))) => {
Some(Knot { anchor, out_ctrl: point(out, budget)?, in_ctrl: point(input, budget)?, smooth: *smooth })
}
_ => None,
}
}
/// Decode a Work Path property assignment, preserving the grouping and operation of each component.
fn path_from(d: &Descriptor) -> Option<Path> {
let mut budget = MAX_SOURCE_BYTES;
if !(d.class_id.is("null") || d.class_id.is("setd")) || !keys(d, &["null", "T "], &[], &mut budget) {
return None;
}
let Some([ReferenceItem::Property { class, key }]) = get_ref(d, "null") else { return None };
if !class.class_id.is("Path") || class.name.0.len() > 4096 || !key.is("WrPt") {
return None;
}
budget = budget.checked_sub(class.name.0.len().checked_mul(2)?.checked_add(64)?)?;
let components = get_list(d, "T ")?;
if components.is_empty() || components.len() > MAX_PARTS {
return None;
}
let mut path = Path { fill_rule: FillRule::EvenOdd, ..Default::default() };
let mut count = 0usize;
for value in components {
let component = object(value, "PaCm")?;
if !keys(component, &["shapeOperation", "SbpL"], &[], &mut budget) {
return None;
}
let op = match component.get("shapeOperation")? {
D::Enumerated { type_id, value } if type_id.is("shapeOperation") => {
if value.is("Add ") {
PathOp::Combine
} else if value.is("Sbtr") {
PathOp::Subtract
} else if value.is("xor") {
PathOp::Exclude
} else {
return None;
}
}
_ => return None,
};
if path.subpaths.is_empty() && op != PathOp::Combine {
return None;
}
let subpaths = get_list(component, "SbpL")?;
if subpaths.is_empty() || path.subpaths.len().checked_add(subpaths.len())? > MAX_PARTS {
return None;
}
for (i, value) in subpaths.iter().enumerate() {
let sub = object(value, "Sbpl")?;
if !keys(sub, &["Clsp", "Pts "], &[], &mut budget) {
return None;
}
let D::Boolean(closed) = sub.get("Clsp")? else { return None };
let points = get_list(sub, "Pts ")?;
count = count.checked_add(points.len())?;
if count > MAX_PARTS {
return None;
}
let knots = points.iter().map(|value| knot(value, &mut budget)).collect::<Option<Vec<_>>>()?;
path.subpaths.push(Subpath { closed: *closed, knots, op: if i == 0 { op } else { PathOp::Join } });
}
}
photocraft_vector::flatten::validate_path(&path).ok()?;
Some(path)
}
/// Decode only the measured Work Path form; unsupported data stays a complete kept step.
pub(super) fn from(d: &Descriptor) -> Mapped {
let Some(path) = path_from(d) else { return Mapped::Unsupported { harmless: false } };
let raw = d.to_bytes();
if raw.len() > MAX_SOURCE_BYTES {
return Mapped::Unsupported { harmless: false };
}
step("path.set", json!({"name":"work", "path":crate::vector_cmds::path_json(&path), SOURCE:photocraft_paint::tile::b64_encode(&raw)}))
}
/// Write a point using pixel units for both coordinates.
fn point_to(p: Point) -> D {
D::Descriptor(desc("Pnt ").with("Hrzn", px(p.x)).with("Vrtc", px(p.y)))
}
/// Emit the compact corner form only when the handles coincide and the knot has no smooth flag.
fn knot_to(k: &Knot) -> D {
let mut d = desc("Pthp").with("Anch", point_to(k.anchor));
if k.in_ctrl != k.anchor || k.out_ctrl != k.anchor || k.smooth {
d = d.with("Fwd ", point_to(k.out_ctrl)).with("Bwd ", point_to(k.in_ctrl)).with("Smoo", D::Boolean(k.smooth));
}
D::Descriptor(d)
}
/// Export representable native geometry or an unchanged validated source descriptor.
/// Nonzero winding, inversion and unmeasured operations cannot silently become even-odd.
pub(super) fn to(p: &Value) -> Option<Descriptor> {
if jstr(p, "name") != Some("work") || p.as_object()?.keys().any(|k| !matches!(k.as_str(), "name" | "path" | SOURCE)) {
return None;
}
let value = p.get("path")?;
let subpaths = value.get("subpaths")?.as_array()?;
if subpaths.is_empty()
|| subpaths.len() > MAX_PARTS
|| subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.get("knots")?.as_array()?.len()))? > MAX_PARTS
{
return None;
}
let path = crate::vector_cmds::parse_path(value).ok()?;
photocraft_vector::flatten::validate_path(&path).ok()?;
let mut canonical = crate::vector_cmds::path_json(&path);
let mut supplied = value.clone();
super::convert::normalise(&mut canonical);
super::convert::normalise(&mut supplied);
if path.inverted || path.fill_rule != FillRule::EvenOdd || canonical != supplied {
return None;
}
if let Some(source) = p.get(SOURCE) {
let text = source.as_str()?;
if text.len() > MAX_SOURCE_BYTES.div_ceil(3) * 4 {
return None;
}
let raw = photocraft_paint::tile::b64_decode(text)?;
if raw.len() > MAX_SOURCE_BYTES {
return None;
}
let original = Descriptor::from_bytes(&raw).ok()?;
return (path_from(&original)? == path).then_some(original);
}
let mut components = Vec::new();
for range in path.components() {
let first = path.subpaths.get(range.start)?;
let op = match first.op {
PathOp::Combine => "Add ",
PathOp::Subtract => "Sbtr",
PathOp::Exclude => "xor",
_ => return None,
};
if components.is_empty() && first.op != PathOp::Combine {
return None;
}
let subs = path
.subpaths
.get(range)?
.iter()
.map(|s| D::Descriptor(desc("Sbpl").with("Clsp", D::Boolean(s.closed)).with("Pts ", D::List(s.knots.iter().map(knot_to).collect()))))
.collect();
components.push(D::Descriptor(desc("PaCm").with("shapeOperation", enm("shapeOperation", op)).with("SbpL", D::List(subs))));
}
Some(
desc("setd").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Path"), key: Id::new("WrPt") }])).with("T ", D::List(components)),
)
}
+28 -5
View File
@@ -600,10 +600,11 @@ fn a_selective_color_step_in_the_scalar_form_keeps_its_values() {
assert_eq!(range(&nulled, "reds"), [35.0, 0.0, 0.0, 0.0], "{nulled}");
}
/// A setting Photoshop's event has no field for is dropped by export, and the step then plays back
/// A setting the exported event has no field for is dropped by export, and the step then plays back
/// as a different edit: Crystallize's `seed` (#7668), Radial Blur's centre, Minimum's and
/// Maximum's `preserve`. Such a step is refused, as one with no equivalent event already is. So is
/// an adjustment edit that names its layer: the event edits the active one (#8532).
/// Maximum's `preserve`, Equalize's `selectedAreaOnly`. Such a step is refused, as one with no
/// equivalent event already is. So is an adjustment edit that names its layer: the event edits the
/// active one (#8532).
#[test]
fn a_setting_the_event_cannot_hold_is_refused() {
for (id, params) in [
@@ -611,6 +612,10 @@ fn a_setting_the_event_cannot_hold_is_refused() {
("filter.blur.radialBlur", json!({"amount": 10, "centerX": 0.25, "centerY": 0.5})),
("filter.other.minimum", json!({"radius": 4, "preserve": "roundness"})),
("filter.other.maximum", json!({"radius": 4, "preserve": "roundness"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": false})),
// Equalize reads `selectedAreaOnly` as a boolean only and refuses the string and null.
("image.adjustments.equalize", json!({"selectedAreaOnly": "true"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": null})),
("layer.setAdjustment", json!({"kind": "exposure", "layer": "Target_existing", "exposure": -0.75, "offset": 0.035, "gamma": 1.37})),
("layer.setAdjustment", json!({"kind": "exposure", "document": 2, "exposure": -0.75})),
] {
@@ -645,6 +650,8 @@ fn settings_at_the_value_the_event_plays_still_export() {
// review).
("filter.other.minimum", json!({"radius": 4, "preserve": "Squareness"})),
("filter.other.maximum", json!({"radius": 4, "preserve": "Roundness"})),
("image.adjustments.equalize", json!({"selectedAreaOnly": true})),
("image.adjustments.equalize", json!({})),
] {
let mut s = Session::new();
s.actions.list.push(Action::new("A", vec![(id.into(), params.clone())]));
@@ -734,6 +741,7 @@ fn every_mapped_command_exports_and_imports_back() {
("filter.blur.smartBlur", json!({"radius": 2.5, "threshold": 40, "quality": "medium", "mode": "overlayEdge"})),
("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.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"})),
@@ -827,6 +835,13 @@ fn every_mapped_command_exports_and_imports_back() {
("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})),
("type.convertToShape", json!({})),
("type.createWorkPath", json!({})),
(
"path.set",
json!({"name":"work","path":{"fillRule":"evenodd","inverted":false,"subpaths":[{"closed":false,"op":"combine","knots":[
{"anchor":[1,2],"in":[1,2],"out":[3,4],"smooth":false},
{"anchor":[8,9],"in":[6,7],"out":[8,9],"smooth":false}
]}]}}),
),
("filter.blur.shapeBlur", json!({})),
("filter.blur.shapeBlur", json!({"radius": 10})),
("layer.new.group", json!({"name": "G"})),
@@ -1107,6 +1122,10 @@ fn every_mapped_command_exports_and_imports_back() {
// Executable values match; independent public tests verify the source carrier.
back.as_object_mut().unwrap().remove("__atnClassicGradient");
}
if *id == "path.set" {
// The imported step retains the original descriptor alongside its playable path.
back.as_object_mut().unwrap().remove(super::steps_path::SOURCE).unwrap();
}
if *id == "edit.perspectiveWarp" {
back.as_object_mut().unwrap().remove("__atnPerspectiveWarp");
}
@@ -1400,6 +1419,7 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))),
),
item("GsnB", "Gaussian Blur", None),
item("GsnB", "Gaussian Blur", Some(desc("null"))),
// Duplicate into another open document: the `T` reference means nothing at
// replay, so the step is kept verbatim instead of misplayed as a same-doc copy.
item(
@@ -1421,8 +1441,11 @@ fn unsupported_shapes_become_photoshop_steps_not_errors() {
};
let imp = import(&atn::write(&file).unwrap()).unwrap();
let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
assert_eq!(imp.unsupported.len(), 5);
assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, MENU_ITEM, "filter.blur.gaussianBlur", PHOTOSHOP_STEP]);
assert_eq!(imp.actions[0].steps[4].1["id"], "filter.blur.gaussianBlur");
assert_eq!(imp.actions[0].steps[4].1["eventKind"], "code");
assert_eq!(imp.actions[0].steps[5].1, json!({}));
assert_eq!(imp.unsupported.iter().map(|step| step.step).collect::<Vec<_>>(), [0, 1, 2, 3, 6]);
assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them");
}
+8 -3
View File
@@ -1105,6 +1105,8 @@ pub(crate) fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
}
b = merge_brush(&b, &patch, cmd)?;
validate_brush(&b, cmd)?;
// The painting tools' Opacity takes 1-100 %, as in Photoshop: a 0 % brush paints nothing (#11812).
b.opacity = b.opacity.max(0.01);
// Loading a stored tip can fail. Finish preparing the brush before changing its identity or
// foreground colour, so a failed pick leaves the entire current brush state intact.
s.load_brush_tips(&mut b).map_err(|e| bad(cmd, e))?;
@@ -1170,8 +1172,8 @@ pub fn remembered_settings(b: &BrushSettings) -> Option<Value> {
/// A brush remembered by [`remembered_settings`], read back at launch: `None` when the value is
/// not a brush or fails the checks `tools.setBrush` makes (a size or scatter out of range). The
/// options-bar values are clamped to the ranges their controls allow (size 1..max px; hardness,
/// opacity, flow and smoothing 0..100 %), and stored tips load when the store is attached (else
/// options-bar values are clamped to the ranges their controls allow (size 1..max px; opacity
/// 1..100 %; hardness, flow and smoothing 0..100 %), and stored tips load when the store is attached (else
/// they paint with their previews until `Session::attach_preset_store`).
pub fn restored_settings(s: &Session, v: &Value) -> Option<BrushSettings> {
if !v.is_object() {
@@ -1183,6 +1185,9 @@ pub fn restored_settings(s: &Session, v: &Value) -> Option<BrushSettings> {
for f in [&mut b.hardness, &mut b.opacity, &mut b.flow, &mut b.smoothing.amount] {
*f = if f.is_nan() { 1.0 } else { f.clamp(0.0, 1.0) };
}
// Opacity takes 1-100 %, as `tools.setBrush` floors it: a brush remembered at 0 % (saved
// before that floor) would show 1 % but paint nothing (#11812).
b.opacity = b.opacity.max(0.01);
let _ = s.load_brush_tips(&mut b);
Some(b)
}
@@ -1365,7 +1370,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tools.setBrush",
"Set Brush",
r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias)}"##,
r##"{"preset":name?,"reset":bool?,…BrushSettings fields (camelCase, deep-merged; pose.overrideTiltX/Y:bool; pose.overrideTilt:legacy combined alias; opacity floors at 0.01)}"##,
always,
set_brush,
true
+16
View File
@@ -1380,6 +1380,22 @@ fn restored_brush_settings_are_clamped_or_rejected() {
}
}
/// #11812: a brush remembered at 0 % opacity (saved before the 1 % floor) reads back at 1 %, the
/// value its options-bar field shows, so it paints instead of laying down nothing.
#[test]
fn restored_brush_opacity_floors_at_one_percent() {
let s = Session::new();
for opacity in [0.0, -1.0, 0.004] {
let b = restored_settings(&s, &json!({"opacity": opacity})).unwrap();
assert_eq!(b.opacity, 0.01, "{opacity}");
}
assert_eq!(restored_settings(&s, &json!({"opacity": 0.37})).unwrap().opacity, 0.37, "values above the floor are kept");
let mut t = Session::new();
t.prefs.edit(|p| p.last_brush = json!({"brush": {"opacity": 0.0}}));
restore_brush(&mut t);
assert_eq!(t.tools.brush.opacity, 0.01, "the remembered session brush");
}
#[test]
fn remembered_brushes_never_embed_large_bitmaps() {
use std::sync::Arc;
+57 -2
View File
@@ -14,7 +14,7 @@ use std::cell::RefCell;
use photocraft_algo::liquify::{LiquifyField, LiquifyStroke, apply_liquify, auto_cell};
use photocraft_algo::perspective::{PerspectiveMap, Plane, Straighten, straighten};
use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask};
use photocraft_algo::puppet::{PuppetDensity, PuppetMesh, PuppetMode, PuppetPin, PuppetWarp, puppet_warp, puppet_warp_with_mask, warp_with_mesh};
use photocraft_algo::transform::Interp;
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface};
use photocraft_color::PixelFormat;
@@ -86,6 +86,51 @@ pub fn puppet_params(cmd: &str, p: &Value) -> Result<PuppetWarp> {
Ok(PuppetWarp { pins, mode, density, expansion })
}
/// The most vertices a stored Puppet Warp mesh may have (Photoshop's meshes have hundreds); the
/// PSD importer reads no more.
const MAX_STORED_VERTS: usize = 1 << 16;
/// A Puppet Warp's stored mesh, as a Photoshop file keeps it (`mesh`: `{"pins", "verts",
/// "deformed", "tris"}`, document pixels): the mesh solved for `pins`, drawn as it is instead of
/// solving again. `None` when there is none, it is malformed, or the filter was edited since: its
/// pins are no longer the ones it was solved for, or a solver setting is set (imported params
/// set none).
pub fn stored_puppet_mesh(p: &Value) -> Option<(PuppetMesh, Vec<[f64; 2]>)> {
let m = p.get("mesh")?;
if m.get("pins") != p.get("pins") || ["mode", "density", "expansion"].iter().any(|k| p.get(*k).is_some()) {
return None;
}
let points = |k: &str| -> Option<Vec<[f64; 2]>> {
let v = m.get(k)?.as_array()?;
if v.len() > MAX_STORED_VERTS {
return None;
}
v.iter().map(|q| serde_json::from_value::<[f64; 2]>(q.clone()).ok()).collect()
};
let (verts, deformed) = (points("verts")?, points("deformed")?);
let tris = m.get("tris")?.as_array()?;
if tris.len() > 2 * MAX_STORED_VERTS {
return None;
}
let tris: Vec<[usize; 3]> = tris.iter().map(|t| serde_json::from_value(t.clone()).ok()).collect::<Option<_>>()?;
Some((PuppetMesh { verts, tris, spacing: 0.0 }, deformed))
}
/// `params` of smart filter `command` on `sm`, without a stored Puppet Warp mesh made for another
/// placement than `sm`'s (`mesh.placement`, recorded on import): a mesh in document pixels doesn't
/// fit a smart object moved, transformed or warped since, so its pins are solved instead.
pub fn placed_puppet_params<'a>(command: &str, params: &'a Value, sm: &photocraft_doc::SmartObject) -> std::borrow::Cow<'a, Value> {
let stale = command == PUPPET_SMART && params.get("mesh").is_some_and(|m| m.get("placement") != Some(&sm.placement_json()));
if !stale {
return std::borrow::Cow::Borrowed(params);
}
let mut p = params.clone();
if let Some(o) = p.as_object_mut() {
o.remove("mesh");
}
std::borrow::Cow::Owned(p)
}
/// Perspective Warp params (planes, after the optional straighten).
pub fn perspective_params(cmd: &str, p: &Value) -> Result<Vec<Plane>> {
let mut planes: Vec<Plane> = match p.get("planes") {
@@ -182,7 +227,17 @@ pub fn apply_to_surface(id: &str, params: &Value, surf: &Surface, canvas: Rect)
Some(liquify_surface(surf, &strokes, cell, canvas))
}
// A stored warp too far to render leaves the smart object as it is (#1019).
PUPPET | PUPPET_SMART => Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone())),
PUPPET | PUPPET_SMART => {
// A mesh stored with the filter (from a Photoshop file) is drawn as it is; one that
// can't be drawn leaves the pins to solve.
let b = surf.content_bounds();
if let Some((mesh, deformed)) = stored_puppet_mesh(params).filter(|_| !b.is_empty())
&& let Some(out) = warp_with_mesh(surf, b, &mesh, &deformed, interp(params))
{
return Some(out);
}
Some(puppet_surface(surf, &puppet_params(id, params).ok()?, interp(params)).unwrap_or_else(|| surf.clone()))
}
PERSPECTIVE | PERSPECTIVE_SMART => Some(perspective_surface(surf, &perspective_params(id, params).ok()?, interp(params))),
_ => None,
}
@@ -473,3 +473,71 @@ fn unlocked_or_position_locked_smart_objects_take_distortions() {
}
}
}
#[test]
fn a_stored_puppet_mesh_is_drawn_while_its_pins_hold() {
// A Photoshop file's Puppet Warp carries its solved mesh: it is drawn as it is (here a whole
// move by (2, 1), although one pin alone would move the block by (6, 4)), until the pins
// change; then the pins are solved.
let surf = active_surface(&session(8));
let canvas = Rect::new(0, 0, 80, 60);
let pins = json!([{"src": [30, 25], "dst": [36, 29]}]);
let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]];
let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect();
let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]]});
let shifted = |dx, dy| photocraft_algo::resample::translate_surface(&surf, dx, dy);
let p = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh});
let out = apply_to_surface(PUPPET_SMART, &p, &surf, canvas).unwrap();
assert_eq!(out.content_bounds(), Rect::new(12, 11, 62, 41));
assert!(worst(&out, &shifted(2, 1), Rect::new(12, 11, 62, 41)) <= 1.0 / 255.0);
// Pins edited after import: the stored mesh no longer applies.
let mut edited = p.clone();
edited["mesh"]["pins"][0]["dst"] = json!([40, 29]);
let out = apply_to_surface(PUPPET_SMART, &edited, &surf, canvas).unwrap();
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44));
// So does a solver setting set after import (imported params set none).
for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] {
let mut q = p.clone();
q[k] = v;
let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap();
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44), "{k}");
}
// A malformed mesh is ignored the same way.
for bad in [
json!({"pins": pins, "verts": verts, "deformed": [[0, 0]], "tris": [[0, 1, 2]]}),
json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 9]]}),
] {
let mut q = p.clone();
q["mesh"] = bad;
let out = apply_to_surface(PUPPET_SMART, &q, &surf, canvas).unwrap();
assert_eq!(out.content_bounds(), Rect::new(16, 14, 66, 44));
}
}
#[test]
fn a_stored_puppet_mesh_is_drawn_only_on_the_placement_it_was_made_for() {
// The mesh sits in document pixels where the layer was on import (`mesh.placement`); once the
// smart object is moved, transformed or warped, its pins are solved instead.
let surf = active_surface(&session(8));
let canvas = Rect::new(0, 0, 80, 60);
let pins = json!([{"src": [30, 25], "dst": [36, 29]}]);
let verts = [[8.0, 8.0], [62.0, 8.0], [62.0, 42.0], [8.0, 42.0]];
let moved: Vec<[f64; 2]> = verts.iter().map(|v| [v[0] + 2.0, v[1] + 1.0]).collect();
let src = photocraft_doc::SmartSource::Linked { path: "x.png".into() };
let mut sm = photocraft_doc::SmartObject::new(src, photocraft_geom::Affine::IDENTITY, None);
let mesh = json!({"pins": pins, "verts": verts, "deformed": moved, "tris": [[0, 1, 2], [0, 2, 3]], "placement": sm.placement_json()});
let params = json!({"pins": pins, "interpolation": "bilinear", "mesh": mesh});
sm.smart_filters.push(SmartFilter { command: PUPPET_SMART.into(), params, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true });
let render = |sm: &photocraft_doc::SmartObject| crate::smart_cmds::apply_smart_filters(&surf, sm, canvas).unwrap().content_bounds();
assert_eq!(render(&sm), Rect::new(12, 11, 62, 41), "the stored mesh");
let mut placed = sm.clone();
placed.transform = photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 4.0, 0.0] };
assert_eq!(render(&placed), Rect::new(16, 14, 66, 44), "moved: solved");
let mut warped = sm.clone();
warped.warp = Some(photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Arc, 10.0, [0.0, 0.0, 50.0, 30.0]));
assert_eq!(render(&warped), Rect::new(16, 14, 66, 44), "warped: solved");
// A mesh with no placement recorded is treated the same way.
let mut bare = sm.clone();
bare.smart_filters[0].params["mesh"].as_object_mut().unwrap().remove("placement");
assert_eq!(render(&bare), Rect::new(16, 14, 66, 44), "no placement: solved");
}
+8 -4
View File
@@ -989,12 +989,16 @@ fn guide_position(s: &Session, p: &Value, vertical: bool) -> Result<Option<f64>>
let px = match (v.as_f64(), v.as_str()) {
(Some(n), _) => n,
(None, Some(text)) => {
let (n, unit) = crate::prefs::Unit::parse_length(text).ok_or_else(|| {
EngineError::Other(format!("`position` {text:?} is not a length: give a number of pixels, or a number with px, in, cm, mm, pt, pc or %"))
})?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let extent = if vertical { doc.size.width } else { doc.size.height };
unit.unwrap_or(crate::prefs::Unit::Pixels).to_px(n, doc.resolution_dpi as f64, extent as f64, s.prefs().units_and_rulers.point_size.per_inch())
let per_inch = s.prefs().units_and_rulers.point_size.per_inch();
// A length, or arithmetic on lengths as Photoshop's field takes ("100% / 3", #10440).
let to_px = |n: f64, unit: crate::prefs::Unit| unit.to_px(n, doc.resolution_dpi as f64, extent as f64, per_inch);
crate::prefs::Unit::eval_length(text, crate::prefs::Unit::Pixels, to_px).ok_or_else(|| {
EngineError::Other(format!(
"`position` {text:?} is not a length: give a number of pixels, a number with px, in, cm, mm, pt, pc or %, or arithmetic on them (+ - * /)"
))
})?
}
_ => return Err(EngineError::Other("`position` must be a number of pixels or text with a unit".into())),
};
+70
View File
@@ -1292,6 +1292,56 @@ fn guide_positions_take_photoshop_units() {
assert_eq!(s.active().unwrap().doc.guides.horizontal[0], 50.0);
}
/// #10440: the position takes arithmetic as Photoshop's New Guide field does. Measured in
/// Photoshop 2025 on a 600 × 400, 72 ppi document: "100% / 3" → 200, "100% - 50px" → 550,
/// "300 / 2" → 150 (pixels), "1in + 10" → 792 (the bare 10 is in the first operand's unit).
#[test]
fn guide_positions_take_arithmetic() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
for (text, px) in [
("100% / 3", 200.0),
("100% - 50px", 550.0),
("300 / 2", 150.0),
("1in + 10", 792.0),
("2 * 50 px", 100.0),
("10 + 2 * 3", 16.0),
("-50px + 100", 50.0),
] {
assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}");
}
// Horizontal guides take percent of the height.
assert!((new_guide(&mut s, "horizontal", json!("50% + 10px")) - 210.0).abs() < 1e-6);
for bad in ["100% /", "1in * 2in", "10 / 0", "10 / 2px", "5 ^ 2", "+", "50% 3"] {
let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": bad})).unwrap_err();
assert!(err.to_string().contains("not a length"), "{bad}: {err}");
}
}
/// The Unicode minus (U+2212, what some keyboards and pasted text give) is a minus, as in the
/// numeric fields' own evaluator.
#[test]
fn guide_position_arithmetic_takes_the_unicode_minus() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
for (text, px) in [("100% − 50px", 550.0), ("−50px + 100", 50.0), ("1in − −10", 792.0), ("−100", -100.0)] {
assert!((new_guide(&mut s, "vertical", json!(text)) - px).abs() < 1e-6, "{text}");
}
}
/// Text longer than the numeric fields' 1024-byte cap is refused before it is tokenised.
#[test]
fn guide_position_arithmetic_is_capped_at_1024_bytes() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 600, "height": 400, "resolution": 72})).unwrap();
let fits = format!("{}100", "0 + ".repeat(255));
assert_eq!(fits.len(), 1023);
assert_eq!(new_guide(&mut s, "vertical", json!(fits)), 100.0);
let long = format!("{}100", "0 + ".repeat(256));
let err = s.execute("view.newGuide", json!({"orientation": "vertical", "position": long})).unwrap_err();
assert!(err.to_string().contains("not a length"), "{err}");
}
#[test]
fn new_guides_keep_their_own_colour_through_moves_deletes_and_undo() {
let mut s = Session::new();
@@ -1381,6 +1431,26 @@ fn editing_a_guide_changes_what_is_given_in_one_undoable_step() {
assert_eq!(g.color(true, 200.0), Some([0xff, 0x00, 0xff]));
}
/// #10440: Edit Selected Guides reads its position as New Guide does, arithmetic included; % is of
/// the new orientation's extent.
#[test]
fn editing_a_guide_takes_arithmetic_in_its_position() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 400, "height": 200, "resolution": 100})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
s.execute("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap();
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "100% / 4"})).unwrap();
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 1, "position": "1in + 1"})).unwrap();
assert_eq!(s.active().unwrap().doc.guides.vertical, vec![100.0, 200.0]);
s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "newOrientation": "horizontal", "position": "50% − 10px"})).unwrap();
let g = &s.active().unwrap().doc.guides;
assert_eq!((g.vertical.clone(), g.horizontal.clone()), (vec![200.0], vec![90.0]));
let before = g.clone();
let err = s.execute("view.editGuide", json!({"orientation": "vertical", "index": 0, "position": "1in * 2in"})).unwrap_err();
assert!(err.to_string().contains("not a length"), "{err}");
assert_eq!(s.active().unwrap().doc.guides, before);
}
#[test]
fn bad_guide_edits_are_refused_without_a_history_step() {
let mut s = Session::new();
+1 -1
View File
@@ -313,7 +313,7 @@ pub use content_aware::{CafPlan, FillEdit, MAX_CAF_EDITS, MAX_CAF_POINTS, SelOp,
use content_aware::{can_caf, content_aware_fill, delete_and_fill};
pub use content_aware_scale::ContentAwareScalePreview;
use content_aware_scale::content_aware_scale;
pub use fade::fade_surface;
pub use fade::{FadePreview, fade_preview, fade_surface};
use fade::{can_fade, fade};
pub use find_replace::find_matches;
use find_replace::{find_replace, has_type_layer};
+54 -10
View File
@@ -1,5 +1,5 @@
use super::{
BlendMode, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of,
BlendMode, Document, EngineError, FadeTarget, Rect, Result, Session, Surface, Value, bad, blend_from_str, f32_or, from_rgba_into, json, str_or, surface_of,
surface_of_mut, to_rgba,
};
@@ -67,7 +67,18 @@ pub fn fade_surface(before: &Surface, after: &mut Surface, area: Rect, mode: Ble
Ok(())
}
pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
/// What Edit › Fade with `p` blends: the layer, mask or channel of the last step, its pixels
/// before it, the area to blend, the mode and the opacity.
struct FadeJob {
target: FadeTarget,
label: String,
before: Surface,
area: Rect,
mode: BlendMode,
opacity: f32,
}
fn fade_job(s: &Session, p: &Value) -> Result<FadeJob> {
let cmd = "edit.fade";
let src = s.edit_state.fade.clone().ok_or_else(|| bad(cmd, "nothing to fade"))?;
let opacity = f32_or(p, "opacity", 100.0).clamp(0.0, 100.0) / 100.0;
@@ -82,12 +93,45 @@ pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
// A uniform mask can change every pixel without storing a single tile. In that case
// the content bounds are empty, but Fade must still blend across the canvas.
let area = if before.default_pixel() != now.default_pixel() { st.doc.bounds() } else { before.content_bounds().union(&now.content_bounds()) };
let label = format!("Fade {}", src.label);
s.edit(&label, |doc, _| {
let surf = surface_of_mut(doc, src.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
fade_surface(&before, surf, area, mode, opacity)?;
surf.prune();
Ok(())
})?;
Ok(json!({"faded": src.label, "opacity": opacity * 100.0, "mode": mode.label()}))
Ok(FadeJob { target: src.target, label: src.label, before, area, mode, opacity })
}
fn apply_fade(doc: &mut Document, j: &FadeJob) -> Result<()> {
let surf = surface_of_mut(doc, j.target).ok_or_else(|| EngineError::Other("the faded layer or channel no longer exists".into()))?;
fade_surface(&j.before, surf, j.area, j.mode, j.opacity)?;
surf.prune();
Ok(())
}
pub(super) fn fade(s: &mut Session, p: &Value) -> Result<Value> {
let j = fade_job(s, p)?;
let label = format!("Fade {}", j.label);
s.edit(&label, |doc, _| apply_fade(doc, &j))?;
Ok(json!({"faded": j.label, "opacity": j.opacity * 100.0, "mode": j.mode.label()}))
}
/// Edit › Fade with given params, captured from the session without a history step: the Fade
/// dialog's live preview (#8230). [`FadePreview::render`] does the blend and can run off the
/// session (the preview worker). The document copy shares its tiles with the session's, and
/// only the tiles in the faded area are blended and rewritten.
pub struct FadePreview {
doc: Document,
job: FadeJob,
}
impl FadePreview {
/// The active document as Edit › Fade would leave it.
pub fn render(mut self) -> Result<Document> {
apply_fade(&mut self.doc, &self.job)?;
Ok(self.doc)
}
}
/// What Edit › Fade with `p` would do to the active document. `Err` when Fade isn't available
/// or `p` is invalid.
pub fn fade_preview(s: &Session, p: &Value) -> Result<FadePreview> {
can_fade(s).map_err(|e| bad("edit.fade", e))?;
let job = fade_job(s, p)?;
let doc = Document::clone(&s.active().ok_or(EngineError::NoDocument)?.doc);
Ok(FadePreview { doc, job })
}
@@ -23,6 +23,33 @@ fn active(s: &Session) -> &Layer {
st.doc.layer(st.active_layer.unwrap()).unwrap()
}
/// #8230: the Fade dialog's preview is the document Fade would leave, at every depth and in a
/// blend mode, with no history step and nothing changed until OK; with nothing to fade or a bad
/// mode it is an error, not a panic.
#[test]
fn fade_preview_matches_fade_without_a_step() {
for depth in [8, 16, 32] {
assert!(fade_preview(&session(depth), &json!({"opacity": 50})).is_err(), "nothing to fade yet");
for p in [json!({"opacity": 25}), json!({"opacity": 60, "mode": "difference"}), json!({})] {
let mut s = session(depth);
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
let (steps, revision) = (s.active().unwrap().history.past_len(), s.active().unwrap().revision);
let preview = fade_preview(&s, &p).and_then(FadePreview::render).unwrap();
assert_eq!((s.active().unwrap().history.past_len(), s.active().unwrap().revision), (steps, revision), "depth {depth} {p}: no step");
assert_eq!(px(&s, 5, 5)[0], 0.0, "depth {depth} {p}: the layer is untouched");
let id = s.active().unwrap().active_layer.unwrap();
let shown = preview.layer(id).unwrap().surface().unwrap().pixel(5, 5);
s.execute("edit.fade", p.clone()).unwrap();
assert_eq!(shown, px(&s, 5, 5), "depth {depth} {p}: the preview is what OK applies");
}
let mut s = session(depth);
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
assert!(fade_preview(&s, &json!({"mode": "nonsense"})).is_err());
s.execute("layer.new.layer", json!({})).unwrap();
assert!(fade_preview(&s, &json!({})).is_err(), "not right after the step");
}
}
#[test]
fn fade_blends_last_step_at_all_depths() {
for depth in [8, 16, 32] {
@@ -119,7 +146,11 @@ fn fade_an_alpha_channel_or_the_quick_mask_at_all_depths() {
assert!((channel(&s, 5, 5) - (1.0 - was)).abs() < 0.01, "depth {depth}, quick mask {quick_mask}: inverted");
let expected = was + (1.0 - 2.0 * was) * k;
let steps = s.active().unwrap().history.past_len();
// The Fade dialog's preview (#8230) shows the channel as OK leaves it.
let preview = fade_preview(&s, &json!({"opacity": opacity})).and_then(FadePreview::render).unwrap();
let shown = if quick_mask { preview.quick_mask.as_ref() } else { preview.channels.first() }.unwrap().surface.pixel(5, 5)[0];
s.execute("edit.fade", json!({"opacity": opacity})).unwrap();
assert_eq!(shown, channel(&s, 5, 5), "depth {depth}, quick mask {quick_mask}, {opacity} %: the preview is what OK applies");
for (x, y) in [(0, 0), (5, 5), (63, 47)] {
let v = channel(&s, x, y);
assert!((v - expected).abs() < 0.02, "depth {depth}, quick mask {quick_mask}, {opacity} %, ({x}, {y}): {v}");
+2 -2
View File
@@ -140,7 +140,7 @@ fn background_eraser_setup(s: &Session, p: &Value) -> Result<(Stroke, BgEraseSet
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")?,
tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0,
tolerance: number(p, CMD, "tolerance", 50.0, 1.0..=100.0)? as f32 / 100.0,
protect_foreground: flag(p, "protectForegroundColor", false),
foreground: s.tools.foreground,
background: s.tools.background,
@@ -267,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Background Eraser",
menu: &[],
shortcut: None,
params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##,
params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":1..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##,
enabled: has_pixel_layer,
run: background_eraser,
journal: true,
+26 -5
View File
@@ -76,12 +76,18 @@ pub(crate) fn step_key(s: &Session, what: &str) -> String {
/// Quick Mask or the layer mask (`Session::execute` fills that in from the Channels panel), that
/// grayscale surface is rewritten instead, as the core filters do (#935).
fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
map_pixels_in(s, label, p, false, f)
}
/// [`map_pixels`], or with `whole` the whole canvas: `f` still gets the selection coverage, and
/// every pixel takes its result in full.
fn map_pixels_in(s: &mut Session, label: &str, p: &Value, whole: bool, f: impl FnOnce(Rect, &mut [[f32; 4]], &[f32])) -> Result<Value> {
let id = layer_param(s, p)?;
let channel = crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels;
s.edit(label, |doc, _| {
let canvas = doc.bounds();
let selection = doc.selection.clone();
let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
let area = if whole { canvas } else { selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas)) };
let (surf, lock_alpha) = if channel {
let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, Some(id), p)? else { return Ok(()) };
(surf, false)
@@ -109,7 +115,7 @@ fn map_pixels(s: &mut Session, label: &str, p: &Value, f: impl FnOnce(Rect, &mut
f(area, &mut px, &cover);
let mut enc = [0.0f32; 8];
for (i, out) in raw.chunks_exact_mut(n).enumerate() {
let k = cover[i];
let k = if whole { 1.0 } else { cover[i] };
if k <= 0.0 {
continue;
}
@@ -230,9 +236,16 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Histogram equalization, like Photoshop's: one CDF over the R, G and B values of
/// the selected pixels, applied to every channel.
/// the selected pixels, applied to every channel. With a selection, `selectedAreaOnly: false`
/// applies the selection's mapping to the layer within the canvas (Photoshop's Equalize Entire
/// Image Based On Selected Area).
fn equalize(s: &mut Session, p: &Value) -> Result<Value> {
map_pixels(s, "Equalize", p, |_, px, cover| {
let whole = match p.get("selectedAreaOnly") {
None => false,
Some(Value::Bool(only)) => !only,
Some(_) => return Err(EngineError::BadParams { cmd: "image.adjustments.equalize".into(), msg: "selectedAreaOnly must be true or false".into() }),
};
map_pixels_in(s, "Equalize", p, whole, |_, px, cover| {
let mut hist = [0f64; 256];
for (p, &k) in px.iter().zip(cover) {
if k > 0.0 && p[3] > 0.0 {
@@ -861,7 +874,15 @@ pub fn specs() -> Vec<CommandSpec> {
})?;
Ok(json!({"selected": true}))
}),
spec!("image.adjustments.equalize", "Equalize", &["Image", "Adjustments"], None, "{}", has_pixels, equalize),
spec!(
"image.adjustments.equalize",
"Equalize",
&["Image", "Adjustments"],
None,
r#"{"selectedAreaOnly":bool=true (with a selection, false equalizes the layer within the canvas by the selection's histogram)}"#,
has_pixels,
equalize
),
spec!("image.revealAll", "Reveal All", &["Image"], None, "{}", has_doc, |s, _| {
let d = s.active().ok_or(EngineError::NoDocument)?;
let all = reveal_all_bounds(&d.doc);
+58
View File
@@ -489,6 +489,64 @@ fn equalize_spreads_values() {
}
}
/// Equalize with a selection: by default only the selected pixels change, as Photoshop's
/// Equalize Selected Area Only; `selectedAreaOnly: false` maps the layer within the canvas by the
/// selected pixels' histogram, as its Equalize Entire Image Based On Selected Area.
#[test]
fn equalize_entire_image_based_on_the_selected_area() {
let setup = |depth: u32| {
let mut s = session(depth);
paint_square(&mut s, Rect::new(0, 0, 30, 15), [0.4, 0.4, 0.4, 1.0]);
paint_square(&mut s, Rect::new(0, 15, 30, 30), [0.5, 0.5, 0.5, 1.0]);
// Darker than anything selected: the selection's mapping sends it to black.
paint_square(&mut s, Rect::new(30, 0, 40, 30), [0.3, 0.3, 0.3, 1.0]);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
s
};
for depth in [8, 16, 32] {
for params in [json!({}), json!({"selectedAreaOnly": true})] {
let mut s = setup(depth);
s.execute("image.adjustments.equalize", params.clone()).unwrap();
let inside = (pixel(&s, 1, 1)[0], pixel(&s, 1, 20)[0]);
assert!(inside.0 < 0.05 && inside.1 > 0.95, "{depth} {params}: inside {inside:?}");
let outside = [pixel(&s, 25, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]];
assert!(outside.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth} {params}: outside {outside:?}");
}
let mut s = setup(depth);
s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": false})).unwrap();
for (x, y, want_light) in [(1, 1, false), (1, 20, true), (25, 1, false), (25, 20, true), (35, 1, false), (35, 20, false)] {
let v = pixel(&s, x, y)[0];
assert!(if want_light { v > 0.95 } else { v < 0.05 }, "{depth}: ({x}, {y}) = {v}");
}
assert!(doc(&s).selection.is_some(), "{depth}: the selection stays");
s.undo();
let undone = [pixel(&s, 1, 1)[0], pixel(&s, 25, 20)[0], pixel(&s, 35, 1)[0]];
assert!(undone.iter().zip([0.4, 0.5, 0.3]).all(|(v, want)| (v - want).abs() < 0.01), "{depth}: one undo step {undone:?}");
}
}
/// Without a selection, `selectedAreaOnly: false` gives the same pixels as plain Equalize.
#[test]
fn equalize_entire_image_without_a_selection_is_plain_equalize() {
let run = |params: Value| {
let mut s = session(8);
paint_square(&mut s, Rect::new(0, 0, 40, 15), [0.4, 0.4, 0.4, 1.0]);
paint_square(&mut s, Rect::new(0, 15, 40, 30), [0.5, 0.5, 0.5, 1.0]);
s.execute("image.adjustments.equalize", params).unwrap();
active(&s).surface().unwrap().read_region(Rect::new(0, 0, 40, 30))
};
assert_eq!(run(json!({"selectedAreaOnly": false})), run(json!({})));
}
#[test]
fn equalize_refuses_a_selected_area_only_that_is_not_a_boolean() {
for bad in [json!(null), json!("false"), json!(0), json!([true]), json!({})] {
let mut s = session(8);
let e = s.execute("image.adjustments.equalize", json!({"selectedAreaOnly": bad})).unwrap_err().to_string();
assert!(e.contains("selectedAreaOnly"), "{bad}: {e}");
}
}
/// A selection masks Clouds but doesn't change their size: inside it they are the canvas's
/// clouds, as when Clouds sized itself by the document (before it became a filter).
#[test]
+39 -5
View File
@@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
}
}
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
/// Scale Effects, and previews that draw a style at a reduced resolution).
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style ›
/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %.
pub fn scale_effects(fx: &mut Effects, k: f32) {
scale_effects_with(fx, k, true);
}
/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales
/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a
/// quarter of the size is 0.5 %, not the dialog's 1 % minimum).
pub fn scale_effects_preview(fx: &mut Effects, k: f32) {
scale_effects_with(fx, k, false);
}
fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) {
let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp);
for e in &mut fx.items {
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
@@ -152,14 +164,17 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
Effect::BevelEmboss(b) => {
b.size *= k;
b.soften *= k;
if let Some(t) = &mut b.texture {
pattern(&mut t.scale);
}
}
Effect::PatternOverlay { scale, .. } => *scale *= k,
Effect::PatternOverlay { scale, .. } => pattern(scale),
Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {}
}
if let Effect::Stroke(s) = e
&& let FxPaint::Pattern { scale, .. } = &mut s.paint
{
*scale *= k;
pattern(scale);
}
}
// Effects changed: the preserved PSD block no longer applies.
@@ -168,6 +183,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
}
}
/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog
/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %).
fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) {
let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE;
let v = *scale * k;
if !v.is_nan() {
*scale = if clamp { v.clamp(min, max) } else { v };
}
}
/// Image Size's resampling methods; `None` changes the resolution only.
#[derive(Clone, Copy)]
enum Method {
@@ -755,6 +780,7 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
let unlock = !delete && !content_aware && !keep_background;
// Values beyond i32 would wrap through the narrowing casts into a rectangle unrelated to
// the numbers passed; reject them (see `commands::int_i32`).
let given = ["x", "y", "width", "height"].into_iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()));
let explicit = match (
crate::commands::int_i32("image.crop", p, "x")?,
crate::commands::int_i32("image.crop", p, "y")?,
@@ -775,6 +801,14 @@ fn crop_with(s: &mut Session, p: &Value, keep_background: bool) -> Result<Value>
};
Some(Rect::new(x, y, end(x, w, "x", "width")?, end(y, h, "y", "height")?))
}
// A rectangle that is passed but incomplete or empty is refused: falling back to the
// selection would crop somewhere the caller didn't ask for (#9279).
_ if given => {
return Err(bad(
"image.crop",
"`x`, `y`, `width` and `height` go together: pass all four as numbers of pixels, with `width` and `height` rounding to at least 1",
));
}
_ => None,
};
let target = crop_target(p)?;
@@ -1206,7 +1240,7 @@ pub fn specs() -> Vec<CommandSpec> {
"image.crop",
"Crop",
["Image"],
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
r##"{"x":px,"y":px,"width":px,"height":px,"angle":-3600..3600=0,"deleteCroppedPixels":bool=true,"contentAware":bool=false,"targetWidth":px,"targetHeight":px,"resolution":ppi,"resample":"bicubic|bilinear|nearest|lanczos|preserveDetails"="bicubic"} → {x,y,width,height,documentWidth,documentHeight,resolution} (x/y/width/height, all four or none: without them the selection's bounds are cropped to; targetWidth/targetHeight, both or neither: the cropped area is resampled to exactly that size, as Image Size, in the same undo step; resolution: the document's new ppi; deleteCroppedPixels false: the pixels past the frame are kept and, unless contentAware, the Background becomes a normal layer, "Layer 0", so an extension is transparent on every layer (with contentAware the Background stays); contentAware: the Crop tool's Fill › Content-Aware Fill uses surrounding pixels to fill areas uncovered by a rotated or extending crop. The fill targets the Background, or the active pixel layer if there is no Background. With Delete Cropped Pixels on, it also targets a separate active pixel layer if it is visible and unlocked.)"##,
has_doc,
crop
),
+95
View File
@@ -429,6 +429,101 @@ fn crop_to_explicit_rect_can_extend_canvas() {
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 5), vec![1.0, 0.0, 0.0, 1.0]);
}
/// A rectangle that is passed but invalid is refused, not replaced by the selection (#9279).
#[test]
fn crop_refuses_a_bad_explicit_rect_instead_of_cropping_to_the_selection() {
let bad = [
json!({"x": 10, "y": 10, "width": 0, "height": 50}),
json!({"x": 10, "y": 10, "width": 20, "height": 0}),
json!({"x": 10, "y": 10, "width": -50, "height": 50}),
json!({"x": 10, "y": 10, "width": "100", "height": 50}),
json!({"x": 10, "y": 10, "width": true, "height": 50}),
json!({"width": 20, "height": 10}),
json!({"x": 1, "y": 1}),
json!({"x": 1, "y": null, "width": 20, "height": 10}),
json!({"height": 10}),
];
for p in bad {
for selection in [true, false] {
let mut s = session();
if selection {
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
}
let (size, past) = (doc(&s).size, s.active().unwrap().history.past_len());
let err = s.execute("image.crop", p.clone()).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{p} (selection: {selection}): {err:?}");
assert_eq!(doc(&s).size, size, "{p} (selection: {selection})");
assert_eq!(doc(&s).selection.is_some(), selection, "{p}: the selection stays");
assert_eq!(s.active().unwrap().history.past_len(), past, "{p} (selection: {selection})");
}
}
// No rectangle at all (or every side null) still crops to the selection.
for p in [json!({}), json!({"x": null, "y": null, "width": null, "height": null})] {
let mut s = session();
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
s.execute("image.crop", p.clone()).unwrap();
assert_eq!(doc(&s).size, Size::new(20, 10), "{p}");
}
// Whole-number floats, as the Crop tool sends them, are a valid rectangle.
let mut s = session();
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
s.execute("image.crop", json!({"x": 1.0, "y": 1.0, "width": 30.0, "height": 15.0})).unwrap();
assert_eq!(doc(&s).size, Size::new(30, 15));
// Fractional pixels round, like the rectangle's `x`/`y` and every other pixel param
// (`commands::int`); a side that rounds to 0 is refused.
let mut s = session();
s.execute("image.crop", json!({"x": 0.4, "y": 0.6, "width": 20.6, "height": 9.5})).unwrap();
assert_eq!(doc(&s).size, Size::new(21, 10));
let err = s.execute("image.crop", json!({"x": 0, "y": 0, "width": 0.4, "height": 5})).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
assert_eq!(doc(&s).size, Size::new(21, 10));
}
/// The explicit rectangle of #9279's edge cases, each with a selection elsewhere that a fallback
/// would crop to: `0` is a given origin, numeric strings are not numbers (refused, as `x`/`y`
/// already were), and a rectangle partly past the canvas is used as passed, never clamped.
#[test]
fn crop_rect_edge_cases_never_fall_back_to_the_selection() {
let with_selection = || {
let mut s = session();
s.execute("select.rect", json!({"x": 5, "y": 2, "width": 20, "height": 10})).unwrap();
s
};
// `x`/`y` of 0 count as given: the red square (10, 5)..(20, 15) stays where it was, where a
// crop to the selection would have moved it to (5, 3).
let mut s = with_selection();
s.execute("image.crop", json!({"x": 0, "y": 0, "width": 15, "height": 10})).unwrap();
assert_eq!(doc(&s).size, Size::new(15, 10));
assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(10, 5, 15, 10));
// Numeric strings are refused on every side, with or without a selection.
for p in [
json!({"x": "0", "y": "0", "width": "15", "height": "10"}),
json!({"x": "0", "y": 0, "width": 15, "height": 10}),
json!({"x": 0, "y": 0, "width": 15, "height": "10"}),
] {
for mut s in [with_selection(), session()] {
let err = s.execute("image.crop", p.clone()).unwrap_err();
assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err:?}");
assert_eq!(doc(&s).size, Size::new(40, 20), "{p}");
}
}
// Partly outside the canvas: the canvas grows to the rectangle, not to its overlap with the
// canvas or the selection.
let mut s = with_selection();
s.execute("image.crop", json!({"x": -10, "y": -5, "width": 30, "height": 40})).unwrap();
assert_eq!(doc(&s).size, Size::new(30, 40));
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(20, 10), vec![1.0, 0.0, 0.0, 1.0]);
// Reveal All passes all four sides through the same check (here x = y = 0: the hidden pixels
// are past the right and bottom edges), so a selection doesn't change what it reveals.
let mut s = with_selection();
s.execute("image.canvasSize", json!({"width": 12, "height": 8, "anchor": "topLeft"})).unwrap();
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap();
assert_eq!(s.execute("image.revealAll", json!({})).unwrap()["changed"], true);
let size = doc(&s).size;
assert!(size.width >= 20 && size.height >= 15, "{size:?}: the red square is revealed");
assert_eq!(doc(&s).layers[1].surface().unwrap().pixel(19, 14), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn crop_extension_fills_the_background_with_the_background_colour() {
let mut shrink = Session::new();
@@ -140,6 +140,119 @@ fn global_light_blending_options_scale_effects() {
}
}
#[test]
fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() {
let mut s = session(8, "rgb");
paint(&mut s, disc);
s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap();
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap();
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
let fx = &active(&s).effects.items;
let overlay = fx.iter().find_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
_ => None,
});
let texture = fx.iter().find_map(|e| match e {
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
_ => None,
});
// Both patterns tile at half size, so a texture aligned with the overlay stays aligned.
assert_eq!((overlay, texture), (Some(0.5), Some(0.5)));
}
/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in
/// that order.
fn pattern_scales(s: &Session) -> (Option<f32>, Option<f32>, Option<f32>) {
let fx = &active(s).effects.items;
let overlay = fx.iter().find_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
_ => None,
});
let texture = fx.iter().find_map(|e| match e {
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
_ => None,
});
let stroke = fx.iter().find_map(|e| match e {
Effect::Stroke(st) => match &st.paint {
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
_ => None,
},
_ => None,
});
(overlay, texture, stroke)
}
/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %.
fn pattern_styled(pct: f64) -> Session {
let mut s = session(8, "rgb");
paint(&mut s, disc);
s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap();
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap();
s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap();
s.edit("pattern stroke", |doc, active| {
let layer = doc.layer_mut(active.unwrap()).unwrap();
for e in &mut layer.effects.items {
if let Effect::Stroke(st) = e {
st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 };
}
}
Ok(())
})
.unwrap();
s
}
#[test]
fn scale_effects_keeps_pattern_scales_within_the_dialog_range() {
// 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %.
let mut s = pattern_styled(10.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap();
assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01)));
// 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %.
let mut s = pattern_styled(500.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap();
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
// Inside the range nothing is clamped: 200 % halved is 100 %.
let mut s = pattern_styled(200.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0)));
}
/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only
/// commands that store the style clamp them.
#[test]
fn preview_scaling_leaves_pattern_scales_unclamped() {
let s = pattern_styled(2.0);
let mut fx = active(&s).effects.clone();
crate::image_cmds::scale_effects_preview(&mut fx, 0.25);
let mut clamped = active(&s).effects.clone();
crate::image_cmds::scale_effects(&mut clamped, 0.25);
let scales = |fx: &photocraft_doc::Effects| {
fx.items
.iter()
.filter_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
Effect::Stroke(st) => match &st.paint {
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(scales(&fx), vec![0.005; 3]);
assert_eq!(scales(&clamped), vec![0.01; 3]);
}
#[test]
fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() {
// 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %.
let mut s = pattern_styled(500.0);
s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap();
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
}
#[test]
fn global_light_rejects_numbers_beyond_f32() {
let mut s = session(8, "rgb");
+9 -1
View File
@@ -101,7 +101,15 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(Value::Null)
}),
spec!("select.findLayers", "Find Layers", &["Select"], Some("Cmd+Alt+Shift+F"), r##"{"name":str (case-insensitive substring)}"##, has_doc, find_layers),
spec!(
"select.findLayers",
"Find Layers",
&["Select"],
Some("Cmd+Alt+Shift+F"),
r##"{"name":str?} (filters the Layers panel by Name, a case-insensitive substring, selecting none of the matches; without `name` the panel's Name text is kept; replies with the matching layers)"##,
has_doc,
find_layers
),
spec!("layer.selectLinkedLayers", "Select Linked Layers", &["Layer"], None, "{}", has_linked, |s, _| select_linked(s)),
spec!("layer.align.topEdges", "Top Edges", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "topEdges")),
spec!("layer.align.verticalCenters", "Vertical Centers", &["Layer", "Align"], None, ALIGN, can_align, |s, p| align(s, p, "verticalCenters")),
@@ -336,16 +336,27 @@ pub(super) fn select_all_layers(s: &mut Session) -> Result<Value> {
Ok(json!({"selected": n}))
}
/// Select › Find Layers (#10884): as in Photoshop it filters the Layers panel by Name and leaves the
/// layer selection alone. The reply's `layerFilter` is the Name filter the app applies (`name` only
/// when one is given) and `selected` lists the layers it matches: part of the name, trimmed and
/// ignoring case, as the panel matches: every layer for a blank name, which the panel doesn't filter
/// by (none without a name).
pub(super) fn find_layers(s: &mut Session, p: &Value) -> Result<Value> {
let needle = p.get("name").and_then(Value::as_str).ok_or_else(|| bad("select.findLayers", "missing `name`"))?.to_lowercase();
let name = match p.get("name") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_str().ok_or_else(|| bad("select.findLayers", "`name` must be a string"))?),
};
let d = s.active().ok_or(EngineError::NoDocument)?;
let ids: Vec<LayerId> = d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(&needle)).map(|(_, _, l)| l.id).collect();
if ids.is_empty() {
return Err(EngineError::Other(format!("no layer name contains \"{needle}\"")));
let needle = name.map(|n| n.trim().to_lowercase());
let ids: Vec<u64> = match &needle {
Some(n) => d.doc.walk().into_iter().filter(|(_, _, l)| l.name.to_lowercase().contains(n.as_str())).map(|(_, _, l)| l.id.0).collect(),
None => Vec::new(),
};
let mut filter = json!({"by": "name"});
if let Some(n) = name {
filter["name"] = json!(n);
}
let out = ids.iter().map(|l| l.0).collect::<Vec<_>>();
set_selection(s, ids, None, None)?;
Ok(json!({"selected": out}))
Ok(json!({"selected": ids, "layerFilter": filter}))
}
pub(super) fn select_linked(s: &mut Session) -> Result<Value> {
@@ -586,9 +586,23 @@ fn find_layers_by_name() {
s.execute("layer.renameLayer", json!({"layer": a.0, "name": "Sky photo"})).unwrap();
s.execute("layer.renameLayer", json!({"layer": b.0, "name": "Photo of sea"})).unwrap();
assert_eq!(doc(&s).layer(a).unwrap().name, "Sky photo");
let r = s.execute("select.findLayers", json!({"name": "PHOTO"})).unwrap();
assert_eq!(r["selected"], json!([a.0, b.0]));
assert!(s.execute("select.findLayers", json!({"name": "nothing"})).is_err());
// #10884: Find Layers filters the Layers panel by Name and leaves the selection alone.
s.execute("layer.select", json!({"layer": a.0})).unwrap();
let r = s.execute("select.findLayers", json!({"name": " PHOTO "})).unwrap();
assert_eq!(r["selected"], json!([a.0, b.0]), "the matching layers, trimmed and ignoring case");
assert_eq!(r["layerFilter"], json!({"by": "name", "name": " PHOTO "}), "the app sets the Name text as given");
assert_eq!(s.active().unwrap().selected_layers(), [a], "the selection is unchanged");
let r = s.execute("select.findLayers", json!({"name": "nothing"})).unwrap();
assert_eq!(r["selected"], json!([]), "no match lists nothing and is no error");
let all: Vec<u64> = doc(&s).walk().into_iter().map(|(_, _, l)| l.id.0).collect();
for blank in ["", " "] {
let r = s.execute("select.findLayers", json!({"name": blank})).unwrap();
assert_eq!(r["selected"], json!(all), "a blank name filters nothing, as in the panel: {blank:?}");
}
let r = s.execute("select.findLayers", json!({})).unwrap();
assert_eq!((&r["selected"], &r["layerFilter"]), (&json!([]), &json!({"by": "name"})), "without a name it only switches to Name");
assert_eq!(s.active().unwrap().selected_layers(), [a]);
assert!(s.execute("select.findLayers", json!({"name": 3})).is_err());
assert!(s.execute("layer.renameLayer", json!({})).is_err());
}
+20 -7
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use photocraft_algo::magnetic::{self, Settings, Tracer};
use photocraft_algo::selection as sel;
use photocraft_doc::{DocId, Document, LayerId};
use photocraft_doc::{ColorMode, DocId, Document, LayerId};
use photocraft_geom::Rect;
use serde_json::{Value, json};
@@ -23,9 +23,12 @@ const MAX_POINTS: usize = 100_000;
/// Longest border one command traces (px, measured along the fastening points).
const MAX_LENGTH: f64 = 200_000.0;
/// The pixels the Magnetic Lasso follows: the active layer's when it is a pixel layer (Photoshop's
/// tool sees only the current layer), else the composite. A snapshot of one document revision,
/// read lazily and cached while a border is drawn.
/// The pixels the Magnetic Lasso follows: the visible composite of an RGB document, whichever layer
/// is active (hidden layers add no edges). Documents in other modes (CMYK, Grayscale, Indexed, ...)
/// keep the older rule: the active layer's own pixels when it is a pixel layer, else the composite.
/// A snapshot of one document revision, read lazily (only the tiles near the border) and cached by
/// the tracer while a border is drawn; [`EdgeSource::is_current`] drops it when the document changes,
/// so the composite is rendered once per revision and tile, not once per pointer event.
pub struct EdgeSource {
doc: Arc<Document>,
layer: Option<LayerId>,
@@ -33,8 +36,12 @@ pub struct EdgeSource {
tracer: Tracer,
}
/// The active layer when it has pixels of its own.
/// The one layer whose pixels the border follows, or `None` for the composite: always the composite
/// in an RGB document; in other modes the active layer when it has pixels of its own.
fn pixel_layer(d: &DocState) -> Option<LayerId> {
if d.doc.mode == ColorMode::Rgb {
return None;
}
d.active_layer.filter(|id| d.doc.layer(*id).is_some_and(|l| l.surface().is_some()))
}
@@ -45,7 +52,13 @@ impl EdgeSource {
Some(EdgeSource { doc: d.doc.clone(), layer: pixel_layer(d), revision: d.revision, tracer: Tracer::new(d.doc.bounds()) })
}
/// Whether this still shows the session's active document, layer and revision.
/// Whether this still shows the session's active document and revision (and, outside RGB
/// documents, active layer).
///
/// When this returns `false` the caller must drop the `EdgeSource` and build a new one with
/// [`EdgeSource::new`]: [`EdgeSource::snap`] and [`EdgeSource::trace`] keep reading the old
/// snapshot and its cached pixels otherwise. The Magnetic Lasso tool does so in `source()`
/// (`crates/ui-egui/src/tools/magnetic_lasso_ui.rs`) before every snap and trace.
pub fn is_current(&self, s: &Session) -> bool {
s.active().is_some_and(|d| d.doc.id == self.doc.id && d.revision == self.revision && pixel_layer(d) == self.layer)
}
@@ -70,7 +83,7 @@ impl EdgeSource {
}
}
/// Straight RGBA8 pixels of `r` from the layer, or the composite without one.
/// Straight RGBA8 pixels of `r` from the layer, or the visible composite without one.
fn read(doc: &Document, layer: Option<LayerId>, r: Rect) -> Vec<[u8; 4]> {
match layer.and_then(|id| doc.layer(id)).and_then(|l| l.surface()) {
Some(surface) => sel::rgba8_image(surface, r),
+4 -4
View File
@@ -65,16 +65,16 @@ fn modes_and_a_given_outline() {
}
#[test]
fn edges_come_from_the_active_pixel_layer() {
fn edges_come_from_the_visible_composite() {
let mut s = session();
s.execute("layer.new.layer", json!({})).unwrap();
// An empty layer has no edges: the point stays where it is.
// An empty layer on top adds no pixels: the edge of the Background's square still counts.
let settings = Settings::default();
let mut src = EdgeSource::new(&s).unwrap();
assert_eq!(src.snap([44.0, 50.0], settings), Some([44.0, 50.0]));
let p = src.snap([44.0, 50.0], settings).unwrap();
assert!((p[0] - 40.0).abs() < 0.01, "{p:?}");
let bg = s.active().unwrap().doc.layers[0].id;
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
assert!(!src.is_current(&s), "another layer");
let mut src = EdgeSource::new(&s).unwrap();
assert!(src.is_current(&s));
let p = src.snap([44.0, 50.0], settings).unwrap();
@@ -176,6 +176,9 @@ fn add_abr_presets(s: &mut Session, p: &Value, group: String, imp: photocraft_io
&& let Some(pr) = photocraft_paint::presets::find(&s.tools.presets, first)
{
let mut b = pr.brush.clone().picked_over(&s.tools.brush);
// Opacity takes 1-100 %, as `tools.setBrush` floors it: a preset saved at 0 % would show
// 1 % but paint nothing (#11812).
b.opacity = b.opacity.max(0.01);
s.load_brush_tips(&mut b).map_err(|e| bad("brush.presets.importAbr", e))?;
s.tools.brush = b;
s.tools.brush_changed();
@@ -477,6 +480,24 @@ mod tests {
.unwrap()
}
/// #11812: selecting an imported brush whose tool options hold 0 % opacity floors it at 1 %,
/// the value its options-bar field shows, so it paints instead of laying down nothing.
#[test]
fn selecting_an_imported_zero_opacity_brush_floors_it_at_one_percent() {
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value as D};
let preset = |opacity: i32| {
Descriptor::new("brushPreset")
.with("Nm ", D::Text(UnicodeString::new_nul("Faint")))
.with("toolOptions", D::Descriptor(Descriptor::new("currentToolOptions").with("Opct", D::Integer(opacity))))
};
for (opacity, expected) in [(0, 0.01), (37, 0.37)] {
let abr = photocraft_psd::abr::write_v6_folders(2, &[], &[], &[preset(opacity)], &[vec![]], true).unwrap();
let mut s = Session::new();
s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&abr), "select": true})).unwrap();
assert!((s.tools.brush.opacity - expected).abs() < 1e-6, "{opacity}: {}", s.tools.brush.opacity);
}
}
#[test]
fn import_adds_a_group_and_paints() {
let mut s = Session::new();
+9 -1
View File
@@ -193,7 +193,7 @@ pub(crate) use filters::{add_smart_filter, add_smart_filter_in, filter_mask_pain
use filters::{blending_options, delete_filter, edit_filters, move_filter, set_filter_params, set_filter_visible, smart, transfer_filters};
pub(crate) use placing::detach_psd;
pub(crate) use placing::{affine_homography, set_placement, shift_smart, transform_placement};
use placing::{convert, shift_layer, via_copy};
use placing::{convert, reset_transform, shift_layer, via_copy};
pub use placing::{layer_to_smart, layers_to_smart, placement};
#[cfg(test)]
pub(crate) use rendering::DISPLAY_NATIVE_HASH_BYTES;
@@ -239,6 +239,14 @@ pub fn specs() -> Vec<CommandSpec> {
has_smart,
edit_contents
),
spec!(
"layer.smartObjects.resetTransform",
"Reset Transform",
SO,
r##"{"layer":id?} (the placement returns to 100 % of the current contents' own size with rotation, skew, Distort, Perspective and the Warp cleared, centred on the average of the placement's four corners; the smart filters stay; refused with nothing changed when the object can't be re-rendered (a visible filter PhotoCraft doesn't have, or contents that can't be read); one undo step)"##,
has_smart,
reset_transform
),
spec!("layer.smartObjects.saveContents", "Save Contents", &[], "{} (in an Edit Contents document)", is_smart_child, |s, _| {
let i = s.active_index().ok_or(EngineError::NoDocument)?;
Ok(json!({"updated": commit_child(s, i)?}))
+70 -1
View File
@@ -1,8 +1,10 @@
use super::rendering::{refresh_or_fail, unapplied_filters};
use super::source::{SourceImage, buffer_to_surface, cache_key, cache_put, source_bytes};
use super::{
Affine, Arc, BlendMode, Document, EngineError, Homography, Layer, LayerContent, LayerId, PLACED_KEYS, Rect, Result, Session, Size, SmartContentsId,
SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, translate_surface,
SmartObject, SmartSource, Surface, Value, encode_source, json, layer_param, other, source_box, translate_surface,
};
use photocraft_algo::transform::Interp;
/// Rounds float noise off a composed transform (scale 0.1 then 10 should be exactly 1), so
/// repeated transforms land back on exact, lossless placements.
@@ -57,6 +59,73 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
}
}
/// Layer ▸ Smart Objects ▸ Reset Transform, and the same item of the Layers panel menu (#11662):
/// the placement goes back to 100 % of the current contents' own size with rotation, skew, Distort,
/// Perspective **and Warp** cleared, centred where the object sits. Measured in the reference
/// application 2026-11-06 (see the #11675 discussion): a smart object warped with Arc 50, rotated
/// and scaled came back with no warp at all, at its contents' own size, unrotated, and centred on
/// the average of its four corners. The smart filters stay.
///
/// The step needs the contents re-rendered from their source: a stack whose filters can't all be
/// applied, or a source that can't be read, refuses it whole rather than recording the old (still
/// warped) pixels under a placement that no longer describes them.
pub(super) fn reset_transform(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let st = s.active().ok_or(EngineError::NoDocument)?;
let LayerContent::Smart(sm) = &st.doc.layer(id).ok_or(EngineError::NoLayer(id))?.content else {
return Err(other("the layer is not a smart object"));
};
// Checked before anything changes, so a filter PhotoCraft doesn't have (kept verbatim from a
// PSD) leaves the document as it was, as `layer.smartObjects.updateModifiedContent` insists on
// a re-render too.
let stuck = unapplied_filters(sm);
if let Some(name) = stuck.first() {
return Err(other(format!("the {name} smart filter is not supported, so the smart object's transform can't be reset")));
}
// The contents' own box in source pixels: 100 % maps it one-to-one onto the document.
let [x0, y0, x1, y1] = source_box(&st.doc, sm).ok_or_else(|| other("the smart object's contents are unavailable"))?;
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let old = placement(sm);
// The object stays where it is: the average of the placement's four corners, which is its
// centre for an affine placement and the quad's centre once Distort or Perspective bent it.
let (mut sx, mut sy) = (0.0, 0.0);
for (u, v) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] {
let (px, py) = old.apply(u, v);
sx += px;
sy += py;
}
let (dx, dy) = (sx / 4.0 - cx, sy / 4.0 - cy);
let to = Affine { m: [1.0, 0.0, 0.0, 1.0, dx, dy] };
let new = affine_homography(&to);
// Everything the accumulated transform moved the pixels by, so the linked masks and the Smart
// Filter mask follow the placement back as they do under Free Transform.
let rel = new.mul(&old.inverse().ok_or_else(|| other("the smart object has a degenerate transform"))?);
if ![dx, dy, cx, cy].iter().all(|v| v.is_finite()) || !rel.0.iter().all(|v| v.is_finite()) {
return Err(other("the smart object's placement is not a usable transform"));
}
s.edit("Reset Transform", |doc, _| {
// The Warp is part of what the reference takes back, so the re-render below starts from the
// unwarped contents. How far a painted Smart Filter mask should follow a cleared warp is
// unmeasured: here it follows the placement, as it does under Free Transform.
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
sm.warp = None;
}
crate::transform_cmds::transform_by_map(doc, id, &rel, None, Interp::Bicubic)?;
// Required, not best-effort: `transform_by_map` re-renders too, but falls back to the old
// pixels when that isn't possible. `Session::edit` runs this on a copy, so an `Err` here
// records no step and leaves the document exactly as it was. The state is unchanged since
// that render, so the guarded refresh answers from the stack cache rather than placing the
// contents a second time.
refresh_or_fail(doc, id)?;
// `new ∘ old⁻¹ ∘ old` only lands on the reset map to within float noise, and could leave a
// Distort placement behind as a nearly affine one: store the exact map.
if let Some(LayerContent::Smart(sm)) = doc.layer_mut(id).map(|l| &mut l.content) {
set_placement(sm, new);
}
Ok(json!({"layer": id.0, "transform": to.m}))
})
}
/// Forget PSD placed-layer data that no longer describes the smart object (its source changed):
/// PSD export then writes fresh placed-layer blocks and embeds the new source.
pub(crate) fn detach_psd(l: &mut Layer) {
@@ -129,7 +129,7 @@ pub fn apply_smart_filters(placed: &Surface, sm: &SmartObject, canvas: Rect) ->
// Edge pixels repeat at the canvas ∪ placed-content edge, as for layer filters.
let extent = canvas.union(&placed.content_bounds());
for f in sm.smart_filters.iter().filter(|f| f.visible) {
cur = apply_smart_filter(f, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?;
cur = apply_smart_filter(f, sm, &cur, filter_bounds(f, placed, canvas), extent, &JobCtx::new())?;
}
Ok(match sm.filter_mask.as_ref().filter(|m| m.enabled) {
Some(m) => photocraft_compose::filter_mask::mix(placed, &cur, m),
@@ -155,8 +155,9 @@ fn renders_in_object(f: &SmartFilter) -> bool {
/// One visible smart filter over `cur`, blended with its blend mode and opacity. `Err` when the
/// requesting session's authority denies the filter's params: callers surface the error instead of
/// rendering (and caching) unfiltered pixels.
fn apply_smart_filter(f: &SmartFilter, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result<Surface> {
let out = match crate::filters::apply_filter_to_surface_in(&f.command, &f.params, cur, canvas, None, extent, ctx)? {
fn apply_smart_filter(f: &SmartFilter, sm: &SmartObject, cur: &Surface, canvas: Rect, extent: Rect, ctx: &JobCtx) -> Result<Surface> {
let params = crate::distort_cmds::placed_puppet_params(&f.command, &f.params, sm);
let out = match crate::filters::apply_filter_to_surface_in(&f.command, &params, cur, canvas, None, extent, ctx)? {
// Unknown ids (e.g. Photoshop filters we don't implement) leave the pixels alone.
Some(out) => out,
None => cur.clone(),
@@ -367,7 +368,7 @@ pub(super) fn render_in(doc: &Document, sm: &SmartObject, ctx: &JobCtx) -> Resul
Some(b) => b,
None => *object_bounds.insert(filter_bounds(f, &placed()?.surface, canvas)),
};
cur = Stage { surface: apply_smart_filter(f, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent };
cur = Stage { surface: apply_smart_filter(f, sm, &cur.surface, bounds, cur.extent, ctx)?, extent: cur.extent };
stack_cache::put(*key, &cur);
}
ctx.progress(1.0, "");
+188
View File
@@ -503,6 +503,193 @@ fn transforms_re_render_from_source_losslessly() {
assert!(active_smart(&smart).perspective.is_some());
}
/// The contents' box centre in document pixels under `sm`'s placement: what an affine placement is
/// centred on, and where the box centre of a projective one maps to.
fn placed_centre(s: &Session, sm: &SmartObject) -> (f64, f64) {
let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap();
placement(sm).apply((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0)
}
/// The average of the four corners of `sm`'s placement in document pixels: the point Reset
/// Transform keeps (measured in the reference application 2026-11-06, see the #11675 discussion).
/// It is the box centre under an affine placement, and the quad's centre under a projective one.
fn quad_centre(s: &Session, sm: &SmartObject) -> (f64, f64) {
let b = crate::smart_cmds::source_box(&s.active().unwrap().doc, sm).unwrap();
let h = placement(sm);
let (mut x, mut y) = (0.0, 0.0);
for (u, v) in [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])] {
let (px, py) = h.apply(u, v);
x += px;
y += py;
}
(x / 4.0, y / 4.0)
}
/// Layer ▸ Smart Objects ▸ Reset Transform (#11662): 100 % of the contents' own size, no rotation,
/// skew, Distort or Perspective, and the object stays centred where it is.
#[test]
fn reset_transform_restores_100_percent_in_place() {
let mut s = session(8);
paint(&mut s);
let id = convert(&mut s);
let fresh = active_smart(&s).cache.unwrap().content_bounds();
s.execute("edit.transform", json!({"layer": id, "scaleX": 200, "scaleY": 200, "rotate": 30})).unwrap();
s.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap();
let sm = active_smart(&s);
assert!(sm.transform.m[0].hypot(sm.transform.m[1]) > 1.5, "the object really is scaled up");
assert_ne!(sm.transform.m[1], 0.0, "…and rotated");
let centre = placed_centre(&s, &sm);
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
let after = active_smart(&s);
assert!(after.perspective.is_none(), "no projective placement left");
assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square, neither rotated nor skewed");
let now = placed_centre(&s, &after);
assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}");
let r = after.cache.unwrap().content_bounds();
assert_eq!((r.width(), r.height()), (fresh.width(), fresh.height()), "the contents at their own size");
// Re-rendered from the source rather than resampled from the rotated pixels: the reset object
// is exactly the converted layer moved by the same whole-pixel distance.
let mut control = session(8);
paint(&mut control);
convert(&mut control);
control.execute("layer.translate", json!({"dx": 7, "dy": 4})).unwrap();
let (got, want) = (flat(&s), flat(&control));
assert!(max_diff(&got, &want) < 1e-6, "lossless reset (mean {})", mean_diff(&got, &want));
// Already reset: the second run changes nothing.
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
assert_eq!(active_smart(&s).transform, after.transform, "idempotent");
}
/// Reset Transform takes the Warp back with the transform and keeps the smart filter stack:
/// measured in the reference application 2026-11-06 (an Arc 50 warp came back as no warp at all),
/// see the #11675 discussion.
#[test]
fn reset_transform_clears_the_warp_and_keeps_the_smart_filters() {
let mut s = session(8);
paint(&mut s);
let id = convert(&mut s);
s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
let before = active_smart(&s);
assert!(before.warp.is_some(), "the fixture is warped");
let r = before.cache.unwrap().content_bounds();
let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1));
s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": [[x0, y0], [x1, y0], [x1 - 12.0, y1 - 8.0], [x0, y1]]})).unwrap();
assert!(active_smart(&s).perspective.is_some(), "Distort keeps a projective placement");
let centre = quad_centre(&s, &active_smart(&s));
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
let after = active_smart(&s);
assert!(after.warp.is_none(), "the Warp goes with the transform, as the reference does");
assert_eq!(after.smart_filters.len(), 1, "the smart filter stack stays");
assert!(after.perspective.is_none(), "Distort is cleared");
assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0]);
let now = placed_centre(&s, &after);
assert!((now.0 - centre.0).abs() < 1e-6 && (now.1 - centre.1).abs() < 1e-6, "centred where it was: {now:?} vs {centre:?}");
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
let again = active_smart(&s);
assert_eq!(again.transform, after.transform, "idempotent");
assert!(again.warp.is_none(), "and still unwarpd");
}
/// The centre Reset Transform keeps is the average of the placement's four corners, not the point
/// its box centre maps to: they differ once Distort or Perspective bends the quad, and the
/// reference application keeps the corners' average (measured 2026-11-06, #11675 discussion).
#[test]
fn reset_transform_centres_a_distorted_object_on_its_corner_average() {
let mut s = session(8);
paint(&mut s);
let id = convert(&mut s);
let r = active_smart(&s).cache.unwrap().content_bounds();
let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1));
let quad = [[x0 - 10.0, y0 - 6.0], [x1 + 26.0, y0 - 2.0], [x1 + 4.0, y1 + 18.0], [x0 - 16.0, y1 + 8.0]];
s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap();
let sm = active_smart(&s);
assert!(sm.perspective.is_some(), "a projective placement");
let corners = quad_centre(&s, &sm);
let middle = placed_centre(&s, &sm);
assert!((corners.0 - middle.0).abs() + (corners.1 - middle.1).abs() > 1.0, "the fixture splits the two centres: {corners:?} vs {middle:?}");
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
let after = active_smart(&s);
assert_eq!(&after.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "100 %, square");
assert!(after.perspective.is_none() && after.warp.is_none(), "no projective placement or warp left");
let now = placed_centre(&s, &after);
assert!((now.0 - corners.0).abs() < 1e-6 && (now.1 - corners.1).abs() < 1e-6, "centred on the corners: {now:?} vs {corners:?}");
assert!((now.0 - middle.0).abs() + (now.1 - middle.1).abs() > 1.0, "not on the point the box centre mapped to");
}
/// A stack holding a filter this build can't apply can't be re-rendered without the warp, so Reset
/// Transform refuses the whole step instead of recording the old warped pixels at the new
/// placement: no history state, and placement, warp and stack left exactly as they were.
#[test]
fn reset_transform_refuses_a_stack_it_cannot_re_render() {
let mut s = session(8);
paint(&mut s);
convert(&mut s);
s.execute("edit.transform.warp", json!({"style": "arc", "bend": 30})).unwrap();
s.execute("edit.transform", json!({"scaleX": 160, "scaleY": 160})).unwrap();
// A smart filter whose command this build doesn't have, as an imported PSD leaves it.
s.edit("unsupported filter", |doc, active| {
let LayerContent::Smart(sm) = &mut doc.layer_mut(active.unwrap()).unwrap().content else { panic!("a smart object") };
sm.smart_filters.push(SmartFilter {
command: UNSUPPORTED_FILTER.into(),
params: json!({"name": "Camera Raw Filter"}),
blend: BlendMode::Normal,
opacity: 1.0,
visible: true,
});
Ok(())
})
.unwrap();
let before = active_smart(&s);
assert!(!super::rendering::unapplied_filters(&before).is_empty(), "the fixture's filter can't be applied");
let states = s.active().unwrap().history.entries().len();
let err = s.execute("layer.smartObjects.resetTransform", json!({})).unwrap_err().to_string();
assert!(err.contains("Camera Raw Filter"), "the error names the filter that blocks it: {err}");
let after = active_smart(&s);
assert_eq!(after.transform, before.transform, "the placement is left alone");
assert_eq!(after.warp, before.warp, "the warp is still there");
assert_eq!(after.smart_filters, before.smart_filters, "so is the stack");
assert_eq!(s.active().unwrap().history.entries().len(), states, "a refused step records no history state");
// Only this step is refused: with the stack gone the same object resets normally.
s.execute("layer.smartFilter.clearSmartFilters", json!({})).unwrap();
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
let cleared = active_smart(&s);
assert_eq!(&cleared.transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "then the reset goes through");
assert!(cleared.warp.is_none(), "with the warp");
}
/// Enabled for a smart object only, and one undo step back (#11662).
#[test]
fn reset_transform_needs_a_smart_object_and_undoes_in_one_step() {
let mut empty = Session::new();
assert!(!empty.is_enabled("layer.smartObjects.resetTransform"), "no document");
assert!(empty.execute("layer.smartObjects.resetTransform", json!({})).is_err());
let mut s = session(8);
paint(&mut s);
let plain = s.active().unwrap().active_layer.unwrap().0;
assert!(!s.is_enabled("layer.smartObjects.resetTransform"), "a pixel layer has no placement to reset");
assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": plain})).is_err());
let id = convert(&mut s);
assert!(s.is_enabled("layer.smartObjects.resetTransform"));
s.execute("edit.transform", json!({"layer": id, "matrix": [2, 0, 0, 2, 0, 0]})).unwrap();
let scaled = active_smart(&s);
s.execute("layer.smartObjects.resetTransform", json!({})).unwrap();
assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0]);
s.execute("edit.undo", json!({})).unwrap();
let back = active_smart(&s);
assert_eq!(back.transform, scaled.transform, "one history step back to the scaled placement");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(&active_smart(&s).transform.m[..4], &[1.0, 0.0, 0.0, 1.0], "…and forward again");
// Hostile targets are refused, never acted on.
assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": u64::MAX})).is_err());
assert!(s.execute("layer.smartObjects.resetTransform", json!({"layer": "nope"})).is_err());
// A locked layer can't be moved.
s.execute("layer.lockLayers", json!({"layer": id, "position": true})).unwrap();
let locked = active_smart(&s).transform;
assert!(s.execute("layer.smartObjects.resetTransform", json!({})).is_err());
assert_eq!(active_smart(&s).transform, locked, "the placement is left alone");
}
#[test]
fn filters_follow_transforms() {
let mut s = session(16);
@@ -712,6 +899,7 @@ fn commands_are_registered_with_catalog_ids() {
"layer.smartObjects.newSmartObjectViaCopy",
"layer.smartObjects.rasterize",
"layer.smartObjects.editContents",
"layer.smartObjects.resetTransform",
"layer.smartObjects.replaceContents",
"layer.smartObjects.exportContents",
"layer.smartObjects.updateModifiedContent",
+9 -4
View File
@@ -634,18 +634,23 @@ pub fn split_selected(surf: &Surface, sel: &Surface) -> Result<(Surface, Surface
/// masks and Smart Filter mask follow, and type, shapes and smart objects re-render. The document
/// selection is left alone.
pub(crate) fn transform_by(doc: &mut Document, id: LayerId, affine: Affine, interp: Interp) -> Result<()> {
let h = crate::smart_cmds::affine_homography(&affine);
transform_by_map(doc, id, &crate::smart_cmds::affine_homography(&affine), Some(affine), interp)
}
/// [`transform_by`] with the map already built, and `affine` only when it is affine (Distort and
/// Perspective results aren't: their smart object keeps the full projective placement).
pub(crate) fn transform_by_map(doc: &mut Document, id: LayerId, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
let group = group_locks(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
// Price the smart object caches before they are taken: the fallback warp below isn't budgeted.
check_layer_budget(None, l, &h)?;
check_layer_budget(None, l, h)?;
let taken = crate::smart_cmds::take_caches(std::slice::from_mut(l));
apply_transform(None, group, l, &h, Some(affine), interp, &Interrupt::NONE)?;
apply_transform(None, group, l, h, affine, interp, &Interrupt::NONE)?;
let snapshot = doc.clone();
if let Some(l) = doc.layer_mut(id) {
refresh_text(&snapshot, l);
}
crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), &h, interp))?;
crate::smart_cmds::restore_unrendered(doc, taken, |c| warp_surface(c, c.content_bounds(), h, interp))?;
Ok(())
}
+11
View File
@@ -230,6 +230,17 @@ pub fn specs() -> Vec<CommandSpec> {
run: |s, p| path_to_selection_with_mode(s, p, "subtract"),
journal: true,
},
// ⇧⌥⌘↩ / Ctrl+Alt+Shift+Enter intersects the path with the selection (#7726).
CommandSpec {
id: "path.intersectWithSelection",
label: "Intersect Path with Selection",
menu: &[],
shortcut: Some("Cmd+Alt+Shift+Enter"),
params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "intersect")"##,
enabled: has_doc,
run: |s, p| path_to_selection_with_mode(s, p, "intersect"),
journal: true,
},
spec!(
"path.toShape",
"Make Shape from Path",
+2 -1
View File
@@ -371,7 +371,8 @@ pub(super) fn path_to_selection(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "selected": selected }))
}
/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts);
/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts,
/// ⇧⌥⌘↩ intersects);
/// a `mode` in the params is overridden, the rest is forwarded and validated as usual.
pub(super) fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
let mut q = match p {
@@ -568,6 +568,26 @@ fn open_pen_paths_make_the_same_selection_as_closed_paths() {
}
}
/// ⇧⌘↩, ⌥⌘↩ and ⇧⌥⌘↩'s commands are Make Selection from Path with their mode (#7726), whatever
/// `mode` the params ask for.
#[test]
fn the_fixed_mode_path_commands_combine_as_their_mode() {
let mut s = session(80, 80, 8);
let square = |x: f64| path_json(&vector::shapes::rect(x, 20.0, 30.0, 30.0));
s.execute("path.set", json!({"name": "Sq", "path": square(30.0)})).unwrap();
for (id, mode) in [("path.addToSelection", "add"), ("path.subtractFromSelection", "subtract"), ("path.intersectWithSelection", "intersect")] {
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap();
s.execute("path.toSelection", json!({"name": "Sq", "mode": mode, "antiAlias": false})).unwrap();
let want = selection_mask(&s);
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 30, "antiAlias": false})).unwrap();
s.execute(id, json!({"name": "Sq", "mode": "replace", "antiAlias": false})).unwrap();
assert_eq!(selection_mask(&s), want, "{id} is mode {mode}");
}
// Intersecting keeps only the 10 px both squares cover.
let mask = selection_mask(&s);
assert_eq!(mask.iter().filter(|v| **v > 0.5).count(), 10 * 30);
}
#[test]
fn selection_to_work_path_roundtrip_iou() {
let mut s = session(240, 180, 8);
+1 -1
View File
@@ -200,7 +200,7 @@ pub struct ToolState {
impl ToolState {
/// How many CMYK picks are remembered.
const CMYK_PICKS: usize = 8;
pub const CMYK_PICKS: usize = 8;
/// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1).
pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) {
+11 -4
View File
@@ -507,9 +507,9 @@ struct Running {
label: String,
params: Value,
journal: bool,
/// The recording (`ActionState::take`) running when the job started, if any (set by
/// `dispatch`, once recording resumes). Only that recording gets the job's step: one started
/// later must not pick up an edit made before it.
/// The take active at dispatch, including strokes started with tool recording off.
started_in_take: Option<u64>,
/// The take eligible to receive this job's step under the mode at dispatch.
recorded_by: Option<u64>,
pre_selected: Option<Vec<LayerId>>,
before: crate::actions_record::Before,
@@ -863,6 +863,7 @@ fn new_job<T: Send + 'static>(
label: label.to_string(),
params: Value::Null,
journal: true,
started_in_take: None,
recorded_by: None,
pre_selected: None,
before: crate::actions_record::Before::capture(s),
@@ -1031,6 +1032,11 @@ pub struct PendingPsdPreflight {
}
impl Session {
/// A recording mode change must not reclassify an in-flight stroke from this take.
pub(crate) fn tool_stroke_job_running(&self) -> bool {
self.jobs.running.iter().any(|job| job.started_in_take == Some(self.actions.take) && crate::actions_cmds::tool_stroke(&job.command))
}
/// Run the heavy part of inline (not background) job commands under `ctx`, so another thread
/// can cancel them: a live preview whose parameters changed stops its stale computation
/// (`Err(Cancelled)`, the document unchanged) instead of finishing it. `None` restores the
@@ -1814,7 +1820,8 @@ impl Session {
job.command = id.to_string();
job.params = params;
job.journal = spec.journal;
job.recorded_by = self.actions.recording.map(|_| self.actions.take);
job.started_in_take = self.actions.recording.map(|_| self.actions.take);
job.recorded_by = job.started_in_take.filter(|_| self.actions.record_tools || !crate::actions_cmds::tool_stroke(id));
job.pre_selected = pre_selected.clone();
job.before = std::mem::take(&mut record_before);
let jid = job.id;
+3 -2
View File
@@ -2,6 +2,7 @@
use photocraft_color::{BlendMode, Color};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::effects::PATTERN_SCALE_RANGE;
use photocraft_doc::{
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx,
StrokePosition,
@@ -804,7 +805,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) {
*id = String::new();
}
if let Some(x) = f_of(p, "scale") {
*scale = (x / 100.0).clamp(0.01, 10.0);
*scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1);
}
if let Some(x) = f_of(p, "angle") {
*angle = x;
@@ -912,7 +913,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) {
bv.texture = Some(photocraft_doc::BevelTexture {
name: t.to_string(),
id: t.to_string(),
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1),
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
invert: b(p, "textureInvert", false),
link: b(p, "textureLink", true),
+75
View File
@@ -188,6 +188,78 @@ impl Unit {
let unit = if suffix.is_empty() { None } else { Some(Unit::from_suffix(suffix)?) };
Some((v, unit))
}
/// A length typed as Photoshop's numeric fields take arithmetic (#10440): lengths and numbers
/// joined by `+`, `-`, `*` and `/`, e.g. "100% / 3", "100% - 50px", "1in + 10", "2 * 50 px".
/// `*` and `/` bind tighter, and `−` (U+2212) is a minus. A number without a unit added or
/// subtracted takes the first operand's unit (Photoshop: "1in + 10" is 11 in), or `default`
/// when the first has none; one multiplied or divided is a plain number. `to_px` converts a
/// value in a unit. `None` when the text is over 1024 bytes (the numeric fields' cap) or isn't
/// such an expression, multiplies two lengths, divides by a length or by zero, or the result
/// isn't finite.
pub fn eval_length(text: &str, default: Unit, to_px: impl Fn(f64, Unit) -> f64) -> Option<f64> {
if text.len() > 1024 {
return None;
}
let text = text.replace('−', "-");
// Tokens: operators, and operands (a number with an optional unit suffix).
let mut ops = Vec::new();
let mut operands: Vec<(f64, Option<Unit>)> = Vec::new();
let mut rest = text.trim();
let mut want_operand = true;
while !rest.is_empty() {
if want_operand {
let num_end = rest
.char_indices()
.find(|&(i, c)| !(c.is_ascii_digit() || matches!(c, '.' | ',') || (i == 0 && matches!(c, '-' | '+'))))
.map_or(rest.len(), |(i, _)| i);
let after = &rest[num_end..];
let unit_end = after.find(['+', '-', '*', '/']).unwrap_or(after.len());
let (v, unit) = Unit::parse_length(&rest[..num_end + unit_end])?;
operands.push((v, unit));
rest = after[unit_end..].trim_start();
want_operand = false;
} else {
let op = rest.chars().next()?;
if !matches!(op, '+' | '-' | '*' | '/') {
return None;
}
ops.push(op);
rest = rest[1..].trim_start();
want_operand = true;
}
}
if want_operand || operands.is_empty() {
return None;
}
let first_unit = operands[0].1.unwrap_or(default);
// Fold the products and quotients into terms: (sum of px for unit factors, scalar, has a unit).
let mut terms: Vec<(char, f64, Option<Unit>)> = Vec::new();
let (mut sign, mut value, mut unit) = ('+', operands[0].0, operands[0].1);
for (op, (v, u)) in ops.iter().zip(operands.iter().skip(1)) {
match op {
'*' => {
if unit.is_some() && u.is_some() {
return None;
}
value *= v;
unit = unit.or(*u);
}
'/' => {
if u.is_some() || *v == 0.0 {
return None;
}
value /= v;
}
_ => {
terms.push((sign, value, unit));
(sign, value, unit) = (*op, *v, *u);
}
}
}
terms.push((sign, value, unit));
let px: f64 = terms.iter().map(|&(s, v, u)| if s == '-' { -1.0 } else { 1.0 } * to_px(v, u.unwrap_or(first_unit))).sum();
px.is_finite().then_some(px)
}
/// The unit a typed suffix names, in any case: `px`, `in` or `"`, `cm`, `mm`, `pt`, `pc` or
/// `pica`, `%`, and their long forms (`pixels`, `inches`, `centimetres`…). The one table every
/// length field reads.
@@ -1301,6 +1373,9 @@ pub struct Preferences {
/// Last selected tool, restored at launch. A tool-name string owned and validated by the
/// shell; null in older files. Kept separate from tool options so neither overwrites the other.
pub selected_tool: Value,
/// Foreground/background swatches for pixels and masks and exact CMYK picks, restored by the shell at launch.
/// Null in older files, which retain the default black/white pixel colours.
pub tool_colors: Value,
/// The tools used last, most recent first, restored at launch so each toolbar slot shows the
/// tool it was left on and each tool key brings it back, as in Photoshop (#3569). Tool-name
/// strings owned and validated by the shell; null in older files.
+55
View File
@@ -612,6 +612,61 @@ fn tool_presets_save_filter_select() {
assert!(s.execute("tool.presets.new", json!({"name": "x"})).is_err());
}
#[test]
fn tool_presets_reject_invalid_colors_before_applying_the_brush() {
let mut invalid = vec![
json!(null),
json!(true),
json!(7),
json!("red"),
json!({}),
json!([]),
json!([1]),
json!([1, 0]),
json!([1, 0, 0, 1, 0]),
json!([null, 0.4, 0.6]),
];
for channel in 0..4 {
for bad_channel in [json!(1e100), json!(-1e100), json!(null), json!("red"), json!(true), json!({}), json!([])] {
let mut values = json!([0.2, 0.4, 0.6, 0.8]);
values[channel] = bad_channel;
invalid.push(values);
}
}
for values in invalid {
let mut s = Session::new();
s.execute("tools.setColors", json!({"foreground": [0.2, 0.4, 0.6, 0.8]})).unwrap();
let color = s.tools.foreground;
let brush = s.tools.brush.clone();
let brush_rev = s.tools.brush_rev;
s.execute(
"tool.presets.new",
json!({
"name": "Invalid", "tool": "brush", "options": {"brush": {"size": 77}, "foreground": values}
}),
)
.unwrap();
assert!(s.execute("tool.presets.select", json!({"preset": "Invalid"})).is_err(), "{values}");
assert_eq!(s.tools.foreground, color, "{values}");
assert_eq!(s.tools.brush, brush, "{values}");
assert_eq!(s.tools.brush_rev, brush_rev, "{values}");
}
}
#[test]
fn tool_presets_keep_finite_hdr_colors_and_leave_absent_colors_unchanged() {
let mut s = Session::new();
for (options, expected) in [
(json!({"foreground": [1.5, -0.1, 0.6, 0.8]}), [1.5, -0.1, 0.6, 0.8]),
(json!({"foreground": [0.2, 0.4, 0.6]}), [0.2, 0.4, 0.6, 1.0]),
(json!({}), [0.2, 0.4, 0.6, 1.0]),
] {
let preset = s.execute("tool.presets.new", json!({"tool": "move", "options": options})).unwrap();
s.execute("tool.presets.select", json!({"preset": preset["name"]})).unwrap();
assert_eq!(s.tools.foreground, expected);
}
}
/// An Art History Brush preset captures the brush's opacity and selecting it restores it.
#[test]
fn art_history_brush_preset_carries_the_opacity() {
+12 -4
View File
@@ -158,13 +158,21 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
.find(|t| t.name == name)
.cloned()
.ok_or_else(|| bad(CMD, format!("no tool preset \"{name}\" (see tool.presets.list)")))?;
// Validate before applying the brush too: an invalid colour must leave the preset unapplied.
let foreground = match t.options.get("foreground") {
None => s.tools.foreground,
Some(value) => {
let valid = value.as_array().is_some_and(|channels| matches!(channels.len(), 3 | 4) && channels.iter().all(Value::is_number));
if !valid {
return Err(bad(CMD, "`foreground` must be [r, g, b] or [r, g, b, a] numbers"));
}
crate::commands::color_param(CMD, &t.options, "foreground", s.tools.foreground)?
}
};
if let Some(b) = t.options.get("brush").filter(|b| b.is_object()) {
super::call(s, "tools.setBrush", json!({"brush": b}))?;
}
if let Some(c) = t.options.get("foreground").and_then(Value::as_array).filter(|a| a.len() >= 3) {
let v: Vec<f32> = c.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect();
s.tools.foreground = [v[0], v[1], v[2], v.get(3).copied().unwrap_or(1.0)];
}
s.tools.foreground = foreground;
Ok(json!({"name": t.name, "tool": t.tool, "options": t.options}))
}
+1
View File
@@ -169,6 +169,7 @@ pub const TOOLS: &[(&str, &str, Option<&str>)] = &[
("tools.select.ellipseShape", "Ellipse Tool", Some("U")),
("tools.select.triangle", "Triangle Tool", Some("U")),
("tools.select.polygon", "Polygon Tool", Some("U")),
("tools.select.star", "Star Tool", Some("U")),
("tools.select.line", "Line Tool", Some("U")),
("tools.select.customShape", "Custom Shape Tool", Some("U")),
("tools.select.slice", "Slice Tool", Some("C")),
@@ -125,7 +125,7 @@ fn native_default_is_written_explicitly_and_keeps_its_behavior() {
}
}
/// Unsupported flags and empty steps remain complete descriptors instead of becoming resizes.
/// Only an absent descriptor is menu intent. Empty or invalid descriptors remain kept steps.
#[test]
fn malformed_or_empty_steps_are_kept_without_becoming_resizes() {
for event in ["ImgS", "imageSize"] {
@@ -143,12 +143,20 @@ fn malformed_or_empty_steps_are_kept_without_becoming_resizes() {
let original = atn::parse(&source).unwrap();
let mut s = Session::new();
let loaded = load(&mut s, &source);
assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1);
assert_eq!(s.actions.list[0].steps.len(), 1);
assert_eq!(s.actions.list[0].steps[0].0, atn_cmds::PHOTOSHOP_STEP);
let (step, params) = &s.actions.list[0].steps[0];
if original.actions[0].items[0].descriptor.is_none() {
assert_eq!(loaded["unsupported"], json!([]));
assert_eq!(step, atn_cmds::MENU_ITEM);
assert_eq!(params["id"], "image.imageSize");
assert_eq!(params["eventKind"], if event == "ImgS" { "code" } else { "name" });
} else {
assert_eq!(loaded["unsupported"].as_array().unwrap().len(), 1);
assert_eq!(step, atn_cmds::PHOTOSHOP_STEP);
}
let saved = atn::parse(&save(&mut s)).unwrap();
assert_eq!(saved.actions[0].items[0].descriptor, original.actions[0].items[0].descriptor);
assert_eq!(saved.actions[0].items[0].event, original.actions[0].items[0].event);
assert_eq!(saved.actions[0].items[0].dictionary, original.actions[0].items[0].dictionary);
}
}
}
+122
View File
@@ -0,0 +1,122 @@
//! Duplicate keeps action identities, complete settings and per-step flags stable.
use photocraft_engine::{Session, actions_cmds::Action};
use serde_json::{Value, json};
/// A tiny document to distinguish definition edits from document mutations and playback.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
s
}
#[test]
fn action_copies_follow_the_source_without_retargeting_calls_or_function_keys() {
let mut s = session();
let mut source = Action::new("Source", vec![("actions.play".into(), json!({"action": 1})), ("layer.select".into(), json!({"layer": "Photo"}))]);
source.disabled.insert(1);
source.dialogs.insert(0);
source.set = Some("Retouch".into());
source.color = "red".into();
let foreign = json!({"actions": [{"name": "Foreign", "steps": [["actions.play", {"action": 1}]]}]});
s.actions.list = vec![
source.clone(),
Action::new("Target", vec![("select.all".into(), json!({}))]),
Action::new(
"Caller",
vec![
("actions.play".into(), json!({"action": 1})),
("actions.play".into(), json!({"action": 3})), // Unresolved before and after insertion.
("actions.play".into(), json!({"action": 1.0})),
("file.automate.batch".into(), foreign.clone()),
("actions.conditional".into(), json!({"if": "always", "then": "Target", "else": "Source"})),
],
),
];
s.prefs.edit(|p| {
p.shortcuts.insert("actions.play:Source".into(), "F6".into());
});
let revision = s.active().unwrap().revision;
let result = s.execute("actions.duplicate", json!({"action": "Source"})).unwrap();
assert_eq!(result["index"], 1);
assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Source", "Source copy", "Target", "Caller"]);
let mut expected = source;
expected.name = "Source copy".into();
expected.steps[0].1["action"] = json!(2);
assert_eq!(s.actions.list[1], expected);
assert_eq!(s.actions.list[0].steps[0].1["action"], 2);
let caller = &s.actions.list[3];
assert_eq!(caller.steps[0].1["action"], 2);
assert_eq!(caller.steps[1].1["action"], 4);
assert_eq!(caller.steps[2].1["action"], 2.0);
assert_eq!(caller.steps[3].1, foreign, "embedded libraries keep their own indices");
assert_eq!(caller.steps[4].1, json!({"if": "always", "then": "Target", "else": "Source"}));
assert_eq!(s.prefs().shortcuts.get("actions.play:Source").map(String::as_str), Some("F6"));
assert!(!s.prefs().shortcuts.contains_key("actions.play:Source copy"));
assert_eq!(s.active().unwrap().revision, revision);
let played = s.execute("actions.play", json!({"action": "Source copy"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert!(s.active().unwrap().doc.selection.is_some(), "the copied call still plays Target rather than itself");
s.execute("actions.duplicate", json!({"action": 0})).unwrap();
assert_eq!(s.actions.list[1].name, "Source copy 2");
assert_eq!(s.actions.list[2].name, "Source copy");
assert_eq!(s.actions.list[1].steps[0].1["action"], 3);
}
#[test]
fn step_copies_preserve_payloads_and_shift_both_flag_columns() {
let mut s = session();
let payload = json!({"layer": "Photo", "_actionLayer": {"name": "Photo", "ordinal": 2}});
let mut a = Action::new("Steps", vec![("layer.select".into(), payload.clone()), ("select.all".into(), Value::Null), ("select.deselect".into(), json!({}))]);
a.disabled.extend([0, 2]);
a.dialogs.extend([0, 1]);
s.actions.list.push(a);
let revision = s.active().unwrap().revision;
let result = s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap();
assert_eq!(result["step"], 1);
assert_eq!(result["index"], 0);
let a = &s.actions.list[0];
assert_eq!(a.name, "Steps");
assert_eq!(a.steps[0], a.steps[1]);
assert_eq!(a.disabled.iter().copied().collect::<Vec<_>>(), [0, 1, 3]);
assert_eq!(a.dialogs.iter().copied().collect::<Vec<_>>(), [0, 1, 2]);
s.execute("actions.duplicate", json!({"action": 0, "step": 2})).unwrap();
assert!(s.actions.list[0].steps[2].1.is_null());
assert!(s.actions.list[0].steps[3].1.is_null(), "copying does not normalize null settings to an empty object");
s.actions.list[0].steps[1].1["layer"] = json!(99);
assert_eq!(s.actions.list[0].steps[0].1, payload, "the copy owns its settings");
assert_eq!(s.active().unwrap().revision, revision);
}
#[test]
fn invalid_copies_do_not_flush_recording_and_valid_step_copies_keep_recording() {
let mut s = session();
s.execute("actions.record", json!({"name": "Recording"})).unwrap();
s.execute("select.all", json!({})).unwrap();
let before = s.actions.list.clone();
let recording = s.actions.recording;
let revision = s.active().unwrap().revision;
for params in [
json!({}),
json!({"action": 9}),
json!({"action": 0}),
json!({"action": 0, "step": -1}),
json!({"action": 0, "step": 5}),
json!({"action": 0, "step": "x"}),
] {
assert!(s.execute("actions.duplicate", params.clone()).is_err(), "{params}");
assert_eq!(s.actions.list, before);
assert_eq!(s.actions.recording, recording);
assert_eq!(s.active().unwrap().revision, revision);
}
s.execute("actions.duplicate", json!({"action": 0, "step": 0})).unwrap();
assert_eq!(s.actions.list[0].steps, vec![("select.all".into(), json!({})); 2]);
assert!(s.actions.recording.is_some());
s.execute("select.deselect", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.len(), 3);
s.actions.list.push(Action::new("Edit during playback", vec![("actions.duplicate".into(), json!({"action": 0}))]));
let before = s.actions.list.clone();
let played = s.execute("actions.play", json!({"action": "Edit during playback"})).unwrap();
assert!(played["failed"]["error"].as_str().is_some_and(|e| e.contains("playback")), "{played}");
assert_eq!(s.actions.list, before);
}
@@ -0,0 +1,200 @@
//! The remaining panel commands compose with the same definitions, journal and ATN boundary.
use photocraft_engine::{Session, actions_cmds::Action, actions_edit_cmds, atn_cmds, jobs::Started};
use serde_json::json;
/// Tiny real document with one empty action keeps recording and background jobs observable.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width":40,"height":30})).unwrap();
s.actions.list.push(Action::new("A", vec![]));
s
}
/// A painted stripe gives both the remove tool and blur a real input before recording starts.
fn stroke(s: &mut Session, color: &str) {
s.execute("paint.stroke", json!({"points":[[8,15],[32,15]],"size":3,"color":color})).unwrap();
}
/// Mode changes flush under the previous mode without altering painting or ordinary recording.
#[test]
fn tool_recording_is_opt_in_and_toggles_do_not_reclassify_pending_steps() {
let mut s = session();
s.execute("actions.record", json!({"action":"A"})).unwrap();
stroke(&mut s, "#ff0000");
s.execute("select.all", json!({})).unwrap();
let original = s.actions.clone();
assert!(s.execute("actions.allowToolRecording", json!({"enabled":"yes"})).is_err());
assert_eq!(s.actions, original, "invalid mode must not flush pending work");
s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap();
stroke(&mut s, "#00ff00");
s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap();
stroke(&mut s, "#0000ff");
s.execute("select.deselect", json!({})).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert_eq!(s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["select.all", "paint.stroke", "select.deselect"]);
assert_eq!(s.actions.list[0].steps[1].1["color"], "#00ff00");
assert_eq!(s.journal.iter().filter(|s| s.0 == "paint.stroke").count(), 3, "all actual painting remains journaled");
assert_eq!(s.execute("actions.list", json!({})).unwrap()["toolRecording"], false);
}
/// A stroke started while off never joins a later enabled recording; ordinary jobs keep their owner.
#[test]
fn background_stroke_ownership_does_not_suppress_ordinary_filter_jobs() {
for command in ["paint.remove", "filter.blur.gaussianBlur"] {
let mut s = session();
stroke(&mut s, "#bb3300");
s.execute("actions.record", json!({"action":"A"})).unwrap();
let params = if command == "paint.remove" { json!({"points":[[20,15],[21,15]],"size":3}) } else { json!({"radius":1}) };
let Started::Job(job) = s.start(command, params).unwrap() else { panic!("expected the real background job: {command}") };
let changed = s.execute("actions.allowToolRecording", json!({"enabled":true}));
if command == "paint.remove" {
assert!(changed.is_err(), "the in-flight stroke retains the mode it started under");
assert!(!s.actions.record_tools);
} else {
changed.unwrap();
}
s.wait_job(job).unwrap();
if command == "paint.remove" {
s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap();
}
s.execute("actions.stop", json!({})).unwrap();
let ids = s.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::<Vec<_>>();
if command == "paint.remove" {
assert!(ids.is_empty());
} else {
assert_eq!(ids, [command]);
}
}
}
/// A Remove job started outside the current take neither blocks its toggle nor joins its steps.
#[test]
fn background_strokes_from_outside_the_current_take_do_not_block_the_toggle() {
for previous_take in [false, true] {
let mut s = session();
stroke(&mut s, "#bb3300");
if previous_take {
s.execute("actions.record", json!({"action":"A"})).unwrap();
s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap();
}
let Started::Job(job) = s.start("paint.remove", json!({"points":[[20,15],[21,15]],"size":3})).unwrap() else {
panic!("expected the real Remove job");
};
if previous_take {
s.execute("actions.stop", json!({})).unwrap();
s.execute("actions.allowToolRecording", json!({"enabled":false})).unwrap();
}
s.execute("actions.record", json!({"action":"A"})).unwrap();
assert_eq!(s.execute("actions.allowToolRecording", json!({"enabled":true})).unwrap()["enabled"], true);
s.wait_job(job).unwrap();
s.execute("actions.stop", json!({})).unwrap();
assert!(s.actions.list[0].steps.is_empty());
assert_eq!(s.journal.iter().filter(|step| step.0 == "paint.remove").count(), 1);
}
}
/// Interleaved membership and numeric calls expose incorrect folder placement or target rewriting.
#[test]
fn set_duplication_preserves_metadata_call_targets_and_empty_folder_order() {
let mut s = session();
s.actions.list.clear();
for (name, set) in [("A", "S"), ("Other", "T"), ("B", "S")] {
let mut a = Action::new(name, vec![("actions.play".into(), json!({"action":2}))]);
a.set = Some(set.into());
a.color = "red".into();
a.disabled.insert(0);
a.dialogs.insert(0);
s.actions.list.push(a);
}
s.prefs.edit(|p| {
p.shortcuts.insert("actions.play:A".into(), "F5".into());
});
let result = s.execute("actions.duplicate", json!({"set":"S"})).unwrap();
assert_eq!(result["set"], "S copy");
assert_eq!(actions_edit_cmds::set_names(&s), ["S", "S copy", "T"]);
assert_eq!(s.actions.list.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["A", "A copy", "B copy", "Other", "B"]);
for a in &s.actions.list {
assert_eq!(a.steps[0].1["action"], 4);
assert_eq!(a.color, "red");
assert!(a.disabled.contains(&0));
assert!(a.dialogs.contains(&0));
}
assert!(!s.prefs().shortcuts.contains_key("actions.play:A copy"));
let mut empty = Session::new();
for name in ["E", "F"] {
empty.execute("actions.newSet", json!({"name":name})).unwrap();
}
empty.execute("actions.duplicate", json!({"set":"E"})).unwrap();
assert_eq!(actions_edit_cmds::set_names(&empty), ["E", "E copy", "F"]);
let original = empty.actions.clone();
for params in [json!({"set":"Missing"}), json!({"set":"E","action":0}), json!({"set":false})] {
assert!(empty.execute("actions.duplicate", params).is_err());
assert_eq!(empty.actions, original);
}
}
/// Menu insertion keeps descriptor absence and kind; empty descriptors and ambiguous commands differ.
#[test]
fn inserted_menu_events_roundtrip_absent_settings_and_stateless_items_execute() {
assert!(!atn_cmds::menu_item_needs_settings("filter.noise.despeckle"));
assert!(atn_cmds::menu_item_needs_settings("filter.blur.gaussianBlur"));
for id in ["image.imageSize", "filter.blur.gaussianBlur", "filter.noise.despeckle"] {
for kind in ["name", "code"] {
let mut s = session();
s.execute("actions.insertMenuItem", json!({"action":"A","id":id,"eventKind":kind})).unwrap();
let bytes = atn_cmds::export("S", &[&s.actions.list[0]], &[], false).unwrap().0;
let raw = photocraft_psd::atn::parse(&bytes).unwrap();
assert!(raw.actions[0].items[0].descriptor.is_none());
let imported = atn_cmds::import(&bytes).unwrap();
let (step, params) = &imported.actions[0].steps[0];
assert_eq!(step, atn_cmds::MENU_ITEM);
assert_eq!(params["id"], id);
assert_eq!(params["eventKind"], kind);
let (saved, skipped) = atn_cmds::export("S", &[&imported.actions[0]], &[], false).unwrap();
assert!(skipped.is_empty());
let saved = photocraft_psd::atn::parse(&saved).unwrap();
assert_eq!(saved.actions[0].items[0], raw.actions[0].items[0]);
let played = s.execute("actions.play", json!({"action":"A"})).unwrap();
assert_eq!(played.get("failed").is_none(), !atn_cmds::menu_item_needs_settings(id), "{id}: {played}");
}
}
let mut s = session();
let original = s.actions.clone();
for params in
[json!({"action":"A","id":"layer.setProps"}), json!({"action":"A","id":"file.open"}), json!({"action":"A","id":"image.imageSize","params":{"width":4}})]
{
assert!(s.execute("actions.insertMenuItem", params).is_err());
assert_eq!(s.actions, original);
}
let mut raw = photocraft_psd::atn::AtnFile::default();
raw.actions.push(photocraft_psd::atn::AtnAction {
items: vec![photocraft_psd::atn::AtnItem {
event: photocraft_psd::atn::AtnEvent::name("gaussianBlur"),
descriptor: Some(photocraft_psd::descriptor::Descriptor::new("null")),
..Default::default()
}],
..Default::default()
});
let loaded = atn_cmds::import(&photocraft_psd::atn::write(&raw).unwrap()).unwrap();
assert_eq!(loaded.actions[0].steps[0].0, "filter.blur.gaussianBlur");
}
/// A full curve is snapshotted independently of later source edits and replays as the work path.
#[test]
fn insert_path_captures_cubic_geometry_without_editing_the_document() {
let mut s = session();
let path = json!({"fillRule":"evenodd","inverted":true,"subpaths":[{"closed":false,"op":"combine","knots":[{"anchor":[1.5,2.25],"in":[-1.0,3.0],"out":[5.0,8.0],"smooth":true},{"anchor":[20.0,18.0],"in":[12.0,5.0],"out":[21.0,19.0]}]}]});
s.execute("path.set", json!({"name":"Curve","path":path})).unwrap();
let expected = s.execute("path.info", json!({"name":"Curve"})).unwrap()["path"].clone();
let revision = s.active().unwrap().revision;
assert!(s.execute("actions.insertPath", json!({"action":"A","name":"work"})).is_err());
assert!(s.actions.list[0].steps.is_empty());
s.execute("actions.insertPath", json!({"action":"A","name":"Curve"})).unwrap();
assert_eq!(s.active().unwrap().revision, revision);
assert_eq!(s.actions.list[0].steps[0].1["path"], expected);
s.execute("path.set", json!({"name":"Curve","path":{"subpaths":[]}})).unwrap();
let played = s.execute("actions.play", json!({"action":"A"})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(s.execute("path.info", json!({"name":"work"})).unwrap()["path"], expected);
assert!(atn_cmds::export("S", &[&s.actions.list[0]], &[], false).is_err(), "the measured ATN descriptor cannot represent inversion");
}
@@ -0,0 +1,77 @@
//! Record Again edits one definition only after its concrete command succeeds.
use photocraft_engine::{EngineError, Session, actions_cmds::Action, atn_cmds};
use serde_json::{Value, json};
/// A disabled, dialog-enabled resize followed by another step exposes flag and ordering loss.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
let mut a = Action::new("A", vec![("image.imageSize".into(), json!({"width": 4, "height": 3, "resample": "nearest"})), ("select.all".into(), json!({}))]);
a.disabled.insert(0);
a.dialogs.insert(0);
s.actions.list.push(a);
s
}
/// Only the selected step runs, retains its flags, and exports its new settings to ATN.
#[test]
fn successful_rerecording_updates_one_step_and_roundtrips_new_settings() {
for menu in [false, true] {
let mut s = session();
if menu {
s.actions.list[0].steps[0] = (atn_cmds::MENU_ITEM.into(), json!({"id": "image.imageSize"}));
}
let revision = s.actions.rev;
s.execute("actions.recordAgain", json!({"action": "A", "step": 0, "revision": revision, "params": {"width": 6, "height": 5, "resample": "nearest"}}))
.unwrap();
assert_eq!(s.active().unwrap().doc.size, photocraft_engine::doc::Size::new(6, 5));
assert!(s.active().unwrap().doc.selection.is_none(), "the following step did not play");
let a = &s.actions.list[0];
assert_eq!(a.steps.len(), 2);
assert_eq!(a.steps[0].0, "image.imageSize");
assert_eq!(a.steps[0].1["width"], 6);
assert!(a.disabled.contains(&0));
assert!(a.dialogs.contains(&0));
assert_eq!(s.actions.rev, revision + 1);
assert_eq!(s.journal.last().unwrap().0, "image.imageSize");
let bytes = atn_cmds::export("Set", &[a], &[], false).unwrap().0;
let loaded = atn_cmds::import(&bytes).unwrap();
assert_eq!(loaded.actions[0].steps[0].1["width"].as_f64(), Some(6.0));
assert!(loaded.actions[0].disabled.contains(&0));
assert!(loaded.actions[0].dialogs.contains(&0));
}
}
/// Permit the wrapper while denying its concrete edit, proving nested authorization remains active.
fn deny_resize(id: &str, _: &Value) -> photocraft_engine::Result<()> {
if id == "image.imageSize" { Err(EngineError::Other("resize denied".into())) } else { Ok(()) }
}
/// Invalid settings, stale selections, denied execution and recording preserve the original step.
#[test]
fn failed_or_stale_rerecording_never_replaces_the_original_step() {
let mut s = session();
let original = s.actions.list.clone();
let revision = s.active().unwrap().revision;
for request in [
json!({"action":"A","step":0}),
json!({"action":"A","step":0,"params":[]}),
json!({"action":"A","step":0,"params":{"percent":-1}}),
json!({"action":"A","step":99,"params":{}}),
json!({"action":"Missing","step":0,"params":{}}),
json!({"action":"A","step":0,"revision":1,"params":{}}),
] {
assert!(s.execute("actions.recordAgain", request.clone()).is_err(), "{request}");
assert_eq!(s.actions.list, original);
assert_eq!(s.actions.rev, 0);
assert_eq!(s.active().unwrap().revision, revision);
}
s.authorize = Some(deny_resize);
assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{"width":6,"height":5}})).is_err());
assert_eq!(s.actions.list, original);
s.authorize = None;
s.execute("actions.record", json!({"action":"A"})).unwrap();
s.execute("select.all", json!({})).unwrap();
assert!(s.execute("actions.recordAgain", json!({"action":"A","step":0,"params":{}})).is_err());
assert_eq!(s.actions.list, original, "recording was not flushed by a refused edit");
}
+3
View File
@@ -1,5 +1,8 @@
//! The crate's integration tests, linked into one test binary.
mod actions_duplicate;
mod actions_panel_menu;
mod actions_record_again;
mod actions_single_history;
mod affinity;
mod artboard_export_names;
@@ -0,0 +1,125 @@
//! The Magnetic Lasso follows the edges of the visible composite, whichever layer is active
//! (#11686), in the live `EdgeSource` and in `select.magneticLasso` with `trace: true`.
use photocraft_algo::magnetic::Settings;
use photocraft_doc::LayerId;
use photocraft_engine::Session;
use photocraft_engine::magnetic_cmds::EdgeSource;
use photocraft_geom::Rect;
use serde_json::json;
/// The red rectangle's pixels: x 50..125, y 40..100, 75 x 60 = 4500 px.
const RED: Rect = Rect { x0: 50, y0: 40, x1: 125, y1: 100 };
const RED_AREA: f32 = 4500.0;
/// Fastening points 4 px outside the rectangle's corners.
const OUTSIDE: [[f64; 2]; 4] = [[46.0, 36.0], [129.0, 36.0], [129.0, 104.0], [46.0, 104.0]];
struct Doc {
s: Session,
background: LayerId,
red: LayerId,
}
/// A 200 x 150 RGB document: a white Background and a layer above it filled red over `RED`. The
/// red layer is active.
fn doc() -> Doc {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 150})).unwrap();
let background = s.active().unwrap().doc.layers[0].id;
s.edit("paint", |doc, _| {
doc.layers[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 200, 150), &[1.0, 1.0, 1.0, 1.0]);
Ok(())
})
.unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
let red = s.active().unwrap().active_layer.unwrap();
assert_ne!(red, background);
s.edit("paint", |doc, _| {
doc.layer_mut(red).unwrap().surface_mut().unwrap().fill_rect(RED, &[0.86, 0.12, 0.12, 1.0]);
Ok(())
})
.unwrap();
Doc { s, background, red }
}
fn select(s: &mut Session, id: LayerId) {
s.execute("layer.select", json!({"layer": id.0})).unwrap();
}
fn coverage(s: &Session, x: i32, y: i32) -> f32 {
s.active().unwrap().doc.selection.as_ref().map_or(0.0, |m| m.sample_channel(x, y, 0))
}
fn selected_area(s: &Session) -> f32 {
(0..150).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| coverage(s, x, y)).sum()
}
/// Traces a border through `OUTSIDE` and asserts it selected the rectangle, not the points' own
/// quadrilateral (83 x 68 = 5644 px).
fn assert_snaps(s: &mut Session, what: &str) {
s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap();
let area = selected_area(s);
assert!((area - RED_AREA).abs() < 150.0, "{what}: the border should select the rectangle ({RED_AREA} px), selected {area} px");
assert!(coverage(s, 52, 42) > 0.99 && coverage(s, 122, 97) > 0.99, "{what}: the rectangle's corners are inside");
assert!(coverage(s, 48, 70) < 0.01 && coverage(s, 127, 70) < 0.01, "{what}: just outside the rectangle is not selected");
s.execute("select.deselect", json!({})).unwrap();
}
/// Whether a fastening point 4 px outside the rectangle's left edge snaps onto it.
fn edge_snaps(s: &Session) -> bool {
let mut src = EdgeSource::new(s).unwrap();
let p = src.snap([46.0, 70.0], Settings::default()).unwrap();
(p[0] - 50.0).abs() < 1.5
}
#[test]
fn the_border_snaps_whichever_layer_is_active() {
let mut d = doc();
assert_snaps(&mut d.s, "the red layer active");
select(&mut d.s, d.background);
assert_snaps(&mut d.s, "the Background active");
assert!(edge_snaps(&d.s), "Background active: the live source sees the red layer's edge");
d.s.execute("layer.new.layer", json!({})).unwrap();
assert!(d.s.active().unwrap().active_layer.is_some_and(|id| id != d.background && id != d.red));
assert!(edge_snaps(&d.s), "a new empty layer active");
assert_snaps(&mut d.s, "a new empty layer active");
}
#[test]
fn an_adjustment_or_group_layer_active_snaps_too() {
let mut d = doc();
d.s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
let st = d.s.active().unwrap();
assert!(st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.surface().is_none()));
assert!(edge_snaps(&d.s), "an adjustment layer active");
assert_snaps(&mut d.s, "an adjustment layer active");
select(&mut d.s, d.background);
d.s.execute("layer.new.group", json!({})).unwrap();
assert!(edge_snaps(&d.s), "an empty group active");
assert_snaps(&mut d.s, "an empty group active");
}
#[test]
fn a_hidden_layer_contributes_no_edges() {
let mut d = doc();
select(&mut d.s, d.background);
d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap();
assert!(!edge_snaps(&d.s), "the hidden red layer has no edge to snap to");
d.s.execute("select.magneticLasso", json!({"points": OUTSIDE, "trace": true})).unwrap();
let area = selected_area(&d.s);
// The border follows the pointer: the points' own 83 x 68 quadrilateral.
assert!((area - 5644.0).abs() < 150.0, "selected {area} px");
// Showing it again brings the edge back, for a source made after the change.
d.s.execute("layer.showLayers", json!({"layer": d.red.0})).unwrap();
assert!(edge_snaps(&d.s));
}
#[test]
fn a_live_source_goes_stale_when_the_composite_changes() {
let mut d = doc();
select(&mut d.s, d.background);
let src = EdgeSource::new(&d.s).unwrap();
assert!(src.is_current(&d.s));
d.s.execute("layer.hideLayers", json!({"layer": d.red.0})).unwrap();
assert!(!src.is_current(&d.s), "hiding a layer changes what the border follows");
}
+1 -1
View File
@@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
+109 -7
View File
@@ -13,13 +13,14 @@
//! (`pixelLength`, `logicalLength`, `logicalUnits`) follow Photoshop's scripting names for the
//! measurement scale; no sample file was available, so parsing is lenient and the raw resource
//! is kept byte-exact while the scale is unchanged.
//! * Count information: image resource 1080 is kept verbatim (its layout is not public) unless
//! the document has its own count markers, which only `.pcraft` stores.
//! * Count information: image resource 1080 (a version-16 `Cnt ` descriptor) maps to the
//! document's ordered Count groups. Unreadable resources are kept while there are no groups.
use std::sync::Arc;
use photocraft_doc::{Color, Document, MeasurementScale, Note};
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
use photocraft_doc::{Color, CountGroup, Document, MeasurementScale, Note};
use photocraft_psd::ImageResource;
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor};
pub const MEASUREMENT_SCALE: u16 = 1074;
pub const COUNT_INFO: u16 = 1080;
@@ -341,17 +342,118 @@ pub fn fresh_scale_resource(doc: &Document) -> Option<Vec<u8>> {
(!doc.measurement.scale.is_default()).then(|| write_scale_resource(&doc.measurement.scale))
}
/// Decode the measured resource as a whole so unknown or damaged groups stay opaque.
fn count_groups_from_resource(data: &[u8]) -> Option<Vec<CountGroup>> {
let d = VersionedDescriptor::from_bytes(data).ok()?.descriptor;
if !d.class_id.is("Cnt ") || d.items.len() != 2 || d.get("Vrsn") != Some(&Value::Integer(1)) {
return None;
}
let Value::List(groups) = d.get("countGroupList")? else { return None };
groups.iter().map(count_group_from_descriptor).collect()
}
/// Decode the fields the Count tool can use without coercing unrecognized data.
fn count_group_from_descriptor(value: &Value) -> Option<CountGroup> {
let Value::Descriptor(d) = value else { return None };
if !d.class_id.is("countGroup") || d.items.len() != 8 {
return None;
}
let integer = |key| match d.get(key)? {
Value::Integer(n) => u32::try_from(*n).ok(),
_ => None,
};
let channel = |key| u8::try_from(integer(key)?).ok().map(|n| f32::from(n) / 255.0);
let color = Color::rgb(channel("Rd ")?, channel("Grn ")?, channel("Bl ")?);
let Value::Text(name) = d.get("Nm ")? else { return None };
let Value::Boolean(visible) = d.get("Vsbl")? else { return None };
let Value::List(objects) = d.get("countObjectList")? else { return None };
let marker_size = integer("Rds ")?;
let label_size = integer("fontSize")?;
// Keep out-of-range sizes opaque instead of passing them to the marker and text renderers.
if !(1..=10).contains(&marker_size) || !(8..=72).contains(&label_size) {
return None;
}
let name = String::from_utf16(name.0.strip_suffix(&[0]).unwrap_or(&name.0)).ok()?;
let points = objects.iter().map(count_point_from_descriptor).collect::<Option<_>>()?;
Some(CountGroup { name, color, marker_size, label_size, visible: *visible, points })
}
/// Keep document coordinates as doubles and reject nonfinite values.
fn count_point_from_descriptor(value: &Value) -> Option<[f64; 2]> {
let Value::Descriptor(d) = value else { return None };
if !d.class_id.is("countObject") || d.items.len() != 2 {
return None;
}
let (Value::Double(x), Value::Double(y)) = (d.get("X ")?, d.get("Y ")?) else { return None };
(x.is_finite() && y.is_finite()).then_some([*x, *y])
}
/// Consume readable Count data. Keep only opaque bytes when no resource can be read.
pub(crate) fn import_count_resource(doc: &mut Document) {
if let Some(groups) = doc.metadata.psd_resources.iter().filter(|(id, _, _)| *id == COUNT_INFO).find_map(|(_, _, data)| count_groups_from_resource(data)) {
doc.measurement.count_groups = groups;
doc.metadata.psd_resources.retain(|(id, _, _)| *id != COUNT_INFO);
}
}
/// Resource 1080 from the current groups, warning when PSD changes sizes or omits points.
pub(crate) fn fresh_count_resource(doc: &Document, warnings: &mut Vec<String>) -> Option<ImageResource> {
if doc.measurement.count_groups.is_empty() {
return None;
}
let groups = doc.measurement.count_groups.iter().map(|group| count_group_descriptor(group, warnings)).collect();
let d = Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(groups));
Some(ImageResource::new(COUNT_INFO, VersionedDescriptor::new(d).to_bytes()))
}
/// Write the measured group layout and preserve marker order.
fn count_group_descriptor(group: &CountGroup, warnings: &mut Vec<String>) -> Value {
let marker_size = group.marker_size.clamp(1, 10);
let label_size = group.label_size.clamp(8, 72);
if marker_size != group.marker_size || label_size != group.label_size {
warnings.push(format!(
"Count group {:?} sizes adjusted for PSD (marker {} to {marker_size}, label {} to {label_size})",
group.name, group.marker_size, group.label_size
));
}
let [r, g, b, _] = group.color.to_rgba8();
let objects: Vec<_> = group
.points
.iter()
.filter(|[x, y]| x.is_finite() && y.is_finite())
.map(|&[x, y]| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y))))
.collect();
let omitted = group.points.len() - objects.len();
if omitted != 0 {
warnings.push(format!("Count group {:?} had nonfinite points omitted from PSD export ({omitted} total)", group.name));
}
Value::Descriptor(
Descriptor::new("countGroup")
.with("Rd ", Value::Integer(i32::from(r)))
.with("Grn ", Value::Integer(i32::from(g)))
.with("Bl ", Value::Integer(i32::from(b)))
.with("Nm ", Value::Text(UnicodeString::new_nul(&group.name)))
.with("Rds ", Value::Integer(marker_size as i32))
.with("fontSize", Value::Integer(label_size as i32))
.with("Vsbl", Value::Boolean(group.visible))
.with("countObjectList", Value::List(objects)),
)
}
/// Whether to keep a preserved resource with this id on export.
pub fn keep_resource(doc: &Document, id: u16) -> bool {
match id {
MEASUREMENT_SCALE => keep_raw_scale(doc),
// Our count markers can't be written into Photoshop's (unpublished) layout; a stale copy
// would contradict them, so it is dropped once the document has markers of its own.
COUNT_INFO => doc.measurement.count_total() == 0,
// An opaque copy is only used when there are no document groups to write.
COUNT_INFO => doc.measurement.count_groups.is_empty(),
_ => true,
}
}
#[cfg(test)]
#[path = "count_resource_tests.rs"]
mod count_resource_tests;
#[cfg(test)]
mod tests {
use super::*;
+213
View File
@@ -0,0 +1,213 @@
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Color, ColorTable, CountGroup, Document};
use photocraft_geom::Size;
use photocraft_psd::descriptor::{Descriptor, UnicodeString, Value, VersionedDescriptor};
use photocraft_psd::{ColorMode as PsdMode, ImageResource, PsdFile};
use super::{COUNT_INFO, count_groups_from_resource};
fn measured_resource(second_y: f64) -> Vec<u8> {
let point = |x, y| Value::Descriptor(Descriptor::new("countObject").with("X ", Value::Double(x)).with("Y ", Value::Double(y)));
let first = Descriptor::new("countGroup")
.with("Rd ", Value::Integer(51))
.with("Grn ", Value::Integer(102))
.with("Bl ", Value::Integer(204))
.with("Nm ", Value::Text(UnicodeString::new_nul("Cells")))
.with("Rds ", Value::Integer(3))
.with("fontSize", Value::Integer(12))
.with("Vsbl", Value::Boolean(true))
.with("countObjectList", Value::List(vec![point(9.5, 10.5), point(1.125, second_y), point(3.5, 4.5)]));
let second = Descriptor::new("countGroup")
.with("Rd ", Value::Integer(255))
.with("Grn ", Value::Integer(0))
.with("Bl ", Value::Integer(0))
.with("Nm ", Value::Text(UnicodeString::new_nul("Café")))
.with("Rds ", Value::Integer(10))
.with("fontSize", Value::Integer(72))
.with("Vsbl", Value::Boolean(false))
.with("countObjectList", Value::List(vec![point(7.5, 8.5), point(5.5, 6.5)]));
VersionedDescriptor::new(
Descriptor::new("Cnt ").with("Vrsn", Value::Integer(1)).with("countGroupList", Value::List(vec![Value::Descriptor(first), Value::Descriptor(second)])),
)
.to_bytes()
}
fn groups() -> Vec<CountGroup> {
vec![
CountGroup {
name: "Cells".into(),
color: Color::rgb(51.0 / 255.0, 102.0 / 255.0, 204.0 / 255.0),
marker_size: 3,
label_size: 12,
visible: true,
points: vec![[9.5, 10.5], [1.125, 2.75], [3.5, 4.5]],
},
CountGroup {
name: "Café".into(),
color: Color::rgb(1.0, 0.0, 0.0),
marker_size: 10,
label_size: 72,
visible: false,
points: vec![[7.5, 8.5], [5.5, 6.5]],
},
]
}
fn document(mode: ColorMode) -> Document {
Document::new("Counts", Size::new(16, 16), mode, SampleType::U8)
}
fn to_psd(doc: &Document) -> Vec<u8> {
crate::export(doc, "counts.psd", &crate::ExportOptions::default()).unwrap().bytes
}
fn import_counts(data: Vec<u8>) -> Document {
let mut file = PsdFile::from_bytes(&to_psd(&document(ColorMode::Rgb))).unwrap();
file.resources.push(ImageResource::new(COUNT_INFO, data));
crate::import("counts.psd", &file.to_bytes().unwrap()).unwrap().document
}
/// Two independently constructed groups preserve every option and the order of all five points.
#[test]
fn parses_hand_built_count_descriptor() {
assert_eq!(count_groups_from_resource(&measured_resource(2.75)), Some(groups()));
assert!(count_groups_from_resource(&measured_resource(f64::INFINITY)).is_none());
let mut trailing = measured_resource(2.75);
trailing.push(1);
assert!(count_groups_from_resource(&trailing).is_none());
}
/// Count groups round-trip through the real PSD reader and both export routes.
#[test]
fn count_groups_round_trip_through_psd() {
for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap), (ColorMode::Multichannel, PsdMode::Multichannel)] {
let mut doc = document(mode);
doc.measurement.count_groups = groups();
let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap();
assert!(!exported.warnings.iter().any(|warning| warning.starts_with("Count group ")));
let bytes = exported.bytes;
let file = PsdFile::from_bytes(&bytes).unwrap();
assert_eq!(file.header.color_mode, wire_mode);
let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect();
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].data, measured_resource(2.75));
let reopened = crate::import("counts.psd", &bytes).unwrap().document;
assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups);
assert!(!reopened.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO));
}
}
/// Both export routes report clamped sizes and preserve the original Count groups.
#[test]
fn count_size_clamps_warn_without_changing_source_groups() {
for mode in [ColorMode::Rgb, ColorMode::Bitmap] {
let mut doc = document(mode);
let mut original = groups();
original[0].marker_size = 0;
original[0].label_size = 73;
original[1].marker_size = 11;
original[1].label_size = 7;
doc.measurement.count_groups = original.clone();
let exported = crate::export(&doc, "counts.psd", &crate::ExportOptions::default()).unwrap();
let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect();
assert_eq!(
warnings,
[
"Count group \"Cells\" sizes adjusted for PSD (marker 0 to 1, label 73 to 72)",
"Count group \"Café\" sizes adjusted for PSD (marker 11 to 10, label 7 to 8)",
]
);
assert_eq!(doc.measurement.count_groups, original);
let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document;
let mut expected = original;
expected[0].marker_size = 1;
expected[0].label_size = 72;
expected[1].marker_size = 10;
expected[1].label_size = 8;
assert_eq!(reopened.measurement.count_groups, expected);
}
}
/// Opaque data survives an untouched save and an add-then-delete, but current groups win.
#[test]
fn unreadable_count_resource_is_preserved_without_groups() {
let raw = vec![0, 0, 0, 16, 0];
let mut doc = import_counts(raw.clone());
assert!(doc.measurement.count_groups.is_empty());
let check_raw = |doc: &Document| {
let file = PsdFile::from_bytes(&to_psd(doc)).unwrap();
assert_eq!(file.resource(COUNT_INFO).unwrap().data, raw);
};
check_raw(&doc);
doc.measurement.count_groups.push(CountGroup::default());
let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap();
let mut resources = file.resources.iter().filter(|r| r.id == COUNT_INFO);
assert_eq!(count_groups_from_resource(&resources.next().unwrap().data), Some(doc.measurement.count_groups.clone()));
assert!(resources.next().is_none());
doc.measurement.count_groups.clear();
check_raw(&doc);
}
/// Deleting the imported groups cannot bring back the readable resource.
#[test]
fn deleting_all_readable_count_groups_writes_no_resource() {
let mut doc = import_counts(measured_resource(2.75));
assert_eq!(doc.measurement.count_groups, groups());
assert!(!doc.metadata.psd_resources.iter().any(|(id, _, _)| *id == COUNT_INFO));
doc.measurement.count_groups.clear();
let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap();
assert!(file.resource(COUNT_INFO).is_none());
}
/// Indexed export uses the shared layerless writer and keeps exactly one Count resource.
#[test]
fn indexed_count_groups_round_trip_through_psd() {
let mut doc = document(ColorMode::Indexed);
doc.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 255, 255]], transparent: None });
let opaque = vec![0, 0, 0, 16, 0];
doc.metadata.psd_resources.push((COUNT_INFO, String::new(), std::sync::Arc::new(opaque.clone())));
let file = PsdFile::from_bytes(&to_psd(&doc)).unwrap();
assert_eq!(file.header.color_mode, PsdMode::Indexed);
assert_eq!(file.resource(COUNT_INFO).unwrap().data, opaque);
doc.measurement.count_groups = groups();
let bytes = to_psd(&doc);
let file = PsdFile::from_bytes(&bytes).unwrap();
assert_eq!(file.header.color_mode, PsdMode::Indexed);
assert!(file.layer_info.is_none());
let resources: Vec<_> = file.resources.iter().filter(|r| r.id == COUNT_INFO).collect();
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].data, measured_resource(2.75));
let reopened = crate::import("counts.psd", &bytes).unwrap().document;
assert_eq!(reopened.color_table, doc.color_table);
assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups);
}
/// Both PSD routes report omitted points, keeping the finite points in order.
#[test]
fn nonfinite_count_points_warn_on_psd_export() {
for (mode, wire_mode) in [(ColorMode::Rgb, PsdMode::Rgb), (ColorMode::Bitmap, PsdMode::Bitmap)] {
let mut doc = document(mode);
doc.measurement.count_groups = groups();
let options = crate::ExportOptions::default();
let clean = crate::export(&doc, "counts.psd", &options).unwrap();
assert!(!clean.warnings.iter().any(|warning| warning.starts_with("Count group ")));
let reopened = crate::import("counts.psd", &clean.bytes).unwrap().document;
assert_eq!(reopened.measurement.count_groups, doc.measurement.count_groups);
let mut expected = groups();
expected[0].points.remove(1);
doc.measurement.count_groups[0].points[1][1] = f64::INFINITY;
let original = doc.measurement.count_groups.clone();
let exported = crate::export(&doc, "counts.psd", &options).unwrap();
let warnings: Vec<_> = exported.warnings.iter().filter(|warning| warning.starts_with("Count group ")).map(String::as_str).collect();
assert_eq!(warnings, ["Count group \"Cells\" had nonfinite points omitted from PSD export (1 total)"]);
assert_eq!(doc.measurement.count_groups, original);
let file = PsdFile::from_bytes(&exported.bytes).unwrap();
assert_eq!(file.header.color_mode, wire_mode);
let reopened = crate::import("counts.psd", &exported.bytes).unwrap().document;
assert_eq!(reopened.measurement.count_groups, expected);
}
}
+1
View File
@@ -196,6 +196,7 @@ pub(crate) fn layerless_file(
if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) {
resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data));
}
resources.extend(crate::annotations_map::fresh_count_resource(doc, warnings));
resources.push(version_info_resource(true));
// Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a
// kept layer block would describe layers the document doesn't have. Their signatures follow
+1
View File
@@ -235,6 +235,7 @@ fn export_supported_document(
if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) {
resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data));
}
resources.extend(crate::annotations_map::fresh_count_resource(doc, &mut ex.warnings));
if let Some(Some(data)) = comps_resource {
resources.push(ImageResource::new(crate::comps_map::LAYER_COMPS, data));
}
+5 -2
View File
@@ -710,7 +710,7 @@ impl Ctx<'_> {
crate::linked::embedded_payload_in_blocks(global.iter().map(|(_, key, data)| (key, data.as_slice())), &id).map(|(_, source)| source)
})
});
LayerContent::Smart(SmartObject {
let mut sm = SmartObject {
contents_id,
source: SmartSource::Linked { path: id },
vector_preview,
@@ -723,7 +723,9 @@ impl Ctx<'_> {
warp,
stack_mode: None,
perspective,
})
};
crate::smart_map::pin_puppet_meshes(&mut sm);
LayerContent::Smart(sm)
}
/// Typed vector mask from `vsms`/`vmsk` (non-shape layers). A user mask that Photoshop
@@ -1238,6 +1240,7 @@ fn read_initial_metadata(
if let Some(scale) = crate::annotations_map::raw_scale(doc) {
doc.measurement.scale = scale;
}
crate::annotations_map::import_count_resource(doc);
let _ = layered;
(damaged, alloc_error)
}
+308
View File
@@ -0,0 +1,308 @@
//! Puppet Warp smart filters (`Fltr` class `rigidTransform`) as observed in Photoshop-authored
//! mockup PSDs.
//!
//! The filter holds a `puppetShapeList` with one `puppetShape` per mesh. Each shape stores its
//! pins (`PinP` where they were placed, `posFinalPins` where they are now, x, y pairs in document
//! pixels) and the mesh Photoshop solved for them, as raw data:
//!
//! ```text
//! originalVertexArray, deformedVertexArray: f32 LE x, y per vertex (document pixels)
//! indexArray: i32 LE, three per triangle
//! ```
//!
//! and the corners of the layer the mesh was built on (`PuX0..3`, `PuY0..3` on the filter).
//! Replace Contents keeps the mesh: Photoshop redraws the new contents through the stored
//! triangles (measured on a dot grid: 0.1 px mean). So the mesh is imported as the
//! `layer.smartObjects.puppetWarp` filter's `mesh`, which is drawn as it is while the pins are
//! unchanged, and the whole item is kept as a template ([`TEMPLATE_KEY`]) to write back. Pins
//! alone feed PhotoCraft's own solver once they are edited; the mode, density and rotation
//! settings aren't mapped (their stored values aren't identified), so those stay at defaults.
use photocraft_doc::SmartFilter;
use photocraft_psd::descriptor::{Descriptor, Value};
use serde_json::{Value as J, json};
use super::{blend_options, from_hex, get_desc, set, to_hex};
use crate::blocks::num;
pub(super) const CLASS: &str = "rigidTransform";
pub(super) const COMMAND: &str = "layer.smartObjects.puppetWarp";
pub(super) const TEMPLATE_KEY: &str = "__puppetPsd";
/// Far beyond the few hundred vertices Photoshop's meshes have, yet small enough that the
/// copies a hostile mesh makes (arrays, JSON params, cloned filters) stay bounded.
const MAX_VERTS: usize = 1 << 16;
fn raw<'a>(d: &'a Descriptor, key: &str) -> Option<&'a [u8]> {
match d.get(key)? {
Value::RawData(b) => Some(b),
_ => None,
}
}
fn floats(d: &Descriptor, key: &str) -> Option<Vec<f64>> {
let Value::List(v) = d.get(key)? else { return None };
v.iter().map(|x| num(Some(x)).filter(|f| f.is_finite())).collect()
}
/// `f32` LE x, y pairs; `None` unless whole, finite and at most [`MAX_VERTS`].
fn vertices(b: &[u8]) -> Option<Vec<[f64; 2]>> {
if !b.len().is_multiple_of(8) || b.len() / 8 > MAX_VERTS {
return None;
}
let f = |c: &[u8]| c.try_into().ok().map(f32::from_le_bytes).filter(|v| v.is_finite()).map(f64::from);
b.as_chunks::<8>().0.iter().map(|c| Some([f(&c[..4])?, f(&c[4..])?])).collect()
}
/// `i32` LE triples, each index below `n`.
fn triangles(b: &[u8], n: usize) -> Option<Vec<[usize; 3]>> {
if !b.len().is_multiple_of(12) || b.len() / 12 > 2 * MAX_VERTS {
return None;
}
let i = |c: &[u8]| c.try_into().ok().map(i32::from_le_bytes).and_then(|v| usize::try_from(v).ok()).filter(|&v| v < n);
b.as_chunks::<12>().0.iter().map(|c| Some([i(&c[..4])?, i(&c[4..8])?, i(&c[8..])?])).collect()
}
/// One shape's pins (as params), vertices, deformed vertices and triangles.
type Shape = (Vec<J>, Vec<[f64; 2]>, Vec<[f64; 2]>, Vec<[usize; 3]>);
/// Pins and mesh of one `puppetShape`, with vertex indices offset by `base`.
fn shape(s: &Descriptor, base: usize) -> Option<Shape> {
let (from, to) = (floats(s, "PinP")?, floats(s, "posFinalPins")?);
if from.len() != to.len() || !from.len().is_multiple_of(2) {
return None;
}
let pins = from.as_chunks::<2>().0.iter().zip(to.as_chunks::<2>().0).map(|(a, b)| json!({"src": a, "dst": b})).collect();
let verts = vertices(raw(s, "originalVertexArray")?)?;
let deformed = vertices(raw(s, "deformedVertexArray")?)?;
if verts.len() != deformed.len() {
return None;
}
let tris = triangles(raw(s, "indexArray")?, verts.len())?;
Some((pins, verts, deformed, tris.into_iter().map(|t| t.map(|i| i + base)).collect()))
}
/// Pins and mesh of a `rigidTransform` descriptor (shapes joined into one mesh), as
/// `{"pins", "mesh"}`.
fn settings(f: &Descriptor) -> Option<J> {
let Some(Value::List(shapes)) = f.get("puppetShapeList") else { return None };
let (mut pins, mut verts, mut deformed, mut tris) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
for s in shapes {
let Value::Descriptor(s) = s else { return None };
let (p, v, d, t) = shape(s, verts.len())?;
pins.extend(p);
verts.extend(v);
deformed.extend(d);
tris.extend(t);
}
if tris.is_empty() || verts.len() > MAX_VERTS {
return None;
}
let pins = J::Array(pins);
Some(json!({"pins": pins, "mesh": {"pins": pins, "verts": verts, "deformed": deformed, "tris": tris}}))
}
/// The corners of the layer the mesh was built on (`PuX0`, `PuY0` … `PuX3`, `PuY3`), as x, y
/// pairs.
pub(super) fn built_on(params: &J) -> Option<[f64; 8]> {
let item = template(params).ok()?;
let f = get_desc(&item, "Fltr")?;
let mut q = [0.0; 8];
for i in 0..4 {
q[2 * i] = num(f.get(&format!("PuX{i}"))).filter(|v| v.is_finite())?;
q[2 * i + 1] = num(f.get(&format!("PuY{i}"))).filter(|v| v.is_finite())?;
}
Some(q)
}
/// `layer.smartObjects.puppetWarp` params for a Puppet Warp item with a readable mesh.
pub(super) fn import_params(item: &Descriptor) -> Option<J> {
let f = get_desc(item, "Fltr")?;
if !f.class_id.is(CLASS) {
return None;
}
let mut params = settings(f)?;
// Neutral blending in the template: the model's own fields replace it on export.
let mut template = item.clone();
let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
set(&mut template, "blendOptions", blend_options(&neutral));
set(&mut template, "enab", Value::Boolean(true));
params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes())));
Some(params)
}
/// The template item of `params`, checked to be a Puppet Warp filter.
fn template(params: &J) -> Result<Descriptor, String> {
let raw = params.get(TEMPLATE_KEY).and_then(J::as_str).ok_or("a Puppet Warp made in PhotoCraft is not written to PSD")?;
let bytes = from_hex(raw).ok_or("the Puppet Warp PSD data is not valid hex")?;
let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Puppet Warp PSD data is unreadable ({e})"))?;
if !item.class_id.is("filterFX") || !get_desc(&item, "Fltr").is_some_and(|f| f.class_id.is(CLASS)) {
return Err("the Puppet Warp PSD data is not a Puppet Warp filter".into());
}
Ok(item)
}
/// The `filterFXList` item: the template, while the pins are still the imported ones.
pub(super) fn export_item(params: &J) -> Result<Descriptor, String> {
let item = template(params)?;
let imported = get_desc(&item, "Fltr").and_then(settings).ok_or("the Puppet Warp PSD data has no readable mesh")?;
if params.get("pins") != imported.get("pins") {
return Err("Puppet Warp pins moved in PhotoCraft are not written to PSD".into());
}
// Imported params set no solver settings, so a set one is an edit the template doesn't hold.
if let Some(k) = ["mode", "density", "expansion"].into_iter().find(|k| params.get(*k).is_some()) {
return Err(format!("the Puppet Warp `{k}` set in PhotoCraft is not written to PSD"));
}
Ok(item)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter, parse_sold};
use photocraft_psd::descriptor::{UnicodeString, VersionedDescriptor};
const QUAD: [f64; 8] = [10.0, 20.0, 110.0, 22.0, 108.0, 220.0, 12.0, 218.0];
fn list(v: &[f64]) -> Value {
Value::List(v.iter().map(|x| Value::Double(*x)).collect())
}
fn f32s(v: &[[f32; 2]]) -> Value {
Value::RawData(v.iter().flatten().flat_map(|x| x.to_le_bytes()).collect())
}
fn i32s(v: &[i32]) -> Value {
Value::RawData(v.iter().flat_map(|x| x.to_le_bytes()).collect())
}
/// A Puppet Warp shape as Photoshop writes it: a box of two triangles with one pin.
fn shape(dx: f32) -> Descriptor {
let verts = [[10.0, 20.0], [110.0, 22.0], [108.0, 220.0], [12.0, 218.0]];
let moved: Vec<[f32; 2]> = verts.iter().map(|v| [v[0] + dx, v[1]]).collect();
Descriptor::new("puppetShape")
.with("rigidType", Value::Boolean(true))
.with("originalVertexArray", f32s(&verts))
.with("deformedVertexArray", f32s(&moved))
.with("indexArray", i32s(&[0, 1, 2, 0, 2, 3]))
.with("pinOffsets", list(&[0.0, 0.0]))
.with("posFinalPins", list(&[60.0 + f64::from(dx), 120.0]))
.with("pinVertexIndices", list(&[0.0]))
.with("PinP", list(&[60.0, 120.0]))
.with("meshExpansion", Value::Integer(2))
}
fn item(shapes: Vec<Descriptor>) -> Descriptor {
let mut f = Descriptor::new(CLASS)
.with("rigidType", Value::Boolean(true))
.with("puppetShapeList", Value::List(shapes.into_iter().map(Value::Descriptor).collect()));
for i in 0..4 {
f = f.with(&format!("PuX{i}"), Value::Double(QUAD[2 * i])).with(&format!("PuY{i}"), Value::Double(QUAD[2 * i + 1]));
}
let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
Descriptor::new("filterFX")
.with("Nm ", Value::Text(UnicodeString::new_nul("Puppet Warp")))
.with("blendOptions", blend_options(&neutral))
.with("enab", Value::Boolean(true))
.with("hasoptions", Value::Boolean(true))
.with("Fltr", Value::Descriptor(f))
.with("filterID", Value::Integer(973))
}
#[test]
fn puppet_items_map_to_the_command_with_their_mesh() {
let src = item(vec![shape(4.0), shape(-2.0)]);
let f = filter_from_item(&src);
assert_eq!(f.command, COMMAND);
assert_eq!(f.params["pins"], json!([{"src": [60.0, 120.0], "dst": [64.0, 120.0]}, {"src": [60.0, 120.0], "dst": [58.0, 120.0]}]));
let mesh = &f.params["mesh"];
assert_eq!(mesh["pins"], f.params["pins"]);
assert_eq!(mesh["verts"].as_array().unwrap().len(), 8);
assert_eq!(mesh["verts"][2], json!([108.0, 220.0]));
assert_eq!(mesh["deformed"][2], json!([112.0, 220.0]));
// The second shape's triangles index its own vertices.
assert_eq!(mesh["tris"], json!([[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7]]));
assert_eq!(built_on(&f.params), Some(QUAD));
// Unchanged pins write the original item back; blending is the model's.
assert_eq!(item_for_filter(&f).unwrap(), src);
let mut faded = f.clone();
faded.opacity = 0.5;
assert_eq!(filter_from_item(&item_for_filter(&faded).unwrap()).opacity, 0.5);
// Moved pins aren't written (the stored mesh would no longer match them).
let mut moved = f.clone();
moved.params["pins"][0]["dst"] = json!([70.0, 120.0]);
assert!(item_for_filter(&moved).is_err());
// Nor are solver settings set after import (the template holds the imported ones).
for (k, v) in [("mode", json!("rigid")), ("density", json!("more")), ("expansion", json!(2.0))] {
let mut edited = f.clone();
edited.params[k] = v;
assert!(item_for_filter(&edited).is_err(), "{k}");
}
// The import records the placement the mesh was solved for; the item written back is
// unchanged by it.
let mut sm = photocraft_doc::SmartObject::new(
photocraft_doc::SmartSource::Linked { path: String::new() },
photocraft_geom::Affine { m: [1.0, 0.0, 0.0, 1.0, 3.0, 4.0] },
None,
);
sm.smart_filters.push(f.clone());
crate::smart_map::pin_puppet_meshes(&mut sm);
assert_eq!(sm.smart_filters[0].params["mesh"]["placement"], sm.placement_json());
assert_eq!(item_for_filter(&sm.smart_filters[0]).unwrap(), src);
// A Puppet Warp made in PhotoCraft has no template: not written, as before.
let own = SmartFilter { params: json!({"pins": f.params["pins"]}), ..f };
assert!(item_for_filter(&own).is_err());
}
#[test]
fn unreadable_puppet_filters_stay_opaque() {
let with = |key: &str, v: Value| {
let mut s = shape(4.0);
set(&mut s, key, v);
item(vec![s])
};
for src in [
with("originalVertexArray", Value::RawData(vec![0; 31])),
with("originalVertexArray", f32s(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0]])),
with("deformedVertexArray", f32s(&[[f32::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])),
with("indexArray", i32s(&[0, 1, 4])),
with("indexArray", i32s(&[0, 1, -1])),
with("indexArray", Value::RawData(vec![0; 10])),
with("indexArray", i32s(&[])),
with("posFinalPins", list(&[60.0])),
with("PinP", Value::Integer(3)),
item(vec![]),
] {
let f = filter_from_item(&src);
assert_eq!(f.command, UNSUPPORTED_FILTER);
assert_eq!(item_for_filter(&f).unwrap(), src);
}
}
#[test]
fn a_mesh_built_on_other_corners_is_dropped() {
// The mesh is in document pixels where the layer was when it was built: a layer whose
// corners differ from the stored ones keeps the pins but not the mesh.
let sold = |quad: [f64; 8]| {
let fx = Descriptor::new("filterFXStyle")
.with("enab", Value::Boolean(true))
.with("filterFXList", Value::List(vec![Value::Descriptor(item(vec![shape(4.0)]))]));
let d = Descriptor::new("null").with("Trnf", list(&quad)).with("nonAffineTransform", list(&quad)).with("filterFX", Value::Descriptor(fx));
let mut v = b"soLD".to_vec();
v.extend_from_slice(&4u32.to_be_bytes());
v.extend(VersionedDescriptor::new(d).to_bytes());
v
};
let filter = |quad| parse_sold(&sold(quad)).and_then(|p| p.stack).map(|s| s.filters[0].clone()).expect("a stack");
let kept = filter(QUAD);
assert_eq!(kept.command, COMMAND);
assert!(kept.params.get("mesh").is_some());
let mut moved = QUAD;
moved[0] += 5.0;
let f = filter(moved);
assert_eq!(f.command, COMMAND);
assert!(f.params.get("mesh").is_none());
assert_eq!(f.params["pins"], kept.params["pins"]);
// Still written back as it was.
assert_eq!(item_for_filter(&f).unwrap(), item(vec![shape(4.0)]));
}
}
+38 -2
View File
@@ -25,7 +25,7 @@
//! (`HStr`) maps to its adjustment command in `hue_sat_filter_map.rs`.
use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{LayerMask, SmartFilter};
use photocraft_doc::{LayerMask, SmartFilter, SmartObject};
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
use photocraft_geom::{Affine, Rect as GeomRect};
use photocraft_psd::Compression;
@@ -49,6 +49,8 @@ mod displace;
mod hue_sat;
#[path = "perspective_warp_map.rs"]
mod perspective_warp;
#[path = "puppet_map.rs"]
mod puppet;
pub use displace::{embed_map as displace_embed_map, embedded_map as displace_embedded_map, without_map as displace_without_map};
pub use perspective_warp::{
@@ -133,10 +135,44 @@ pub fn parse_sold(data: &[u8]) -> Option<Placed> {
let d = sold_descriptor(data)?;
let (idnt, transform) = crate::blocks::parse_smart(b"SoLd", data);
let placed = text_of(&d, "placed").unwrap_or_default();
let stack = get_desc(&d, "filterFX").map(filter_stack);
let mut stack = get_desc(&d, "filterFX").map(filter_stack);
if let Some(stack) = &mut stack {
drop_moved_puppet_meshes(stack, &d);
}
Some(Placed { descriptor: d, idnt, placed, transform, stack })
}
/// A Puppet Warp's stored mesh sits where the layer was when it was built (document pixels); one
/// built on other corners than the layer's own doesn't fit it, so its pins are solved instead.
fn drop_moved_puppet_meshes(stack: &mut FilterStack, d: &Descriptor) {
let corners = |k: &str| match d.get(k) {
Some(Value::List(v)) if v.len() == 8 => v.iter().map(|x| num(Some(x))).collect::<Option<Vec<f64>>>(),
_ => None,
};
let quad = corners("nonAffineTransform").or_else(|| corners("Trnf"));
for f in stack.filters.iter_mut().filter(|f| f.command == puppet::COMMAND) {
let fits = match (&quad, puppet::built_on(&f.params)) {
(Some(q), Some(b)) => q.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.01),
_ => false,
};
if !fits && let Some(p) = f.params.as_object_mut() {
p.remove("mesh");
}
}
}
/// Records on each imported Puppet Warp mesh the placement it was solved for (`mesh.placement`,
/// [`SmartObject::placement_json`]): once the smart object is moved, transformed or warped, the
/// mesh no longer fits it and the pins are solved instead.
pub(crate) fn pin_puppet_meshes(sm: &mut SmartObject) {
let placement = sm.placement_json();
for f in sm.smart_filters.iter_mut().filter(|f| f.command == puppet::COMMAND) {
if let Some(m) = f.params.get_mut("mesh").and_then(J::as_object_mut) {
m.insert("placement".into(), placement.clone());
}
}
}
/// Reads a `filterFX` object.
pub fn filter_stack(fx: &Descriptor) -> FilterStack {
let filters = match fx.get("filterFXList") {
+7 -1
View File
@@ -7,7 +7,7 @@ use super::import::{
use super::placed::enumv;
use super::{
Class, Descriptor, FilterStack, Id, J, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, camera_raw, displace, hue_sat,
perspective_warp,
perspective_warp, puppet,
};
// ---------- writing ----------
@@ -309,6 +309,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result<Descriptor, String> {
if perspective_warp::handles(&f.command) {
return perspective_warp::export_item(f);
}
if f.command == puppet::COMMAND && f.params.get(puppet::TEMPLATE_KEY).is_some() {
let mut d = puppet::export_item(&f.params)?;
set(&mut d, "blendOptions", blend_options(f));
set(&mut d, "enab", Value::Boolean(f.visible));
return Ok(d);
}
if f.command == UNSUPPORTED_FILTER {
let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?;
let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?;
+2 -1
View File
@@ -2,7 +2,7 @@ use super::export::{CELL_SIZE, blend_options, is_meta, pi, rgbc, whole};
use super::placed::enumv;
use super::{
BlendMode, Descriptor, Id, J, Map, ReferenceItem, SmartFilter, UNSUPPORTED_FILTER, UnicodeString, Value, bool_or, camera_raw, displace, enum_of, get_desc,
hue_sat, json, num, perspective_warp, text_of,
hue_sat, json, num, perspective_warp, puppet, text_of,
};
// ---------- the filters we model ----------
@@ -867,6 +867,7 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter {
Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)),
Some(hue_sat::CLASS) => fltr.and_then(hue_sat::import_params).map(|p| (hue_sat::COMMAND, p)),
Some(perspective_warp::CLASS) => perspective_warp::import_params(item).map(|p| (perspective_warp::COMMAND, p)),
Some(puppet::CLASS) => puppet::import_params(item).map(|p| (puppet::COMMAND, p)),
Some(GALLERY_CLASS) => fltr.and_then(|f| gallery_params(item, f)).map(|p| (GALLERY_COMMAND, p)),
Some(c @ ("Fbrs" | "Clds" | "DfrC")) => fltr.and_then(|f| coloured_params(c, item, f)),
// A `Fltr` that is there but not a descriptor is settings we can't read: kept verbatim
+130
View File
@@ -0,0 +1,130 @@
//! Hasselblad 3FR / FFF: a TIFF whose IFD0 holds the camera's JPEG preview and
//! whose CFA image sits in a SubIFD with DNG-style tags (BlackLevel, WhiteLevel,
//! DefaultCropOrigin / DefaultCropSize, AsShotNeutral, ColorMatrix1) but no
//! DNGVersion and no CFA-pattern tag — neither the TIFF/EP 33421/33422 pair nor
//! the EXIF one — so the Bayer phase comes from measurements of each body.
//!
//! Decoded today: the X2D II 100C's 16-bit uncompressed samples (Compression 1,
//! one strip covering the whole sensor, masked borders included; the crop to the
//! usable area comes from the file's DefaultCrop tags). Bodies that store
//! lossless-JPEG strips (Compression 7, the earlier X and H generations) are
//! recognised but not decoded yet.
//!
//! Clean-room: derived from the TIFF 6.0 and DNG specifications, observation of
//! sample files, and the camera's own embedded JPEG — the oracle the
//! camera-profile measurements in [`crate::cameras`] use. No code or tables
//! from dcraw, LibRaw, rawspeed, rawler or darktable.
use crate::cr2::clip_level;
use crate::error::{RawError, Result};
use crate::sensor::{BlackLevels, Cfa, JpegLayout, Rect, Sensor, read_plane};
use crate::tiff::{Ifd, Tiff, tag};
use crate::tiffep;
use crate::{Limits, RawFormat};
/// The Bayer colours at the data origin, row-major (0 red, 1 green, 2 blue).
/// Hasselblad records no CFA tag, so a body's phase is measured against its own
/// embedded JPEG: on an X2D II 100C dusk landscape the blue sky reads red at the
/// origin site at about half the green rate while the blue site holds three
/// quarters of it, the reverse of the warm ground.
fn cfa_for(model: Option<&str>) -> (Cfa, bool) {
let measured = model.is_some_and(|m| m.eq_ignore_ascii_case("X2D II 100C"));
(Cfa { width: 2, height: 2, colors: vec![0, 1, 1, 2], origin_x: 0, origin_y: 0 }, measured)
}
pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result<Sensor> {
let ifds = t.all_ifds();
let Some(raw) = tiffep::raw_ifd(t, &ifds) else {
return Err(RawError::unsupported("Hasselblad 3FR/FFF: no CFA image found"));
};
match t.tag_uint(&raw, tag::COMPRESSION).unwrap_or(1) {
1 => {}
7 => return Err(RawError::unsupported("lossless-JPEG (compressed 3FR/FFF)")),
c => return Err(RawError::unsupported(format!("raw compression scheme {c}"))),
}
let plane = read_plane(t, &raw, limits, JpegLayout::Flat)?;
if plane.samples != 1 {
return Err(RawError::unsupported(format!("CFA data with {} samples per pixel", plane.samples)));
}
let make = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MAKE));
let model = ifds.iter().find_map(|i| t.tag_ascii(i, tag::MODEL));
let (cfa, measured) = cfa_for(model.as_deref());
let mut warnings = Vec::new();
if !measured {
warnings.push(format!(
"{}: this body's Bayer phase is not measured yet; assumed the X2D II 100C's (red at the data origin)",
model.as_deref().unwrap_or("Hasselblad")
));
}
let full = Rect::new(0, 0, plane.width, plane.height);
// DNG-style ActiveArea (top, left, bottom, right) in the raw IFD; an absent,
// inverted or out-of-plane one leaves the whole plane active.
let active = match t.tag_uints(&raw, tag::ACTIVE_AREA).as_slice() {
[top, left, bottom, right] if bottom > top && right > left => {
Rect::new(*left as usize, *top as usize, (*right - *left) as usize, (*bottom - *top) as usize).intersect(&full)
}
_ => full,
};
let active = if active.is_empty() { full } else { active };
// DNG-style DefaultCropOrigin / DefaultCropSize in the raw IFD, relative to
// the active area as in DNG.
let mut crop = active;
if let ([ox, oy], [cw, ch]) = (t.tag_floats(&raw, tag::DEFAULT_CROP_ORIGIN).as_slice(), t.tag_floats(&raw, tag::DEFAULT_CROP_SIZE).as_slice()) {
let f = |v: f64| if v.is_finite() && v >= 0.0 { v.round() as usize } else { 0 };
let r = Rect::new(active.x.saturating_add(f(*ox)), active.y.saturating_add(f(*oy)), f(*cw), f(*ch)).intersect(&active);
if !r.is_empty() {
crop = r;
}
}
let bl = t.tag_floats(&raw, tag::BLACK_LEVEL);
let black = match bl.as_slice() {
[b] if b.is_finite() && *b >= 0.0 => BlackLevels::uniform(*b as f32),
// A 2×2 repeat is in the CFA's own row-major order.
[r, g1, g2, b] if [*r, *g1, *g2, *b].iter().all(|v| v.is_finite() && *v >= 0.0) => {
BlackLevels { rows: 2, cols: 2, values: [r, g1, g2, b].map(|v| *v as f32).to_vec(), delta_h: Vec::new(), delta_v: Vec::new() }
}
_ => {
warnings.push("Hasselblad 3FR/FFF: black level not recorded; assumed 0".into());
BlackLevels::uniform(0.0)
}
};
let white =
t.tag_floats(&raw, tag::WHITE_LEVEL).first().copied().filter(|w| *w > 0.0).map(|w| w as f32).unwrap_or_else(|| clip_level(&plane.data, plane.bits));
// AsShotNeutral (IFD0) is the camera-space colour of a neutral patch: the
// green-normalised multipliers are the other channels over green.
let camera_wb = ifds
.iter()
.find_map(|i: &Ifd| t.tag_floats(i, tag::AS_SHOT_NEUTRAL).try_into().ok())
.and_then(|n: [f64; 3]| match n {
[r, g, b] if g > 0.0 && r > 0.0 && b > 0.0 => Some([g / r, 1.0, g / b]),
_ => None,
})
.filter(|m| m.iter().all(|v| (0.25..8.0).contains(v)));
let ifd0 = t.ifd_at(t.first_ifd, 0);
let baseline_exposure = ifd0.as_ref().map(|i| t.tag_floats(i, tag::BASELINE_EXPOSURE).first().copied().unwrap_or(0.0)).unwrap_or(0.0);
let mut sensor = Sensor {
format: RawFormat::Fff,
make,
model,
width: plane.width,
height: plane.height,
samples: 1,
data: plane.data,
cfa: Some(cfa),
linearization: None,
black,
white: [white; 3],
active,
crop,
color: Default::default(),
camera_wb,
orientation: crate::tiff::orientation(ifd0.as_ref().and_then(|i| t.tag_uint(i, tag::ORIENTATION))),
baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 },
gain_maps: Vec::new(),
tone_curve: Vec::new(),
black_point: 0.0,
warnings,
};
crate::cameras::apply(&mut sensor, plane.bits);
Ok(sensor)
}
+11 -3
View File
@@ -33,8 +33,9 @@
//! lossless-JPEG TIFF/EP raws (NEF, ARW, PEF… when not vendor-compressed),
//! Nikon compressed NEF (lossless and lossy), Sony compressed ARW (cRAW),
//! Panasonic RW2 (RawFormat 5 packed and RawFormat 3/4 vendor-compressed),
//! uncompressed Olympus ORF and uncompressed Fujifilm RAF (Bayer and
//! X-Trans). Everything else reports [`RawError::Unsupported`].
//! uncompressed Olympus ORF, uncompressed Fujifilm RAF (Bayer and
//! X-Trans), and Hasselblad 3FR/FFF (the X2D II 100C's uncompressed 16-bit CFA;
//! see [`crate::hasselblad`]). Everything else reports [`RawError::Unsupported`].
//!
//! The crate is standalone (no workspace dependencies), does no I/O, builds for
//! `wasm32-unknown-unknown` and never panics on hostile input: sizes are
@@ -51,6 +52,7 @@ mod demosaic;
mod develop;
mod dng;
mod error;
mod hasselblad;
mod jxl;
mod ljpeg;
mod look;
@@ -119,6 +121,8 @@ pub enum RawFormat {
Rw2,
/// Fujifilm RAF.
Raf,
/// Hasselblad 3FR / FFF.
Fff,
/// Another TIFF/EP raw.
TiffEp,
}
@@ -135,13 +139,14 @@ impl RawFormat {
RawFormat::Orf => "ORF",
RawFormat::Rw2 => "RW2",
RawFormat::Raf => "RAF",
RawFormat::Fff => "3FR/FFF",
RawFormat::TiffEp => "TIFF/EP raw",
}
}
}
/// File extensions of the raw formats this crate recognises.
pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf"];
pub const EXTENSIONS: &[&str] = &["dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", "pef", "orf", "rw2", "raf", "3fr", "fff"];
/// Decompression-bomb guards, checked on header values before allocating
/// (the same shape and defaults as `photocraft-codecs`' limits).
@@ -195,6 +200,8 @@ fn make_format(make: Option<&str>) -> RawFormat {
RawFormat::Pef
} else if m.starts_with("OLYMPUS") || m.starts_with("OM DIGITAL") {
RawFormat::Orf
} else if m.starts_with("HASSELBLAD") {
RawFormat::Fff
} else {
RawFormat::TiffEp
}
@@ -249,6 +256,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result<Sensor, RawError> {
RawFormat::Cr2 => cr2::decode(&t, limits),
RawFormat::Rw2 => rw2::decode(&t, limits),
RawFormat::Orf => orf::decode(&t, limits),
RawFormat::Fff => hasselblad::decode(&t, limits),
f => tiffep::decode(&t, f, limits),
}
}
+2
View File
@@ -9,6 +9,7 @@
mod canon;
mod dng;
mod hasselblad;
mod lj92;
mod orf;
mod raf;
@@ -17,6 +18,7 @@ mod tiff;
pub use canon::{Cr2Spec, Cr2SrawSpec, craw_block, craw_encode, nef_compressed, sony_craw, tiff_ep};
pub use dng::{DngSpec, DngStorage, baseline_jpeg, gain_map_opcode_list, map_polynomial_opcode_list};
pub use hasselblad::Hasselblad3frSpec;
pub use lj92::{lj92_encode, lj92_encode_sraw, lj92_encode_sraw_tables, lj92_encode_tables};
pub use orf::orf;
pub use raf::{RafPacking, RafSpec, XTRANS, mosaic, mosaic_cfa, scene};
+106
View File
@@ -0,0 +1,106 @@
use super::tiff::{TiffBuilder, Val, urat};
// ---------------------------------------------------------------- Hasselblad 3FR
/// A synthetic Hasselblad 3FR, as the X2D II 100C writes it: IFD0 with Make,
/// Model and DNG-style AsShotNeutral, and the CFA image in a SubIFD —
/// `bits`-bit uncompressed samples with BlackLevel, WhiteLevel and DefaultCrop
/// tags and no CFA-pattern tags. `compression` and the optional tags exist for
/// the unsupported and hostile variants.
#[derive(Debug, Clone)]
pub struct Hasselblad3frSpec {
pub width: usize,
pub height: usize,
/// Row-major sensor samples.
pub data: Vec<u16>,
pub bits: u16,
pub compression: u16,
/// The BlackLevel RATIONAL; `None` leaves the tag out.
pub black: Option<(u32, u32)>,
/// The WhiteLevel; `None` leaves the tag out.
pub white: Option<u16>,
/// AsShotNeutral; `None` leaves the tag out.
pub as_shot_neutral: Option<[f64; 3]>,
/// DefaultCropOrigin (x, y) and DefaultCropSize (w, h); `None` leaves both out.
pub crop: Option<([u16; 2], [u16; 2])>,
/// ActiveArea (top, left, bottom, right); `None` leaves the tag out.
pub active_area: Option<[u32; 4]>,
pub make: String,
pub model: String,
}
impl Hasselblad3frSpec {
/// An X2D II 100C-shaped file: 16-bit uncompressed, black 4096, white
/// 64763, the sample file's as-shot neutral, no crop.
pub fn new(width: usize, height: usize, data: Vec<u16>) -> Self {
Hasselblad3frSpec {
width,
height,
data,
bits: 16,
compression: 1,
black: Some((4096, 1)),
white: Some(64763),
as_shot_neutral: Some([0.375, 1.0, 0.617]),
crop: None,
active_area: None,
make: "Hasselblad".into(),
model: "X2D II 100C".into(),
}
}
pub fn build(&self) -> Vec<u8> {
let mut t = TiffBuilder::default();
let len = self.data.len() * 2;
let strip = t.blob(self.data.iter().flat_map(|&v| v.to_le_bytes()).collect());
let mut raw = vec![
(254, Val::Long(vec![0])),
(256, Val::Long(vec![self.width as u32])),
(257, Val::Long(vec![self.height as u32])),
(258, Val::Short(vec![self.bits])),
(259, Val::Short(vec![self.compression])),
(262, Val::Short(vec![32803])),
(273, Val::Blobs(vec![strip])),
(277, Val::Short(vec![1])),
(278, Val::Long(vec![self.height as u32])),
(279, Val::Long(vec![len as u32])),
];
if let Some((n, d)) = self.black {
raw.push((50714, Val::Rational(vec![(n, d)])));
}
if let Some(w) = self.white {
raw.push((50717, Val::Short(vec![w])));
}
if let Some((origin, size)) = &self.crop {
raw.push((50719, Val::Short(origin.to_vec())));
raw.push((50720, Val::Short(size.to_vec())));
}
if let Some(area) = self.active_area {
raw.push((50829, Val::Long(area.to_vec())));
}
let raw_ifd = t.ifd(raw);
let thumb = t.blob(vec![128; 12]);
let mut ifd0 = vec![
(254, Val::Long(vec![1])),
(256, Val::Long(vec![2])),
(257, Val::Long(vec![2])),
(258, Val::Short(vec![8, 8, 8])),
(259, Val::Short(vec![7])),
(262, Val::Short(vec![6])),
(271, Val::Ascii(self.make.clone())),
(272, Val::Ascii(self.model.clone())),
(273, Val::Blobs(vec![thumb])),
(277, Val::Short(vec![3])),
(278, Val::Long(vec![2])),
(279, Val::Long(vec![12])),
(330, Val::Ifds(vec![raw_ifd])),
(50708, Val::Ascii(format!("{} {}", self.make, self.model))),
];
if let Some(n) = self.as_shot_neutral {
ifd0.push((50728, Val::Rational(n.iter().map(|&v| urat(v)).collect())));
}
let i0 = t.ifd(ifd0);
t.chain = vec![i0];
t.build()
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ const NIKON_BLACK_LEVEL: u16 = 0x003D;
const SONY_BLACK_LEVEL: u16 = 0x7310;
const SONY_WB_RGGB_LEVELS: u16 = 0x7313;
fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option<Ifd> {
pub(crate) fn raw_ifd(t: &Tiff, ifds: &[Ifd]) -> Option<Ifd> {
ifds.iter()
.filter(|i| t.tag_uint(i, tag::PHOTOMETRIC) == Some(PHOTOMETRIC_CFA))
.max_by_key(|i| u64::from(t.tag_uint(i, tag::IMAGE_WIDTH).unwrap_or(0)) * u64::from(t.tag_uint(i, tag::IMAGE_LENGTH).unwrap_or(0)))
+143
View File
@@ -0,0 +1,143 @@
//! Synthetic Hasselblad 3FR files (the X2D II 100C layout): identification,
//! exact samples, metadata, the DefaultCrop area, and the unsupported and
//! hostile variants.
use photocraft_raw::testgen::{Hasselblad3frSpec, mosaic, scene};
use photocraft_raw::*;
const RGGB: [u8; 4] = [0, 1, 1, 2];
fn spec() -> Hasselblad3frSpec {
let (w, h) = (16, 12);
Hasselblad3frSpec::new(w, h, mosaic(&scene(w, h), w, RGGB, 4096, 64763))
}
#[test]
fn identifies_as_fff() {
let b = spec().build();
assert_eq!(identify(&b), Some(RawFormat::Fff));
assert!(is_raw(&b));
assert!(EXTENSIONS.contains(&"3fr") && EXTENSIONS.contains(&"fff"));
}
#[test]
fn decodes_exactly() {
let s = spec();
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!((sensor.width, sensor.height), (16, 12));
assert_eq!(sensor.samples, 1);
assert_eq!(sensor.data, s.data, "the samples are the stored strip");
let cfa = sensor.cfa.as_ref().unwrap();
assert_eq!((cfa.width, cfa.height), (2, 2));
for (i, &c) in RGGB.iter().enumerate() {
assert_eq!(cfa.color(i % 2, i / 2), c, "position {i}");
}
assert!(cfa.is_bayer());
assert_eq!(sensor.format, RawFormat::Fff);
}
#[test]
fn metadata() {
let mut s = spec();
s.crop = Some(([2, 2], [12, 8]));
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!(sensor.crop, Rect::new(2, 2, 12, 8));
assert_eq!(sensor.active, Rect::new(0, 0, 16, 12));
assert_eq!(sensor.black, BlackLevels::uniform(4096.0));
assert_eq!(sensor.white, [64763.0; 3]);
let wb = sensor.camera_wb.unwrap();
assert!((wb[0] - 1.0 / 0.375).abs() < 1e-9, "{wb:?}");
assert!((wb[2] - 1.0 / 0.617).abs() < 1e-9, "{wb:?}");
assert_eq!((sensor.make.as_deref(), sensor.model.as_deref()), (Some("Hasselblad"), Some("X2D II 100C")));
assert_eq!(sensor.orientation, 1);
assert!(sensor.warnings.is_empty(), "{:?}", sensor.warnings);
}
#[test]
fn develops() {
let mut s = spec();
s.crop = Some(([2, 2], [12, 8]));
let b = s.build();
for m in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] {
let d = develop(&b, &DevelopOptions { demosaic: m, ..Default::default() }).unwrap_or_else(|e| panic!("{m:?}: {e}"));
assert_eq!((d.width, d.height), (12, 8), "{m:?}");
}
}
#[test]
fn unknown_body_warns_about_the_assumed_phase() {
let mut s = spec();
s.model = "X2D 100C".into();
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert!(sensor.warnings.iter().any(|w| w.contains("Bayer phase")), "{:?}", sensor.warnings);
}
#[test]
fn compressed_3fr_is_reported_unsupported() {
let mut s = spec();
s.compression = 7;
let e = decode(&s.build(), &Limits::default()).unwrap_err();
assert!(matches!(&e, RawError::Unsupported(reason) if reason.contains("lossless-JPEG")), "{e:?}");
}
#[test]
fn missing_levels_fall_back() {
let mut s = spec();
// Clipped highlights, so the clipping point stands in for the missing WhiteLevel.
for v in s.data.iter_mut().step_by(8) {
*v = u16::MAX;
}
s.black = None;
s.white = None;
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!(sensor.black, BlackLevels::uniform(0.0));
assert!(sensor.warnings.iter().any(|w| w.contains("black level")), "{:?}", sensor.warnings);
// clip_level backs the clipped plateau off slightly.
assert!((sensor.white[0] - f32::from(u16::MAX) * 0.995).abs() < 1.0, "{:?}", sensor.white);
}
#[test]
fn a_crop_outside_the_plane_is_dropped() {
let mut s = spec();
s.crop = Some(([100, 100], [4, 4]));
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!(sensor.crop, Rect::new(0, 0, 16, 12));
}
#[test]
fn active_area_bounds_the_image_and_the_crop() {
let mut s = spec();
s.active_area = Some([1, 2, 11, 14]);
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!(sensor.active, Rect::new(2, 1, 12, 10));
assert_eq!(sensor.crop, Rect::new(2, 1, 12, 10), "no default crop: the active area");
let d = develop(&s.build(), &DevelopOptions::default()).unwrap();
assert_eq!((d.width, d.height), (12, 10));
// The default crop is relative to the active area, as in DNG.
s.crop = Some(([1, 1], [8, 6]));
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!(sensor.crop, Rect::new(3, 2, 8, 6));
}
#[test]
fn an_active_area_outside_the_plane_is_ignored() {
let mut s = spec();
s.active_area = Some([100, 100, 200, 200]);
let sensor = decode(&s.build(), &Limits::default()).unwrap();
assert_eq!((sensor.active, sensor.crop), (Rect::new(0, 0, 16, 12), Rect::new(0, 0, 16, 12)));
}
#[test]
fn truncated_strip_is_malformed() {
let mut b = spec().build();
// The preview blob sits past the strip; cutting 60 bytes eats into the strip.
b.truncate(b.len() - 60);
assert!(matches!(decode(&b, &Limits::default()), Err(RawError::Malformed(_))));
}
#[test]
fn zero_dimensions_are_malformed() {
let mut s = spec();
s.width = 0;
assert!(matches!(decode(&s.build(), &Limits::default()), Err(RawError::Malformed(_))));
}
+1
View File
@@ -3,6 +3,7 @@
mod cr3;
mod development_limits;
mod dng;
mod hasselblad;
mod hostile;
mod nef_packed;
mod nikon_d4_depth;
+5 -2
View File
@@ -133,6 +133,7 @@ impl PhotocraftApp {
synthetic_reinject: false,
synthetic_snapshot: None,
automation_input: false,
programmatic_playback: false,
run_depth: 0,
paused_playbacks: Default::default(),
channel_thumbs: None,
@@ -169,7 +170,7 @@ impl PhotocraftApp {
distort: Default::default(),
gradient: Default::default(),
camera_raw: None,
pending_raw_open: None,
raw_shelf: Vec::new(),
raw_redevelop: Vec::new(),
wide_angle: None,
dialog_edits: Default::default(),
@@ -208,6 +209,8 @@ impl PhotocraftApp {
held_memory_prompts: Default::default(),
prompt_order: Default::default(),
held_preset_import: None,
held_record_again: None,
held_insert_menu: None,
allow_close: false,
stylus: Default::default(),
control_click: Default::default(),
@@ -234,7 +237,7 @@ impl PhotocraftApp {
impl eframe::App for PhotocraftApp {
fn on_exit(&mut self) {
if let Err(err) = tool_memory::save_on_exit(self) {
log::warn!("Preferences: could not save the final tool selection: {err}");
log::warn!("Preferences: could not save the final tool state: {err}");
}
// Release a pending screenshot's image budget and answer its control request; the
// callback is inert afterwards (it holds only a weak slot).
+9
View File
@@ -6,6 +6,7 @@ use super::{
impl PhotocraftApp {
/// Run an engine command, reporting errors in the status bar.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
let params = if id == "select.findLayers" { crate::layer_filter_ui::find::params_for_run(self, params) } else { params };
if let Some(result) = delete_layer_prompt::intercept(self, id, &params) {
return result;
}
@@ -31,6 +32,13 @@ impl PhotocraftApp {
}
self.sync_mask_targets();
}
// Find Layers sets the Layers panel's Name filter however it runs: the menu, the shortcut,
// `engine.execute` or an action step (#10884).
if id == "select.findLayers"
&& let Ok(reply) = &result
{
crate::layer_filter_ui::find::apply(self, reply);
}
result
}
@@ -264,6 +272,7 @@ impl PhotocraftApp {
| "path.toSelection"
| "path.addToSelection"
| "path.subtractFromSelection"
| "path.intersectWithSelection"
)
&& self.session.active().is_some_and(|st| st.doc.selection.is_some())
{
@@ -85,14 +85,15 @@ fn a_click_with_the_mode_off_still_places_nothing() {
}
#[test]
fn with_no_board_the_click_uses_the_canvas_size() {
fn with_no_board_the_click_uses_the_bars_tool_option_size() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600, "background": "white"})).unwrap();
app.sync_views();
app.ui.tool = Tool::Artboard;
app.ui.artboard_tool.adding = true;
click(&mut app, 40.0, 50.0);
assert_eq!(boards(&app), vec![Rect::new(40, 50, 840, 650)]);
// The size the bar shows (the iPhone 8/7/6 default), not the canvas's 800 x 600.
assert_eq!(boards(&app), vec![Rect::new(40, 50, 790, 1384)]);
}
#[test]
@@ -0,0 +1,538 @@
//! The Artboard tool's options bar (#11385): the iPhone 8/7/6 default size, the background for the
//! boards it draws, the Align panel and the gear menu, and what Reset Tool puts back.
use egui::Modifiers;
use photocraft_doc::{ArtboardBackground, LayerId};
use photocraft_geom::Rect;
use serde_json::json;
use crate::PhotocraftApp;
use crate::artboard_tool::ArtboardToolUi;
use crate::artboard_tool::bar::{self, ALIGN_PANEL, GEAR, Gear};
use crate::canvas::{ToolEvent, tool_event};
use crate::state::Tool;
fn app_with_depth(depth: u32) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 800, "height": 600, "depth": depth})).unwrap();
app.sync_views();
app.ui.extras.snap = false;
app.ui.view.show.smart_guides = false;
app.ui.tool = Tool::Artboard;
app
}
fn app() -> PhotocraftApp {
app_with_depth(8)
}
fn drag(app: &mut PhotocraftApp, from: [f64; 2], to: [f64; 2]) {
let m = Modifiers::NONE;
tool_event(app, ToolEvent::Down { x: from[0], y: from[1], pressure: 1.0 }, m);
tool_event(app, ToolEvent::Move { x: to[0], y: to[1], pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x: to[0], y: to[1] }, m);
}
fn boards(app: &PhotocraftApp) -> Vec<(LayerId, Rect, ArtboardBackground)> {
app.session.active().unwrap().doc.artboards().iter().map(|b| (b.0, b.2.rect, b.2.background)).collect()
}
fn reset_tool(app: &mut PhotocraftApp) {
crate::menus::invoke(app, &egui::Context::default(), crate::tool_reset::RESET, json!({})).unwrap();
}
#[test]
fn the_presets_list_has_iphone_8_7_6() {
assert!(photocraft_engine::artboard_cmds::PRESETS.contains(&("iPhone 8/7/6", 750, 1334)));
assert!(photocraft_engine::artboard_cmds::PRESETS.contains(&("iPhone 14", 390, 844)), "the existing entries stay");
// The engine takes the new preset by name.
let mut app = app();
let r = app.run("layer.new.artboard", json!({"preset": "iPhone 8/7/6", "x": 0, "y": 0})).unwrap();
assert_eq!(r["rect"], json!([0, 0, 750, 1334]));
}
#[test]
fn a_fresh_bar_has_the_measured_defaults() {
let app = app();
let o = &app.ui.artboard_tool;
assert_eq!((o.preset.as_str(), o.width, o.height), ("iPhone 8/7/6", 750, 1334));
assert_eq!((o.background.as_str(), o.background_color.as_str()), ("white", "#ffffff"));
assert!(GEAR.iter().all(|(g, _)| g.checked(o)), "all four gear entries are checked");
assert_eq!(bar::DEFAULT_SIZE, ("iPhone 8/7/6", 750, 1334));
}
#[test]
fn reset_tool_restores_the_options_and_only_for_the_artboard_tool() {
let mut app = app();
bar::set_size(&mut app, 200.0, 300.0);
bar::set_custom_background(&mut app, "#336699").unwrap();
app.ui.artboard_tool.auto_nest = false;
app.ui.artboard_tool.shrinkwrap_on_save = false;
// Another tool's Reset Tool leaves them.
app.ui.tool = Tool::Move;
reset_tool(&mut app);
assert_eq!(app.ui.artboard_tool.width, 200);
// The Artboard tool's puts every one back.
app.ui.tool = Tool::Artboard;
reset_tool(&mut app);
assert_eq!(app.ui.artboard_tool, ArtboardToolUi::default());
assert_eq!(app.ui.artboard_tool.height, 1334);
// Reset All Tools too.
bar::set_preset(&mut app, "A4");
app.ui.artboard_tool.auto_size_canvas = false;
crate::menus::invoke(&mut app, &egui::Context::default(), crate::tool_reset::RESET_ALL, json!({"confirmed": true})).unwrap();
assert_eq!(app.ui.artboard_tool, ArtboardToolUi::default());
}
#[test]
fn the_options_are_serde_tool_options() {
let mut app = app();
bar::set_preset(&mut app, "Letter");
app.ui.artboard_tool.keep_relative_position = false;
let v = serde_json::to_value(&app.ui.artboard_tool).unwrap();
assert_eq!(v["preset"], "Letter");
assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(612), Some(792)));
assert_eq!(v["keep_relative_position"], false);
assert_eq!(v["auto_nest"], true);
assert!(v.get("drag").is_none(), "the drag in progress is not saved");
let back: ArtboardToolUi = serde_json::from_value(v).unwrap();
assert_eq!(back, app.ui.artboard_tool);
// Settings saved before these options existed load with the defaults.
let old: ArtboardToolUi = serde_json::from_value(json!({})).unwrap();
assert_eq!(old, ArtboardToolUi::default());
// The whole UI state carries them, so `ui.inspect` reaches them.
let ui = serde_json::to_value(&app.ui).unwrap();
assert_eq!(ui["artboard_tool"]["preset"], "Letter");
}
#[test]
fn width_and_height_edit_the_tool_option_when_no_board_is_selected() {
let mut app = app();
assert!(boards(&app).is_empty());
assert_eq!(bar::bar_size(&app), [750, 1334]);
bar::set_size(&mut app, 400.0, 500.0);
assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (400, 500));
assert_eq!(app.ui.artboard_tool.preset, "", "a size that is no preset's reads Custom");
assert_eq!(bar::bar_size(&app), [400, 500]);
// Back to a preset's size: the preset reads again only when chosen; choosing it sets the size.
bar::set_preset(&mut app, "iPhone 14");
assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (390, 844));
assert_eq!(app.ui.artboard_tool.preset, "iPhone 14");
// Out of range and not a number are held to 1..=300000.
bar::set_size(&mut app, 0.0, 1.0e9);
assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (1, 300_000));
bar::set_size(&mut app, f32::NAN, 5.0);
assert_eq!(app.ui.artboard_tool.width, 1);
// No document change.
assert_eq!(app.session.active().unwrap().doc.size.width, 800);
assert!(boards(&app).is_empty());
assert!(!app.session.active().unwrap().history.entries().iter().any(|e| e.contains("Artboard")));
}
#[test]
fn with_a_board_selected_the_bar_size_is_the_boards_and_the_options_stay() {
let mut app = app();
bar::set_size(&mut app, 400.0, 500.0);
drag(&mut app, [100.0, 100.0], [300.0, 300.0]);
assert_eq!(boards(&app).first().map(|b| b.1), Some(Rect::new(100, 100, 300, 300)));
// A 200 x 200 board reads 200 x 200 (Custom), not the options.
assert_eq!(bar::bar_size(&app), [200, 200]);
let id = boards(&app)[0].0;
let preset = app.session.active().unwrap().doc.layer(id).unwrap().artboard().unwrap().preset.clone();
assert_eq!(preset, "", "Custom");
assert_eq!((app.ui.artboard_tool.width, app.ui.artboard_tool.height), (400, 500), "the tool option is untouched");
// Choosing iPhone 8/7/6 resizes the board in one step and keeps its top-left corner.
let steps = app.session.active().unwrap().history.entries().len();
app.run("layer.artboard.set", json!({"layer": id.0, "preset": "iPhone 8/7/6"})).unwrap();
assert_eq!(boards(&app)[0].1, Rect::new(100, 100, 850, 1434));
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1);
assert_eq!(bar::bar_size(&app), [750, 1334]);
}
#[test]
fn bar_size_helper_prefers_the_selected_board() {
let mut app = app();
assert_eq!(bar::bar_size(&app), [750, 1334]);
app.run("layer.new.artboard", json!({"rect": [0, 0, 120, 90]})).unwrap();
assert_eq!(bar::bar_size(&app), [120, 90]);
}
/// The background reaches `layer.new.artboard` for a board the tool draws, at 8 and 16 bit.
#[test]
fn a_drawn_board_gets_the_bars_background() {
for depth in [8, 16] {
for (name, want) in [("white", ArtboardBackground::White), ("black", ArtboardBackground::Black), ("transparent", ArtboardBackground::Transparent)] {
let mut app = app_with_depth(depth);
app.ui.artboard_tool.background = name.to_string();
drag(&mut app, [50.0, 50.0], [150.0, 120.0]);
assert!(!app.ui.status_error, "{depth} bit {name}: {}", app.ui.status);
let b = boards(&app);
assert_eq!(b.len(), 1, "{depth} bit {name}");
assert_eq!((b[0].1, b[0].2), (Rect::new(50, 50, 150, 120), want), "{depth} bit {name}");
}
// Other…: the picked colour.
let mut app = app_with_depth(depth);
bar::set_custom_background(&mut app, "#ff0000").unwrap();
drag(&mut app, [50.0, 50.0], [150.0, 120.0]);
let ArtboardBackground::Custom(c) = boards(&app)[0].2 else { panic!("{depth} bit: a custom background") };
assert_eq!(c.to_rgba8(), [255, 0, 0, 255], "{depth} bit");
}
}
/// A board placed by the Add New Artboard click takes the bar's background too.
#[test]
fn a_placed_board_gets_the_bars_background() {
let place = |app: &mut PhotocraftApp| {
app.ui.artboard_tool.adding = true;
let m = Modifiers::NONE;
tool_event(app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x: 20.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
};
let mut app = self::app();
place(&mut app);
assert_eq!(boards(&app).len(), 1);
assert_eq!(boards(&app)[0].2, ArtboardBackground::White, "the default setting keeps white");
let mut app = self::app();
app.ui.artboard_tool.background = "transparent".to_string();
place(&mut app);
assert_eq!(boards(&app)[0].2, ArtboardBackground::Transparent);
let mut app = self::app();
bar::set_custom_background(&mut app, "#00ff00").unwrap();
place(&mut app);
let ArtboardBackground::Custom(c) = boards(&app)[0].2 else { panic!("a custom background") };
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
}
/// With no board selected, a placed board takes the size the bar shows (the tool option), not the canvas's.
#[test]
fn a_placed_board_takes_the_bars_size_with_no_board_selected() {
let mut app = app();
assert!(boards(&app).is_empty());
bar::set_size(&mut app, 333.0, 222.0);
app.ui.artboard_tool.adding = true;
let m = Modifiers::NONE;
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, m);
tool_event(&mut app, ToolEvent::Up { x: 20.0, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
assert_eq!(boards(&app).first().map(|b| b.1), Some(Rect::new(20, 30, 353, 252)));
}
/// A placed board keeps the bar's preset name, so selected it shows that preset, not Custom.
#[test]
fn a_placed_board_keeps_the_bars_preset_name() {
let mut app = app();
let place = |app: &mut PhotocraftApp, x: f64| {
app.ui.artboard_tool.adding = true;
let m = Modifiers::NONE;
tool_event(app, ToolEvent::Down { x, y: 30.0, pressure: 1.0 }, m);
tool_event(app, ToolEvent::Up { x, y: 30.0 }, m);
assert!(!app.ui.status_error, "{}", app.ui.status);
let (id, rect, _) = *boards(app).last().unwrap();
app.run("layer.select", json!({"layer": id.0})).unwrap();
(id, rect)
};
let (id, rect) = place(&mut app, 20.0);
assert_eq!((rect.width(), rect.height()), (750, 1334));
assert_eq!(bar::board_preset(&app, id), "iPhone 8/7/6");
let stored = app.session.active().unwrap().doc.layer(id).unwrap().artboard().unwrap().preset.clone();
assert_eq!(stored, "iPhone 8/7/6");
// Deselect, edit the size by hand: the preset is Custom and so is the next placed board.
app.session.active_mut().unwrap().active_layer = None;
bar::set_size(&mut app, 333.0, 222.0);
let (id2, rect2) = place(&mut app, 2000.0);
assert_eq!((rect2.width(), rect2.height()), (333, 222));
assert_eq!(bar::board_preset(&app, id2), "");
}
/// OK in the picker with the colour the swatch shows leaves the board's own colour alone.
#[test]
fn ok_in_the_picker_without_a_change_leaves_the_boards_alone() {
let mut app = app();
let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": [0.2, 0.4, 0.6, 0.5]})).unwrap();
app.run("layer.select", json!({"layer": a})).unwrap();
let before = (steps(&app), boards(&app)[0].2);
let hex = bar::shown_background(&app).1;
let id = crate::color_picker_ui::open_for_target(&mut app, "artboardBackground", "Color Picker (Color)", crate::color_picker_ui::parse_hex(&hex).unwrap());
let fields = app.ui.dialog_mut(id).unwrap().fields.clone();
crate::color_picker_ui::confirm(&mut app, &fields).unwrap();
assert_eq!((steps(&app), boards(&app)[0].2), before, "no history step, colour bit-exact");
// A real change still recolours.
bar::set_custom_background(&mut app, "#00ff00").unwrap();
assert_eq!(steps(&app), before.0 + 1);
assert_ne!(boards(&app)[0].2, before.1);
}
/// Two boards, both selected.
fn two_boards(app: &mut PhotocraftApp) -> (u64, u64) {
let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap();
let b = app.run("layer.new.artboard", json!({"rect": [300, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.select", json!({"layer": a})).unwrap();
app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
(a, b)
}
fn steps(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().history.past_len()
}
fn last_label(app: &PhotocraftApp) -> Option<String> {
app.session.active().unwrap().history.entries().last().cloned()
}
/// Two selected boards with different custom colours; the first board's colour is the swatch's.
fn two_coloured_boards(app: &mut PhotocraftApp) -> (u64, u64) {
let (a, b) = two_boards(app);
app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": "#336699"})).unwrap();
app.run("layer.artboard.set", json!({"layer": b, "background": "custom", "color": "#cc3300"})).unwrap();
(a, b)
}
/// Open the Color Picker from the swatch (as the bar does) and OK it with `color`, or as opened.
fn pick_from_swatch(app: &mut PhotocraftApp, color: Option<&str>) {
let hex = bar::shown_background(app).1;
let id = crate::color_picker_ui::open_for_target(app, "artboardBackground", "Color Picker (Color)", crate::color_picker_ui::parse_hex(&hex).unwrap());
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
if let Some(c) = color {
fields.insert("color".into(), json!(c));
}
crate::color_picker_ui::confirm(app, &fields).unwrap();
}
fn colours(app: &PhotocraftApp) -> Vec<ArtboardBackground> {
boards(app).iter().map(|b| b.2).collect()
}
#[test]
fn an_unchanged_ok_with_boards_of_different_colours_leaves_every_board_alone() {
let mut app = app();
two_coloured_boards(&mut app);
let before = (steps(&app), colours(&app));
pick_from_swatch(&mut app, None);
assert_eq!((steps(&app), colours(&app)), before, "no history step, both colours bit-exact");
assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#336699"));
}
#[test]
fn a_changed_ok_with_boards_of_different_colours_recolours_both_in_one_step() {
let mut app = app();
two_coloured_boards(&mut app);
let steps0 = steps(&app);
pick_from_swatch(&mut app, Some("#00ff00"));
assert_eq!(steps(&app), steps0 + 1);
assert_eq!(last_label(&app).as_deref(), Some("Edit Artboards"));
let green = |c: &ArtboardBackground| matches!(c, ArtboardBackground::Custom(c) if c.to_rgba8()[..3] == [0, 255, 0]);
assert!(colours(&app).iter().all(green));
}
#[test]
fn an_unchanged_ok_with_only_web_colors_leaves_the_boards_alone() {
let mut app = app();
app.ui.color_panel.web_only = true;
let (a, _) = two_boards(&mut app);
app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": [0.2, 0.4, 0.61, 1.0]})).unwrap();
let before = (steps(&app), colours(&app));
pick_from_swatch(&mut app, None);
assert_eq!((steps(&app), colours(&app)), before, "no history step, colours bit-exact");
}
#[test]
fn a_colour_some_boards_already_have_recolours_only_the_others() {
let mut app = app();
let (_, b) = two_coloured_boards(&mut app);
let b_before = colours(&app)[1];
let steps0 = steps(&app);
pick_from_swatch(&mut app, Some("#cc3300"));
assert_eq!(steps(&app), steps0 + 1);
assert_eq!(last_label(&app).as_deref(), Some("Edit Artboard"), "the label follows the one board that changed");
assert_eq!(colours(&app)[1], b_before, "board {b} keeps its own value");
assert!(matches!(colours(&app)[0], ArtboardBackground::Custom(c) if c.to_rgba8()[..3] == [0xcc, 0x33, 0x00]));
}
#[test]
fn with_a_board_selected_the_bar_shows_its_background() {
let mut app = app();
let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.artboard.set", json!({"layer": a, "background": "black"})).unwrap();
app.run("layer.select", json!({"layer": a})).unwrap();
assert_eq!(app.ui.artboard_tool.background, "white", "the tool option is still white");
assert_eq!(bar::shown_background(&app).0, "black");
app.run("layer.artboard.set", json!({"layer": a, "background": "custom", "color": "#336699"})).unwrap();
assert_eq!(bar::shown_background(&app), ("custom".to_string(), "#336699".to_string()));
}
#[test]
fn a_background_choice_recolours_every_selected_board_in_one_step() {
let mut app = app();
let (a, b) = two_boards(&mut app);
let before = steps(&app);
bar::set_background(&mut app, "transparent", "#ffffff").unwrap();
assert_eq!(steps(&app), before + 1);
assert_eq!(last_label(&app).as_deref(), Some("Edit Artboards"));
assert!(boards(&app).iter().all(|x| x.2 == ArtboardBackground::Transparent));
assert_eq!(boards(&app).iter().map(|x| x.0.0).collect::<Vec<_>>(), vec![a, b]);
assert_eq!(app.ui.artboard_tool.background, "transparent", "also the next drawn board's");
// Other...: OK in the picker recolours them too.
bar::set_custom_background(&mut app, "#00ff00").unwrap();
assert_eq!(steps(&app), before + 2);
for x in boards(&app) {
let ArtboardBackground::Custom(c) = x.2 else { panic!("a custom background") };
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
}
assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#00ff00"));
}
#[test]
fn one_selected_board_is_edit_artboard() {
let mut app = app();
let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 100, 100]})).unwrap()["layer"].as_u64().unwrap();
app.run("layer.select", json!({"layer": a})).unwrap();
let before = steps(&app);
bar::set_background(&mut app, "black", "#ffffff").unwrap();
assert_eq!(steps(&app), before + 1);
assert_eq!(last_label(&app).as_deref(), Some("Edit Artboard"));
assert_eq!(boards(&app)[0].2, ArtboardBackground::Black);
}
#[test]
fn with_no_board_selected_the_background_edits_only_the_tool_option() {
let mut app = app();
let before = steps(&app);
bar::set_background(&mut app, "black", "#ffffff").unwrap();
assert_eq!(app.ui.artboard_tool.background, "black");
assert_eq!(steps(&app), before, "no history step");
assert!(boards(&app).is_empty());
}
#[test]
fn the_background_param_carries_color_only_for_other() {
let mut app = app();
assert_eq!(bar::with_background(&app, json!({"rect": [0, 0, 5, 5]})), json!({"rect": [0, 0, 5, 5], "background": "white"}));
bar::set_custom_background(&mut app, "#00ff00").unwrap();
assert_eq!(bar::with_background(&app, json!({})), json!({"background": "custom", "color": "#00ff00"}));
assert!(bar::set_custom_background(&mut app, "green").is_err());
assert_eq!(app.ui.artboard_tool.background_color, "#00ff00", "a bad colour changes nothing");
}
#[test]
fn other_opens_the_color_picker_and_ok_sets_the_option() {
let mut app = app();
app.ui.artboard_tool.background = "black".to_string();
// What the dropdown's Other… and the swatch do.
app.ui.artboard_tool.background_color = "#123456".to_string();
let before = app.ui.dialogs.len();
let rgb = crate::color_picker_ui::parse_hex("#123456").unwrap();
let id = crate::color_picker_ui::open_for_target(&mut app, "artboardBackground", "Color Picker (Color)", rgb);
assert_eq!(app.ui.dialogs.len(), before + 1);
// Cancel leaves the option alone; OK sets it.
assert_eq!(app.ui.artboard_tool.background, "black");
let fields = app.ui.dialog_mut(id).unwrap().fields.clone();
crate::color_picker_ui::confirm(&mut app, &fields).unwrap();
assert_eq!((app.ui.artboard_tool.background.as_str(), app.ui.artboard_tool.background_color.as_str()), ("custom", "#123456"));
}
#[test]
fn the_align_panel_has_the_eight_entries_in_the_measured_order() {
let labels: Vec<Vec<&str>> = ALIGN_PANEL.iter().map(|g| g.iter().map(|e| e.0).collect()).collect();
assert_eq!(
labels,
vec![
vec!["Left Edges", "Horizontal Centers", "Right Edges"],
vec!["Top Edges", "Vertical Centers", "Bottom Edges"],
vec!["Distribute Widths", "Distribute Heights"],
]
);
let specs = photocraft_engine::command_specs();
for (label, cmd) in ALIGN_PANEL.iter().flat_map(|g| g.iter()) {
match cmd {
Some(c) => {
assert!(specs.iter().any(|s| s.id == *c), "{c} is a command");
assert!(crate::align_bar::ALIGN.iter().any(|a| a.0 == *c && a.2 == *label), "{label} is the Move tool's");
}
None => assert!(label.starts_with("Distribute"), "{label} has no command yet"),
}
}
// Distribute Widths and Heights run nothing.
assert!(bar::run_align(&mut app(), 2, 0).is_none());
assert!(bar::run_align(&mut app(), 2, 1).is_none());
}
/// A filled square at `r` (`x, y, w, h`); the engine aligns a board by its contents.
fn square(app: &mut PhotocraftApp, r: [i64; 4]) -> u64 {
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": r[0], "y": r[1], "width": r[2], "height": r[3]})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.session.active().unwrap().active_layer.unwrap().0
}
fn wrap(app: &mut PhotocraftApp, layer: u64) -> u64 {
app.run("layer.select", json!({"layer": layer})).unwrap();
app.run("layer.new.artboardFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn an_align_entry_moves_two_selected_boards_in_one_step() {
let mut app = app();
let (sa, sb) = (square(&mut app, [40, 10, 100, 100]), square(&mut app, [300, 200, 80, 100]));
let (a, b) = (wrap(&mut app, sa), wrap(&mut app, sb));
app.run("layer.select", json!({"layer": a})).unwrap();
app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap();
assert!(app.session.is_enabled("layer.align.leftEdges"));
let before: Vec<Rect> = boards(&app).iter().map(|b| b.1).collect();
assert_eq!(before.len(), 2);
let steps = app.session.active().unwrap().history.entries().len();
let r = bar::run_align(&mut app, 0, 0);
assert!(matches!(r, Some(Ok(_))), "{r:?}");
let after: Vec<Rect> = boards(&app).iter().map(|b| b.1).collect();
// Left Edges: the second board (with its contents) moves left to the first one's edge.
assert_eq!(after[0], before[0]);
assert_eq!((after[1].x0, after[1].y0), (before[0].x0, before[1].y0));
assert_eq!((after[1].width(), after[1].height()), (before[1].width(), before[1].height()));
assert_eq!(app.session.active().unwrap().history.entries().len(), steps + 1, "one history step");
// Top Edges (second group, first entry) moves it up to the first board's top.
assert!(matches!(bar::run_align(&mut app, 1, 0), Some(Ok(_))));
let after: Vec<Rect> = boards(&app).iter().map(|b| b.1).collect();
assert_eq!((after[1].x0, after[1].y0), (before[0].x0, before[0].y0));
}
#[test]
fn the_gear_menu_has_four_entries_all_checked_and_only_auto_nest_live() {
let labels: Vec<&str> = GEAR.iter().map(|g| g.1).collect();
assert_eq!(labels, vec!["Auto-nest Layers", "Auto-size Canvas", "Keep Relative Position During Layer Reordering", "Shrinkwrap Canvas on Save"]);
let live: Vec<Gear> = GEAR.iter().map(|g| g.0).filter(|g| g.enabled()).collect();
assert_eq!(live, vec![Gear::AutoNest]);
let mut o = ArtboardToolUi::default();
for (g, _) in GEAR {
assert!(g.checked(&o));
}
o.auto_nest = false;
o.shrinkwrap_on_save = false;
let checked: Vec<bool> = GEAR.iter().map(|g| g.0.checked(&o)).collect();
assert_eq!(checked, vec![false, true, true, false]);
}
/// Auto-nest Layers off: a layer dragged with the Move tool onto another board stays where it was.
#[test]
fn auto_nest_layers_turns_the_move_tools_nesting_on_and_off() {
for (nest, joins) in [(true, true), (false, false)] {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("file.new", json!({"width": 300, "height": 100, "background": "transparent"})).unwrap();
app.sync_views();
app.session.execute("layer.new.layer", json!({})).unwrap();
app.session.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.session.execute("select.deselect", json!({})).unwrap();
let red = app.session.active().unwrap().active_layer.unwrap();
app.session.execute("layer.new.artboardFromLayers", json!({"name": "A"})).unwrap();
app.session.execute("layer.artboard.set", json!({"width": 40, "height": 40})).unwrap();
let b = LayerId(app.session.execute("layer.new.artboard", json!({"rect": [200, 20, 40, 40], "name": "B"})).unwrap()["layer"].as_u64().unwrap());
app.session.execute("layer.select", json!({"layer": red.0})).unwrap();
app.ui.tool = Tool::Move;
app.ui.artboard_tool.auto_nest = nest;
crate::canvas::move_ui::finish(&mut app, 190.0, 10.0);
let board = app.session.active().unwrap().doc.artboard_of(red);
assert_eq!(board == Some(b), joins, "auto_nest {nest}");
}
}
@@ -0,0 +1,207 @@
//! ⌥ (Alt) with the Background Eraser is the Eyedropper, as with the Brush (#11906): a click or
//! drag sets the foreground colour, adds no history state and changes no pixel, and the cursor
//! is the pipette while ⌥ is held. Control+⌥ stays the brush-resize gesture.
use egui::{Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::Harness;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
use crate::state::Tool;
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
fn harness(app: PhotocraftApp) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(900.0, 600.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
let ctx = ui.ctx().clone();
if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::shortcuts::handle(app, &ctx);
egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(4);
let v = &mut h.state_mut().ui.views[0];
v.zoom = 1.0;
v.center = [100.0, 50.0];
v.fit_pending = false;
h.run_steps(2);
h
}
fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 {
let app = h.state();
let v = &app.ui.views[0];
let xf = ViewXform {
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
aspect: app.ui.view.display_aspect(),
};
xf.to_screen(x, y)
}
fn mods(h: &mut Harness<'static, PhotocraftApp>, m: Modifiers) {
h.event(egui::Event::ModifiersChanged(m));
h.run_steps(1);
}
fn button(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, p: Pos2, down: bool, m: Modifiers) {
h.event(egui::Event::PointerButton { pos: p, button: b, pressed: down, modifiers: m });
h.run_steps(1);
}
/// Press `b` at document point `from` with `m` held from before the press, and move to `to`;
/// the button stays down.
fn press_and_move(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, from: [f32; 2], to: [f32; 2], m: Modifiers) {
let p = screen(h, from[0], from[1]);
mods(h, m);
h.event(egui::Event::PointerMoved(p));
h.run_steps(1);
button(h, b, p, true, m);
if from != to {
for i in 1..=8 {
let t = i as f32 / 8.0;
let q = screen(h, from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t);
h.event(egui::Event::PointerMoved(q));
h.run_steps(1);
}
}
}
/// Release `b` at document point `at`, then let go of the modifiers.
fn release(h: &mut Harness<'static, PhotocraftApp>, b: PointerButton, at: [f32; 2], m: Modifiers) {
button(h, b, screen(h, at[0], at[1]), false, m);
h.run_steps(2);
mods(h, Modifiers::NONE);
}
/// A drag between document points with `m` held from before the press until after the
/// release; `from == to` is a click.
fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f32; 2], to: [f32; 2], m: Modifiers) {
press_and_move(h, PointerButton::Primary, from, to, m);
release(h, PointerButton::Primary, to, m);
}
/// Red on the left, green on the right of an unlocked layer, Background Eraser active, blue
/// foreground and white background colour.
fn background_eraser() -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
app.run("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap();
app.run("edit.fill", json!({"color": "#00ff00"})).unwrap();
app.run("select.deselect", json!({})).unwrap();
app.run("tools.setColors", json!({"foreground": BLUE, "background": [1.0, 1.0, 1.0, 1.0]})).unwrap();
app.sync_views();
let mut h = harness(app);
h.state_mut().ui.tool = Tool::BackgroundEraser;
h.run_steps(1);
h
}
/// The document and the number of history states, to show nothing changed.
fn snapshot(h: &Harness<'static, PhotocraftApp>) -> (std::sync::Arc<photocraft_doc::Document>, usize) {
let st = h.state().session.active().unwrap();
(st.doc.clone(), st.history.past_len())
}
fn unchanged(h: &Harness<'static, PhotocraftApp>, before: &(std::sync::Arc<photocraft_doc::Document>, usize), what: &str) {
let (doc, steps) = snapshot(h);
assert!(std::sync::Arc::ptr_eq(&doc, &before.0), "{what}: no pixel changed");
assert_eq!(steps, before.1, "{what}: no history state");
}
#[test]
fn alt_click_sets_the_foreground_without_erasing() {
let mut h = background_eraser();
let before = snapshot(&h);
drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::ALT);
assert_eq!(h.state().session.tools.foreground, GREEN, "⌥-click picked green");
assert_eq!(h.state().session.tools.background, [1.0, 1.0, 1.0, 1.0], "the background colour stays");
unchanged(&h, &before, "⌥-click");
assert!(!h.state().alt_sampling, "the release ends the sampling");
}
#[test]
fn alt_drag_follows_the_pointer_without_erasing() {
let mut h = background_eraser();
let before = snapshot(&h);
press_and_move(&mut h, PointerButton::Primary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT);
assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag samples while the button is held");
release(&mut h, PointerButton::Primary, [40.0, 50.0], Modifiers::ALT);
assert_eq!(h.state().session.tools.foreground, RED, "⌥-drag ends on red");
unchanged(&h, &before, "⌥-drag");
}
/// The same press without ⌥ still erases: the harness drives the real tool.
#[test]
fn a_click_without_alt_still_erases() {
let mut h = background_eraser();
let (_, steps) = snapshot(&h);
drag(&mut h, [150.0, 50.0], [150.0, 50.0], Modifiers::NONE);
assert_eq!(h.state().session.tools.foreground, BLUE);
assert_eq!(snapshot(&h).1, steps + 1, "the stroke added a history state");
let st = h.state().session.active().unwrap();
let layer = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
assert_eq!(layer.rgba(150, 50)[3], 0.0, "the clicked pixel was erased");
}
/// While ⌥ is held the cursor is the Eyedropper's pipette (`pipette_cursor`), where the
/// Background Eraser otherwise shows its own tip; Control+⌥ keeps the tip.
#[test]
fn alt_shows_the_eyedropper_cursor() {
let mut h = background_eraser();
let p = screen(&h, 150.0, 50.0);
let pipette = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers| {
mods(h, m);
h.hover_at(p);
h.run_steps(2);
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("pc-icon-cursor"));
let shown = h.ctx.memory(|memory| memory.areas().visible_layer_ids().contains(&layer));
(shown, h.output().platform_output.cursor_icon)
};
assert_eq!(pipette(&mut h, Modifiers::ALT), (true, egui::CursorIcon::None), "⌥: the pipette replaces the pointer");
assert!(!pipette(&mut h, Modifiers::NONE).0, "without ⌥: the eraser's own tip");
assert!(!pipette(&mut h, Modifiers::ALT | Modifiers::CTRL).0, "Control+⌥ is not the Eyedropper");
assert!(pipette(&mut h, Modifiers::ALT).0, "⌥ again: the pipette again");
}
/// Control+⌥-drag stays the brush-resize gesture: it samples nothing and erases nothing.
#[test]
fn ctrl_alt_drag_resizes_instead_of_sampling() {
assert!(crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT));
assert!(!crate::canvas::alt_samples(Tool::BackgroundEraser, Modifiers::ALT | Modifiers::CTRL));
let mut h = background_eraser();
h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap();
let before = snapshot(&h);
drag(&mut h, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT | Modifiers::CTRL);
assert_eq!(h.state().session.tools.foreground, BLUE, "Control+⌥ sampled nothing");
unchanged(&h, &before, "Control+⌥-drag");
assert_ne!(h.state().session.tools.brush.size, 40.0, "Control+⌥-drag resized the brush");
}
/// ⌥+right-drag (not measured in the reference application) keeps today's meaning, the brush
/// resize: it samples nothing and erases nothing.
#[test]
fn alt_right_drag_resizes_instead_of_sampling() {
let mut h = background_eraser();
h.state_mut().run("tools.setBrush", json!({"brush": {"size": 40}})).unwrap();
let before = snapshot(&h);
press_and_move(&mut h, PointerButton::Secondary, [150.0, 50.0], [40.0, 50.0], Modifiers::ALT);
release(&mut h, PointerButton::Secondary, [40.0, 50.0], Modifiers::ALT);
assert_eq!(h.state().session.tools.foreground, BLUE, "⌥+right-drag sampled nothing");
unchanged(&h, &before, "⌥+right-drag");
assert_ne!(h.state().session.tools.brush.size, 40.0, "⌥+right-drag resized the brush");
}
@@ -0,0 +1,125 @@
//! #11907: with the Background Eraser active, the number keys set its Tolerance as they set Opacity
//! for the painting tools (3 = 30%, 0 = 100%, 5 then 5 typed quickly = 55%), as in the reference
//! application. Other tools' digits are unchanged.
use egui::{Event, Key, Modifiers};
use serde_json::json;
use crate::PhotocraftApp;
use crate::opacity_keys::press;
use crate::state::Tool;
fn app(tool: Tool) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.ui.tool = tool;
app
}
fn tolerance(app: &PhotocraftApp) -> f32 {
app.ui.tool_options.bg_tolerance
}
fn layer(app: &PhotocraftApp) -> (f32, f32) {
let st = app.session.active().unwrap();
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
(l.opacity, l.fill_opacity)
}
#[test]
fn single_digits_set_the_tolerance() {
let mut a = app(Tool::BackgroundEraser);
assert_eq!(tolerance(&a), 50.0, "default");
press(&mut a, 3, false, 0.0);
assert_eq!(tolerance(&a), 30.0);
press(&mut a, 0, false, 5000.0);
assert_eq!(tolerance(&a), 100.0, "0 = 100%");
press(&mut a, 1, false, 10000.0);
assert_eq!(tolerance(&a), 10.0);
}
#[test]
fn two_quick_digits_set_an_exact_tolerance() {
let mut a = app(Tool::BackgroundEraser);
press(&mut a, 5, false, 0.0);
press(&mut a, 5, false, 300.0);
assert_eq!(tolerance(&a), 55.0);
// A slow second digit starts over.
press(&mut a, 4, false, 5000.0);
press(&mut a, 2, false, 9000.0);
assert_eq!(tolerance(&a), 20.0);
}
#[test]
fn the_tolerance_is_shown_in_ui_inspect() {
let mut a = app(Tool::BackgroundEraser);
press(&mut a, 3, false, 0.0);
let options = serde_json::to_value(&a.ui.tool_options).unwrap();
assert_eq!(options["bg_tolerance"], json!(30.0));
}
#[test]
fn background_eraser_digits_leave_the_brush_and_the_layer_alone() {
let mut a = app(Tool::BackgroundEraser);
let brush = a.session.tools.brush.clone();
press(&mut a, 7, false, 0.0);
assert_eq!(a.session.tools.brush, brush);
assert_eq!(layer(&a), (1.0, 1.0));
assert_eq!(a.ui.tool_options.fill_opacity, 100.0);
}
#[test]
fn unmeasured_shift_digits_and_double_zero_keep_the_tolerance() {
let mut a = app(Tool::BackgroundEraser);
press(&mut a, 3, true, 0.0);
assert_eq!(tolerance(&a), 50.0, "⇧3 changes nothing");
press(&mut a, 0, false, 5000.0);
press(&mut a, 0, false, 5100.0);
assert_eq!(tolerance(&a), 100.0, "00 keeps the 100% of the first 0");
}
#[test]
fn other_tools_digits_are_unchanged() {
for tool in [Tool::Brush, Tool::Pencil, Tool::Eraser] {
let mut a = app(tool);
press(&mut a, 3, false, 0.0);
assert!((a.session.tools.brush.opacity - 0.3).abs() < 1e-6, "{tool:?}");
assert_eq!(tolerance(&a), 50.0, "{tool:?}");
}
for tool in [Tool::Gradient, Tool::PaintBucket] {
let mut a = app(tool);
press(&mut a, 3, false, 0.0);
assert_eq!(a.ui.tool_options.fill_opacity, 30.0, "{tool:?}");
assert_eq!(tolerance(&a), 50.0, "{tool:?}");
}
let mut a = app(Tool::Move);
press(&mut a, 3, false, 0.0);
assert!((layer(&a).0 - 0.3).abs() < 1e-6);
assert_eq!(tolerance(&a), 50.0);
// The tools whose number is a strength, exposure or tolerance of their own still ignore the digits.
for tool in [Tool::ColorReplacement, Tool::Dodge, Tool::Smudge, Tool::MagicEraser] {
let mut a = app(tool);
let before = (a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone());
press(&mut a, 3, false, 0.0);
assert_eq!((a.session.tools.brush.clone(), layer(&a), a.ui.tool_options.clone()), before, "{tool:?}");
}
}
/// Through the whole app: number keys typed over the canvas (no widget focused).
#[test]
fn number_keys_set_the_tolerance_in_the_app() {
use egui_kittest::Harness;
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_max_steps(64).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
app(Tool::BackgroundEraser)
});
h.run_steps(4);
let key = |h: &mut Harness<'_, PhotocraftApp>, k: Key| {
h.event(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.event(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
};
key(&mut h, Key::Num3);
assert_eq!(tolerance(h.state()), 30.0);
}
@@ -0,0 +1,310 @@
//! #11909: the Background Eraser's Tolerance takes whole numbers 1..100; a value typed past the
//! range becomes the closest one and, once the field is left, an alert with OK says so.
//! `paint.backgroundEraser` clamps `tolerance` to 1..100 too, and the options bar's controls carry
//! the reference application's tooltips.
use egui::vec2;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
use crate::PhotocraftApp;
use crate::eraser_ui::TOLERANCE_RANGE_ALERT;
use crate::state::{DialogKind, Tool};
fn app_harness() -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
app.ui.tool = Tool::BackgroundEraser;
app
});
h.run_steps(5);
h
}
fn bar(tool: Tool) -> Harness<'static, PhotocraftApp> {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 80, "height": 80})).unwrap();
app.ui.tool = tool;
let mut h = Harness::builder().with_size(vec2(1600.0, 300.0)).build_ui_state(|ui, app| crate::panels::options_bar(app, ui), app);
PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
h
}
/// Types `text` into the Tolerance field, then Tabs out of it.
fn type_tolerance(h: &mut Harness<'static, PhotocraftApp>, text: &str) {
h.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text(text.into()));
h.run_steps(1);
h.key_press(egui::Key::Tab);
h.run_steps(3);
}
fn alert(h: &Harness<'static, PhotocraftApp>) -> Option<String> {
h.state().ui.dialogs.iter().find(|d| d.kind == DialogKind::Error).and_then(|d| d.fields.get("message")).and_then(|m| m.as_str()).map(str::to_owned)
}
fn tolerance(h: &Harness<'static, PhotocraftApp>) -> f32 {
h.state().ui.tool_options.bg_tolerance
}
#[test]
fn tolerance_past_the_range_takes_the_closest_value_and_alerts() {
for (typed, want) in [("0", 1.0), ("150", 100.0), ("-5", 1.0)] {
let mut h = app_harness();
type_tolerance(&mut h, typed);
assert_eq!(tolerance(&h), want, "{typed}");
assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT), "{typed}");
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(alert(&h), None, "{typed}: OK closes the alert");
assert_eq!(tolerance(&h), want, "{typed}: the clamped value is kept");
}
}
#[test]
fn the_alert_is_a_modal_the_control_channel_sees() {
let mut h = app_harness();
type_tolerance(&mut h, "150");
let dialogs = serde_json::to_value(&h.state().ui.dialogs).unwrap();
assert!(dialogs.to_string().contains(TOLERANCE_RANGE_ALERT), "{dialogs}");
assert!(h.query_by_label("OK").is_some(), "the alert has an OK button");
}
#[test]
fn tolerance_in_range_stores_a_whole_number_without_an_alert() {
let mut h = app_harness();
type_tolerance(&mut h, "33.6");
assert_eq!(tolerance(&h), 34.0);
assert_eq!(alert(&h), None);
type_tolerance(&mut h, "100");
assert_eq!(tolerance(&h), 100.0);
assert_eq!(alert(&h), None, "the end of the range is in it");
type_tolerance(&mut h, "abc");
assert_eq!(tolerance(&h), 100.0, "not a number: the value stays");
assert_eq!(alert(&h), None);
}
#[test]
fn a_leading_zero_typed_key_by_key_keeps_the_text() {
let mut h = app_harness();
h.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
for c in ["0", "5"] {
h.event(egui::Event::Text(c.into()));
h.run_steps(2);
}
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(tolerance(&h), 5.0, "0 then 5 is 05, not 15");
assert_eq!(alert(&h), None, "05 is in range");
}
#[test]
fn a_tolerance_set_elsewhere_during_an_edit_wins_over_the_typed_text() {
let mut h = app_harness();
h.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("150".into()));
h.run_steps(1);
// The bar goes away mid-edit and a preset sets the Tolerance meanwhile.
h.state_mut().ui.panels.options_bar = false;
h.run_steps(2);
h.state_mut().ui.tool_options.bg_tolerance = 30.0;
h.state_mut().ui.panels.options_bar = true;
h.run_steps(1);
// From the first frame back, the field shows the preset's value.
let shown = h
.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.accesskit_node()
.numeric_value();
assert_eq!(shown, Some(30.0), "the field shows the preset's value, not the typed 150");
h.run_steps(2);
assert_eq!(tolerance(&h), 30.0, "the preset's value, not the typed 150");
}
#[test]
fn an_expression_committed_with_enter_past_the_range_alerts() {
let mut h = app_harness();
h.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.click();
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("50*4".into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(tolerance(&h), 100.0);
assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT));
}
#[test]
fn a_restored_tolerance_out_of_range_shows_as_the_field_stores_it() {
for (saved, want) in [(0.0, 1.0), (33.6, 34.0), (f32::NAN, 50.0)] {
let mut h = app_harness();
h.state_mut().ui.tool_options.bg_tolerance = saved;
h.run_steps(3);
assert_eq!(tolerance(&h), want, "{saved}");
assert_eq!(alert(&h), None, "{saved}: no alert for a restored value");
}
}
#[test]
fn a_restored_fraction_reaches_the_stroke_as_a_whole_number() {
// The options bar need not have drawn: the stroke's params round it themselves.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for (saved, want) in [(23.6, 24.0), (0.0, 1.0), (f32::NAN, 50.0)] {
app.ui.tool_options.bg_tolerance = saved;
let p = crate::eraser_ui::background_eraser_params(&app, &[[1.0, 1.0, 1.0]]);
assert_eq!(p["tolerance"].as_f64(), Some(want), "{saved}");
}
}
#[test]
fn a_label_scrub_past_100_does_not_alert() {
let mut h = app_harness();
type_tolerance(&mut h, "90");
// The field keeps the keyboard while the caption is scrubbed from 90 past the end.
let field = h
.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field");
let rect = field.rect();
field.click();
h.run_steps(1);
// The caption sits just left of the field.
let from = egui::pos2(rect.left() - 25.0, rect.center().y);
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.run_steps(1);
for k in 1..=6 {
h.event(egui::Event::PointerMoved(from + vec2(50.0 * k as f32, 0.0)));
h.run_steps(1);
}
let to = from + vec2(300.0, 0.0);
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.key_press(egui::Key::Tab);
h.run_steps(3);
assert_eq!(tolerance(&h), 100.0, "the scrub reached the end");
assert_eq!(alert(&h), None, "a scrub stops at 100 without an alert");
}
#[test]
fn an_edit_cut_off_by_hiding_the_bar_leaves_no_stale_alert() {
let mut h = app_harness();
let field = |h: &Harness<'static, PhotocraftApp>| {
h.query_all_by_role(egui::accesskit::Role::SpinButton)
.find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:"))
.expect("the Tolerance field")
.rect()
.center()
};
// 150 typed, then the bar goes away before the field is left.
let at = field(&h);
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(1);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.event(egui::Event::Text("150".into()));
h.run_steps(1);
h.state_mut().ui.panels.options_bar = false;
h.run_steps(3);
h.state_mut().ui.panels.options_bar = true;
h.run_steps(3);
assert_eq!(tolerance(&h), 100.0);
// Back in the field and out again without a change: nothing to say.
type_tolerance(&mut h, "100");
assert_eq!(alert(&h), None, "no alert for an edit that was already cut off");
}
/// The accessible descriptions (tooltips) of the widgets of `role` named `label`.
fn descriptions(h: &Harness<'static, PhotocraftApp>, role: Option<egui::accesskit::Role>, label: &str) -> Vec<String> {
let of = |n: egui_kittest::Node<'_>| n.accesskit_node().description().map(|d| d.to_string());
match role {
Some(role) => h.query_all_by_role(role).filter(|n| n.accesskit_node().label().as_deref() == Some(label)).filter_map(of).collect(),
None => h.query_all_by_label(label).filter_map(of).collect(),
}
}
#[test]
fn the_bar_has_the_reference_tooltips() {
use egui::accesskit::Role;
for (role, label, tip) in [
(Role::Button, "Brush Preset picker", "Click to open the Brush Preset picker"),
(Role::ComboBox, "Limits:", "How far to let the erasing spread"),
(Role::SpinButton, "Tolerance:", "Erase range of similar colors"),
(Role::CheckBox, "Protect Foreground Color", "Do not erase the foreground swatch color"),
] {
let mut h = bar(Tool::BackgroundEraser);
assert!(h.query_by_label(tip).is_none(), "{label}: no tooltip before the hover");
let at = h.query_all_by_role(role).find(|n| n.accesskit_node().label().as_deref() == Some(label)).expect(label).rect().center();
h.hover_at(at);
h.run_steps(8);
assert!(h.query_by_label(tip).is_some(), "{label}: resting on it shows {tip:?}");
}
}
#[test]
fn the_other_tools_keep_their_brush_chip_tooltip() {
for tool in [Tool::Eraser, Tool::Pencil] {
let h = bar(tool);
let found = descriptions(&h, None, "Brush Preset picker");
assert!(found.iter().any(|d| d == "Brush Preset picker"), "{tool:?}: {found:?}");
}
}
/// The alpha at `(x, y)` after a Background Eraser stroke over white and a square one level off
/// white, with `tolerance`.
fn erased_alpha(tolerance: serde_json::Value, (x, y): (i32, i32)) -> f32 {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 40})).unwrap();
s.execute("select.rect", json!({"x": 30, "y": 0, "width": 34, "height": 40, "antiAlias": false})).unwrap();
s.execute("edit.fill", json!({"color": "#feffff"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute(
"paint.backgroundEraser",
json!({"points": [[4, 20], [56, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": tolerance}),
)
.unwrap();
let d = s.active().unwrap();
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y)[3]
}
#[test]
fn the_command_clamps_tolerance_to_1_through_100() {
// Sampled white; the square is 1/255 off it: Tolerance 1 % reaches it, 0 % would not.
let near = (45, 20);
assert!(erased_alpha(json!(1), near) < 1.0, "1 % erases the near-white square");
assert_eq!(erased_alpha(json!(0), near), erased_alpha(json!(1), near), "0 is taken as 1");
assert_eq!(erased_alpha(json!(-20), near), erased_alpha(json!(1), near), "below the range is taken as 1");
assert_eq!(erased_alpha(json!(150), (6, 20)), erased_alpha(json!(100), (6, 20)), "150 is taken as 100");
}
#[test]
fn the_params_doc_says_1_through_100() {
let spec = photocraft_engine::commands::find("paint.backgroundEraser").expect("registered");
assert!(spec.params.contains(r#""tolerance":1..100=50"#), "{}", spec.params);
}

Some files were not shown because too many files have changed in this diff Show More