mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 18:13:39 +00:00
Merge storytold/photocraft main into the Fill/Stroke popover branch
217 upstream commits. Upstream added a colour swatch (Select and Mask's overlay colour): it now opens the Color Picker titled "Color Picker (Overlay Color)". Content-Aware Fill and Select and Mask leave their keys to an open Color Picker, as they did for the swatch popup it replaced (test keys_wait_while_the_overlay_colour_picker_is_open). docs/ui-parity.md keeps the scorecard's 12 of 182. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TYsCL5E2GM6hUabP9dmYAv
This commit is contained in:
@@ -27,6 +27,10 @@ next to them.
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Swedish UI translations | @kler, @davvarn 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/fi.tsv` | Finnish UI translations | @Janiz-zzz, @metuuu 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/da.tsv` | Danish UI translations | @pechnikov2026-star 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/nb.tsv` | Norwegian Bokmål UI translations | @nikolainyegaard, @Bolla76 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) |
|
||||
| `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
@@ -5023,6 +5023,7 @@ dependencies = [
|
||||
name = "photocraft-algo"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
|
||||
@@ -83,7 +83,10 @@ pub(crate) fn batch(a: &BatchArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
h.session.actions.list = set.clone();
|
||||
h.open(input)?;
|
||||
for (id, p) in &actions {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id) || photocraft_engine::actions_cmds::shell_save_command(id) {
|
||||
if photocraft_engine::actions_cmds::shell_view_command(id)
|
||||
|| photocraft_engine::actions_cmds::shell_save_command(id)
|
||||
|| photocraft_engine::actions_cmds::noop_step(&h.session, id, p)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(id, p.clone()).map_err(|source| CliError::Command { id: id.clone(), source })?;
|
||||
|
||||
@@ -93,6 +93,11 @@ pub(crate) fn run_cmds(a: &RunArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
}
|
||||
let cmds = command_list(a)?;
|
||||
for (id, params) in cmds {
|
||||
// A step that is a silent no-op in action playback (Deselect with nothing selected, an Arrange that cannot move the layer) is one in a script too (#8802).
|
||||
if photocraft_engine::actions_cmds::noop_step(&h.session, &id, ¶ms) {
|
||||
print_json(out, &json!({"command": id, "result": null}), JsonStyle::Compact)?;
|
||||
continue;
|
||||
}
|
||||
let r = h.command_run(&id, params).map_err(|source| CliError::Command { id: id.clone(), source })?;
|
||||
if let Some(message) = photocraft_engine::actions_cmds::nested_failure(&id, &r) {
|
||||
return Err(CliError::NestedFailure { id, message });
|
||||
|
||||
@@ -1421,3 +1421,78 @@ fn control_token_env_vars_are_the_flags_fallbacks() {
|
||||
let err = serve_tcp_stderr(bin().args(["serve", "--port"]).arg(free_port().to_string()).env("PHOTOCRAFT_CONTROL_TOKEN", ""));
|
||||
assert!(err.contains("photocraft-cli serving on") && err.contains("photocraft-cli control token: "), "{err}");
|
||||
}
|
||||
|
||||
/// #8802: Deselect with nothing selected is skipped in a `run`, as action playback does (#6744),
|
||||
/// so a script that deselects after Reveal Selection still finishes and saves.
|
||||
#[test]
|
||||
fn run_skips_deselect_with_nothing_selected() {
|
||||
let folder = tmp("deselect-run");
|
||||
let native = folder.join("out.pcraft").to_string_lossy().to_string();
|
||||
let Invoke { code, stdout, stderr } = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":16,"height":16}"#,
|
||||
"--cmd",
|
||||
"select.rect",
|
||||
"--params",
|
||||
r#"{"x":0,"y":0,"width":8,"height":16}"#,
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--cmd",
|
||||
"layer.layerMask.revealSelection",
|
||||
"--cmd",
|
||||
"select.deselect",
|
||||
"--cmd",
|
||||
"layer.new.layer",
|
||||
"--params",
|
||||
r#"{"name":"after"}"#,
|
||||
"--out",
|
||||
&native,
|
||||
]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let results: Vec<Value> = stdout.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(results.len(), 5, "every command reports a result: {stdout}");
|
||||
assert_eq!(results[3], json!({"command": "select.deselect", "result": null}));
|
||||
let Invoke { code, stdout, stderr } = invoke(&["info", &native, "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(info["layers"][0]["name"], "after", "the steps after Deselect ran: {info}");
|
||||
|
||||
// Deselect with a selection still deselects: Inverse afterwards finds nothing to invert.
|
||||
let Invoke { code, stderr, .. } = invoke(&[
|
||||
"run",
|
||||
"--new",
|
||||
r#"{"width":16,"height":16}"#,
|
||||
"--cmd",
|
||||
"select.all",
|
||||
"--cmd",
|
||||
"select.deselect",
|
||||
"--cmd",
|
||||
"select.inverse",
|
||||
"--out",
|
||||
&native,
|
||||
]);
|
||||
assert_eq!(code, 1, "{stderr}");
|
||||
assert!(stderr.contains("select.inverse") && stderr.contains("no selection"), "{stderr}");
|
||||
let _ = std::fs::remove_dir_all(&folder);
|
||||
}
|
||||
|
||||
/// #8802: a batched action that starts with Deselect plays on files with nothing selected, as the
|
||||
/// same action does in the Actions panel (#6744).
|
||||
#[test]
|
||||
fn batch_skips_deselect_with_nothing_selected() {
|
||||
let dir = tmp("deselect-batch");
|
||||
let path = |name: &str| dir.join(name).to_string_lossy().to_string();
|
||||
std::fs::create_dir_all(dir.join("in")).unwrap();
|
||||
let Invoke { code, stderr, .. } = invoke(&["run", "--new", r#"{"width":8,"height":6}"#, "--out", &path("in/a.png")]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
std::fs::write(dir.join("steps.json"), json!([["select.deselect", {}], ["image.imageRotation.90cw", {}]]).to_string()).unwrap();
|
||||
let Invoke { code, stdout, stderr } = invoke(&["batch", "--actions", &path("steps.json"), "--in", &path("in"), "--out", &path("out")]);
|
||||
assert_eq!(code, 0, "{stdout}{stderr}");
|
||||
assert!(stdout.contains("1 succeeded, 0 failed"), "{stdout}");
|
||||
let Invoke { code, stdout, stderr } = invoke(&["info", &path("out/a.png"), "--compact"]);
|
||||
assert_eq!(code, 0, "{stderr}");
|
||||
let info: Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(6), Some(8)), "the step after Deselect ran: {info}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -634,12 +634,6 @@ fn shell_word(s: &str) -> String {
|
||||
if s.bytes().all(|b| b.is_ascii_alphanumeric() || b"/._-+:,@".contains(&b)) { s.to_string() } else { format!("'{}'", s.replace('\'', r"'\''")) }
|
||||
}
|
||||
|
||||
/// The release-check transport, or `None` inside Flatpak: `flatpak update` owns updates there.
|
||||
/// The sandbox has network (for the `--control` server), which does not change this.
|
||||
fn update_transport(flatpak_id: Option<&std::ffi::OsStr>) -> Option<photocraft_engine::updates_cmds::CheckFn> {
|
||||
flatpak_id.is_none().then(crate::updates::service)
|
||||
}
|
||||
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let clip_unreadable: Rc<Cell<bool>> = Rc::default();
|
||||
@@ -704,7 +698,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
beep: Some(platform_beep),
|
||||
check_updates: update_transport(std::env::var_os("FLATPAK_ID").as_deref()),
|
||||
check_updates: Some(crate::updates::service()),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
guarded_clipboard_set(move |w: u32, h: u32, px: &[u8]| {
|
||||
|
||||
@@ -182,9 +182,8 @@ fn open_filters_keep_literal_extensions_on_windows_and_macos() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flatpak_builds_have_no_update_transport() {
|
||||
assert!(update_transport(Some(std::ffi::OsStr::new("ai.storyteller.photocraft"))).is_none());
|
||||
assert!(update_transport(None).is_some());
|
||||
fn native_services_provide_update_transport() {
|
||||
assert!(native(None).check_updates.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -253,7 +252,7 @@ fn every_save_dialog_leads_with_its_own_extension() {
|
||||
app.run("prefs.set", json!({"path": "export.quickExportFormat", "value": format})).unwrap();
|
||||
let _ = invoke(&mut app, "file.export.quickExportAsPng");
|
||||
}
|
||||
for format in ["gif", "png8", "png24", "jpeg", "wbmp"] {
|
||||
for format in ["gif", "png8", "png24", "jpeg", "webp", "wbmp"] {
|
||||
dialog(&mut app, "file.export.saveForWebLegacy", json!({"format": format}));
|
||||
}
|
||||
app.run("slice.new", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
@@ -266,13 +265,13 @@ fn every_save_dialog_leads_with_its_own_extension() {
|
||||
let exts: Vec<&str> = asked.iter().filter_map(|s| s.rsplit_once('.').map(|(_, e)| e)).collect();
|
||||
// The extension each save above should suggest, in order.
|
||||
let want = [
|
||||
"psd", // Save As untitled
|
||||
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
|
||||
"psd", // Save a Copy
|
||||
"png jpg webp tif tga", // Export As
|
||||
"png jpg gif webp", // Quick Export
|
||||
"gif png png jpg wbmp html", // Save for Web: gif png8 png24 jpeg wbmp, then with slices
|
||||
"csv pcpresets", // Measurement Log, Export Presets
|
||||
"psd", // Save As untitled
|
||||
"pcraft jpeg gif bmp psd", // Save As opened .pcraft .jpeg .gif .bmp .dng
|
||||
"psd", // Save a Copy
|
||||
"png jpg webp tif tga", // Export As
|
||||
"png jpg gif webp", // Quick Export
|
||||
"gif png png jpg webp wbmp html", // Save for Web: gif png8 png24 jpeg webp wbmp, then with slices
|
||||
"csv pcpresets", // Measurement Log, Export Presets
|
||||
]
|
||||
.join(" ");
|
||||
assert_eq!(exts, want.split(' ').collect::<Vec<_>>());
|
||||
|
||||
@@ -23,3 +23,8 @@ rayon = { workspace = true }
|
||||
[dev-dependencies]
|
||||
photocraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "blur"
|
||||
harness = false
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! `cargo bench -p photocraft-algo`
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use photocraft_algo::{FilterParams, apply};
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn textured(n: u32) -> Surface {
|
||||
let r = Rect::new(0, 0, n as i32, n as i32);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let bytes: Vec<u8> = (0..n).flat_map(|y| (0..n).flat_map(move |x| [(x ^ y) as u8, (x / 3) as u8, (y * 7) as u8, 255])).collect();
|
||||
s.write_interleaved(r, &bytes);
|
||||
s
|
||||
}
|
||||
|
||||
/// Gaussian Blur's direct-kernel path (radius up to 4; larger radii use box passes), including
|
||||
/// the variance-matched kernels below radius 1 (#8544).
|
||||
fn gaussian_direct(c: &mut Criterion) {
|
||||
let n = 2048;
|
||||
let s = textured(n);
|
||||
let area = Rect::new(0, 0, n as i32, n as i32);
|
||||
for radius in [0.3f32, 0.8, 3.0] {
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
c.bench_function(&format!("gaussian_blur_{radius}px_2048"), |b| b.iter(|| apply(&s, &p, area, area, None)));
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().sample_size(10);
|
||||
targets = gaussian_direct
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Stack Mode Entropy timings and exact output hashes, including lookup preparation.
|
||||
//! Run the same example on both revisions: `cargo run --release -p photocraft-algo --example
|
||||
//! bench_stack_entropy -- [--quick] [--reps N] [--threads N] [--json path]`.
|
||||
//! Full cases use 24/36 MP; quick cases use 1/16 as many pixels. The deep-stack fallback is
|
||||
//! deliberately small. Input generation, output hashing and deallocation are outside timings.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_algo::stack::{Stat, combine};
|
||||
use photocraft_testkit::perf::{self, Summary};
|
||||
use serde_json::json;
|
||||
|
||||
struct Options {
|
||||
quick: bool,
|
||||
reps: usize,
|
||||
threads: Option<usize>,
|
||||
json: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Options, String> {
|
||||
let mut o = Options { quick: false, reps: 5, threads: None, json: None };
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--quick" => o.quick = true,
|
||||
"--reps" => o.reps = args.next().ok_or("--reps needs an integer")?.parse().map_err(|_| "invalid --reps")?,
|
||||
"--threads" => o.threads = Some(args.next().ok_or("--threads needs an integer")?.parse().map_err(|_| "invalid --threads")?),
|
||||
"--json" => o.json = Some(args.next().ok_or("--json needs a path")?.into()),
|
||||
_ => return Err(format!("unknown option: {arg}")),
|
||||
}
|
||||
}
|
||||
if !(1..=25).contains(&o.reps) || o.threads.is_some_and(|n| !(1..=64).contains(&n)) {
|
||||
return Err("expected 1..=25 repetitions and 1..=64 threads".into());
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn benchmark(o: &Options) -> Result<(), String> {
|
||||
let mut rows = Vec::new();
|
||||
let cases = [
|
||||
("3 layers opaque 24 MP", 3, 24_000_000, false),
|
||||
("7 layers varying alpha 24 MP", 7, 24_000_000, true),
|
||||
("3 layers opaque 36 MP", 3, 36_000_000, false),
|
||||
("257 layers varying alpha", 257, 8192, true),
|
||||
];
|
||||
for (name, count, pixels, masked) in cases {
|
||||
let pixels = if o.quick && pixels > 8192 { pixels / 16 } else { pixels };
|
||||
let layers: Vec<Vec<[f32; 4]>> = (0..count)
|
||||
.map(|k| {
|
||||
(0..pixels)
|
||||
.map(|i| {
|
||||
[
|
||||
((i * 31 + k * 17) % 256) as f32 / 255.0,
|
||||
((i / 7 + k * 29) % 32) as f32 / 31.0,
|
||||
((i + k * 13) % 1024) as f32 / 255.0 - 0.5,
|
||||
if masked && k > i % (count + 1) { 0.0 } else { 1.0 },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
// Warm the worker pool and input pages before collecting samples.
|
||||
std::hint::black_box(combine(&layers, Stat::Entropy));
|
||||
let mut samples = Vec::with_capacity(o.reps);
|
||||
let mut hash = 0u64;
|
||||
for rep in 0..o.reps {
|
||||
let start = Instant::now();
|
||||
let out = std::hint::black_box(combine(&layers, Stat::Entropy));
|
||||
samples.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
if rep == 0 {
|
||||
hash = out.iter().flatten().fold(0xcbf29ce484222325, |h, v| (h ^ u64::from(v.to_bits())).wrapping_mul(0x100000001b3));
|
||||
}
|
||||
}
|
||||
let p50 = Summary::of(&samples).map_or(0.0, |s| s.p50);
|
||||
println!("{name}: {pixels} pixels, {p50:.3} ms p50, hash {hash:016x}, samples_ms={samples:?}");
|
||||
let mut row = perf::row(name, &samples, perf::process_peak_rss_bytes(), None);
|
||||
row["output_hash"] = json!(format!("{hash:016x}"));
|
||||
row["pixels"] = json!(pixels);
|
||||
rows.push(row);
|
||||
}
|
||||
if let Some(path) = &o.json {
|
||||
let report = perf::report("bench_stack_entropy", json!({"quick": o.quick, "threads": o.threads}), rows, None);
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let o = options()?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(threads) = o.threads {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().map_err(|e| e.to_string())?;
|
||||
return pool.install(|| benchmark(&o));
|
||||
}
|
||||
benchmark(&o)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -31,6 +31,43 @@ pub(crate) fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
k.into_iter().map(|v| v / s).collect()
|
||||
}
|
||||
|
||||
/// Gaussian Blur's kernel for `radius` (its standard deviation). Sampling the curve at whole
|
||||
/// pixels leaves a small radius almost no blur: at 0.3 the neighbour weight is about 0.004, a
|
||||
/// tenth of the spread asked for (#8544). Below 1 the sampling width is instead chosen so the
|
||||
/// kernel has exactly the variance `radius²`; from 1 up the sampled kernel is within about 0.4%
|
||||
/// of it (its 3σ truncation), so larger radii are unchanged and the blur grows smoothly from 0.05.
|
||||
pub(crate) fn gaussian_blur_kernel(radius: f32) -> Vec<f32> {
|
||||
if !(0.05..1.0).contains(&radius) {
|
||||
return gaussian_kernel(radius);
|
||||
}
|
||||
let variance = |k: &[f32]| {
|
||||
let r = (k.len() / 2) as f32;
|
||||
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
|
||||
};
|
||||
// Sampled at `width` with one fixed half-width for every radius below 1: its variance then
|
||||
// rises steadily with `width` (the search can't settle on a step where taps are added), and
|
||||
// the blur grows without the dips a width chosen per radius made at 1/3 and 2/3. Three is the
|
||||
// half-width `gaussian_kernel` uses at radius 1, so the hand-over is smooth too.
|
||||
let half = 3;
|
||||
let sampled = |width: f32| {
|
||||
let k: Vec<f32> = (-half..=half).map(|i| (-(i * i) as f32 / (2.0 * width * width)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
k.into_iter().map(|v| v / s).collect::<Vec<f32>>()
|
||||
};
|
||||
let target = radius * radius;
|
||||
// The sampled kernel's variance only falls short of sigma², so the width lies above `radius`.
|
||||
let (mut lo, mut hi) = (radius, radius + 1.0);
|
||||
for _ in 0..40 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if variance(&sampled(mid)) < target {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
sampled(0.5 * (lo + hi))
|
||||
}
|
||||
|
||||
/// Separable convolution of `src` (premultiplied internally) for `out`.
|
||||
/// `src` must cover `out` grown by the kernel radii.
|
||||
pub(crate) fn conv_sep(src: &Image, out: Rect, kx: &[f32], ky: &[f32], alpha: bool) -> Vec<f32> {
|
||||
@@ -111,7 +148,7 @@ pub(crate) fn gaussian(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f3
|
||||
match box_widths(&FilterParams::GaussianBlur { radius }) {
|
||||
Some(boxes) => box_passes(src, out, ctx.alpha, &boxes),
|
||||
None => {
|
||||
let k = gaussian_kernel(radius);
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
conv_sep(src, out, &k, &k, ctx.alpha)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,76 @@ use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// #8544, rendered: Gaussian Blur at 0.1–0.3 px through the filter itself (the direct-kernel
|
||||
/// path) visibly softens a hard edge, more with each step, as the kernel predicts.
|
||||
#[test]
|
||||
fn small_gaussian_radii_soften_a_rendered_edge() {
|
||||
let canvas = Rect::new(0, 0, 20, 4);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(canvas, &[0.0, 0.0, 0.0, 1.0]);
|
||||
s.fill_rect(Rect::new(10, 0, 20, 4), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let mut last = 0.0f32;
|
||||
for radius in [0.1f32, 0.2, 0.3] {
|
||||
let p = FilterParams::GaussianBlur { radius };
|
||||
assert!(box_widths(&p).is_none(), "the direct-kernel path");
|
||||
let out = crate::apply(&s, &p, canvas, canvas, None);
|
||||
// The last black pixel takes the white side's share of the kernel.
|
||||
let got = out.pixel(9, 1)[0];
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
let want: f32 = k[k.len() / 2 + 1..].iter().sum();
|
||||
assert!((got - want).abs() <= 1.0 / 255.0, "radius {radius}: {got} vs {want}");
|
||||
assert!(got >= 1.0 / 255.0 && got > last, "radius {radius}: {got} after {last}");
|
||||
last = got;
|
||||
}
|
||||
}
|
||||
|
||||
/// #8544: Gaussian Blur spreads a hard edge for every radius from 0.1, growing smoothly
|
||||
/// with the radius (no dead zone up to 0.3, then a jump); from 1 up the kernel is unchanged.
|
||||
#[test]
|
||||
fn small_gaussian_radii_blur_smoothly() {
|
||||
let spread = |k: &[f32]| {
|
||||
let r = (k.len() / 2) as f32;
|
||||
k.iter().enumerate().map(|(i, w)| w * (i as f32 - r).powi(2)).sum::<f32>()
|
||||
};
|
||||
let mut last = 0.0f32;
|
||||
for step in 1..=20 {
|
||||
let radius = step as f32 * 0.05;
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
assert!((k.iter().sum::<f32>() - 1.0).abs() < 1e-5, "normalised at {radius}");
|
||||
if radius < 1.0 {
|
||||
assert!((spread(&k) - radius * radius).abs() < 1e-4, "variance at {radius}: {}", spread(&k));
|
||||
}
|
||||
// A hard 0 → 1 edge: the first pixel past it takes the kernel's tail beyond the centre.
|
||||
let c = k.len() / 2;
|
||||
let edge: f32 = k[c + 1..].iter().sum();
|
||||
assert!(edge > last, "radius {radius}: edge {edge} after {last}");
|
||||
if radius > 0.1 {
|
||||
assert!(edge - last < 0.05, "radius {radius}: jump {} from {last}", edge - last);
|
||||
}
|
||||
last = edge;
|
||||
}
|
||||
assert!(gaussian_blur_kernel(0.1).len() >= 3, "0.1 blurs");
|
||||
// No step where the variance-matched range hands over to plain sampling.
|
||||
let edge = |r: f32| {
|
||||
let k = gaussian_blur_kernel(r);
|
||||
k[k.len() / 2 + 1..].iter().sum::<f32>()
|
||||
};
|
||||
assert!((edge(0.999) - edge(1.0)).abs() < 0.005, "{} vs {}", edge(0.999), edge(1.0));
|
||||
// No dip where a per-radius width used to add taps (1/3 and 2/3).
|
||||
for pair in [(0.33f32, 0.34f32), (0.66, 0.67)] {
|
||||
assert!(edge(pair.1) > edge(pair.0), "edge falls from {} to {}", pair.0, pair.1);
|
||||
}
|
||||
// Exact variance right up to the hand-over, past where `gaussian_kernel` changes width.
|
||||
for radius in [0.98f32, 0.995, 0.998, 0.9995] {
|
||||
let k = gaussian_blur_kernel(radius);
|
||||
assert!((spread(&k) - radius * radius).abs() < 2e-5, "variance at {radius}: {}", spread(&k));
|
||||
}
|
||||
for radius in [1.0, 1.5, 3.0, 4.0] {
|
||||
assert_eq!(gaussian_blur_kernel(radius), gaussian_kernel(radius), "{radius}");
|
||||
}
|
||||
assert_eq!(gaussian_blur_kernel(0.0), vec![1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_blur_samples_large_arcs_beyond_64_intervals() {
|
||||
let bounds = Rect::new(0, 0, 1000, 1000);
|
||||
|
||||
+153
-26
@@ -28,28 +28,57 @@ pub(crate) fn reach() -> f32 {
|
||||
(2 * MAX_LUMA_R + 2 * MAX_CHROMA_R + 6) as f32
|
||||
}
|
||||
|
||||
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
|
||||
fn guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let len = w * h;
|
||||
// Interleave the four box-filtered moments to run one pass.
|
||||
let mut m = vec![0.0f32; len * 4];
|
||||
for i in 0..len {
|
||||
m[i * 4] = g[i];
|
||||
m[i * 4 + 1] = p[i];
|
||||
m[i * 4 + 2] = g[i] * g[i];
|
||||
m[i * 4 + 3] = g[i] * p[i];
|
||||
/// Shared first and second moments of a guide (two filtered channels).
|
||||
fn guide_moments(g: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
let mut moments = Vec::with_capacity(g.len() * 2);
|
||||
for &v in g {
|
||||
moments.extend_from_slice(&[v, v * v]);
|
||||
}
|
||||
box_blur_n(&mut m, w, h, 4, r);
|
||||
let mut ab = vec![0.0f32; len * 2];
|
||||
for i in 0..len {
|
||||
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
|
||||
let var = (mgg - mg * mg).max(0.0);
|
||||
let a = (mgp - mg * mp) / (var + eps);
|
||||
ab[i * 2] = a;
|
||||
ab[i * 2 + 1] = mp - a * mg;
|
||||
box_blur_n(&mut moments, w, h, 2, r);
|
||||
moments
|
||||
}
|
||||
|
||||
/// Self-guided luminance: mean(p) = mean(g), mean(g*p) = mean(g*g).
|
||||
fn guided_luma(g: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let mut ab = guide_moments(g, w, h, r);
|
||||
for pair in ab.as_chunks_mut::<2>().0.iter_mut() {
|
||||
let mean = pair[0];
|
||||
let covariance = pair[1] - mean * mean;
|
||||
// Keep the original unclamped numerator, including roundoff below zero.
|
||||
let a = covariance / (covariance.max(0.0) + eps);
|
||||
pair[0] = a;
|
||||
pair[1] = mean - a * mean;
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
|
||||
g.iter().zip(ab.as_chunks::<2>().0.iter()).map(|(&v, pair)| pair[0] * v + pair[1]).collect()
|
||||
}
|
||||
|
||||
/// Chroma planes share the cleaned guide's mean and regularised variance.
|
||||
/// Two guide channels plus two moments and two coefficients per chroma plane:
|
||||
/// ten filtered channels, versus twelve for two independent guided filters.
|
||||
fn guided_chroma(g: &[f32], planes: [&mut [f32]; 2], w: usize, h: usize, r: usize, eps: f32, amount: f32) {
|
||||
let mut stats = guide_moments(g, w, h, r);
|
||||
for pair in stats.as_chunks_mut::<2>().0.iter_mut() {
|
||||
pair[1] = (pair[1] - pair[0] * pair[0]).max(0.0) + eps;
|
||||
}
|
||||
for plane in planes {
|
||||
let mut ab = Vec::with_capacity(g.len() * 2);
|
||||
for (&guide, &p) in g.iter().zip(plane.iter()) {
|
||||
ab.extend_from_slice(&[p, guide * p]);
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
for (pair, guide) in ab.as_chunks_mut::<2>().0.iter_mut().zip(stats.as_chunks::<2>().0.iter()) {
|
||||
let mean = pair[0];
|
||||
let a = (pair[1] - guide[0] * mean) / guide[1];
|
||||
pair[0] = a;
|
||||
pair[1] = mean - a * guide[0];
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
for ((v, &guide), pair) in plane.iter_mut().zip(g).zip(ab.as_chunks::<2>().0.iter()) {
|
||||
let filtered = pair[0] * guide + pair[1];
|
||||
*v += (filtered - *v) * amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec) -> Vec<f32> {
|
||||
@@ -125,7 +154,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
let r = (1.0 + spec.strength.clamp(0.0, 10.0) * 0.4).round() as usize;
|
||||
let keep = spec.preserve.clamp(0.0, 100.0) / 100.0;
|
||||
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - keep);
|
||||
let d = guided(&yv, &yv, ww, wh, r.min(MAX_LUMA_R), eps);
|
||||
let d = guided_luma(&yv, ww, wh, r.min(MAX_LUMA_R), eps);
|
||||
let k = s.sqrt();
|
||||
for (v, dv) in yv.iter_mut().zip(d) {
|
||||
*v += (dv - *v) * k;
|
||||
@@ -135,12 +164,7 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
if col > 0.0 && model != Model::Gray {
|
||||
let r = (1.0 + col * 8.0).round() as usize;
|
||||
let eps = (0.01 + 0.05 * col).powi(2);
|
||||
for plane in [&mut c1, &mut c2] {
|
||||
let d = guided(&yv, plane, ww, wh, r.min(MAX_CHROMA_R), eps);
|
||||
for (v, dv) in plane.iter_mut().zip(d) {
|
||||
*v += (dv - *v) * col;
|
||||
}
|
||||
}
|
||||
guided_chroma(&yv, [&mut c1, &mut c2], ww, wh, r.min(MAX_CHROMA_R), eps, col);
|
||||
}
|
||||
// Sharpen Details: unsharp mask on luma.
|
||||
if sh > 0.0 {
|
||||
@@ -178,3 +202,106 @@ pub(crate) fn reduce_noise(src: &Image, out: Rect, ctx: &Ctx, spec: &DenoiseSpec
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Guided filter of `p` with guide `g` (both `w × h`), box radius `r`.
|
||||
fn reference_guided(g: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec<f32> {
|
||||
let len = w * h;
|
||||
// Interleave the four box-filtered moments to run one pass.
|
||||
let mut m = vec![0.0f32; len * 4];
|
||||
for i in 0..len {
|
||||
m[i * 4] = g[i];
|
||||
m[i * 4 + 1] = p[i];
|
||||
m[i * 4 + 2] = g[i] * g[i];
|
||||
m[i * 4 + 3] = g[i] * p[i];
|
||||
}
|
||||
box_blur_n(&mut m, w, h, 4, r);
|
||||
let mut ab = vec![0.0f32; len * 2];
|
||||
for i in 0..len {
|
||||
let (mg, mp, mgg, mgp) = (m[i * 4], m[i * 4 + 1], m[i * 4 + 2], m[i * 4 + 3]);
|
||||
let var = (mgg - mg * mg).max(0.0);
|
||||
let a = (mgp - mg * mp) / (var + eps);
|
||||
ab[i * 2] = a;
|
||||
ab[i * 2 + 1] = mp - a * mg;
|
||||
}
|
||||
box_blur_n(&mut ab, w, h, 2, r);
|
||||
(0..len).map(|i| ab[i * 2] * g[i] + ab[i * 2 + 1]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_statistics_match_independent_filters_bit_for_bit() {
|
||||
for (w, h) in [(1, 1), (1, 19), (23, 1), (7, 5), (33, 17)] {
|
||||
let len = w * h;
|
||||
for flat in [false, true] {
|
||||
let g: Vec<f32> = (0..len).map(|i| if flat { 0.1 } else { ((i * 37 % 101) as f32 - 20.0) / 63.0 }).collect();
|
||||
let p: Vec<f32> = (0..len).map(|i| (i * 13 % 71) as f32 / 70.0 - 0.5).collect();
|
||||
let q: Vec<f32> = (0..len).map(|i| (i * 29 % 83) as f32 / 41.0 - 1.0).collect();
|
||||
for r in [0, 1, MAX_LUMA_R, MAX_CHROMA_R, 25] {
|
||||
for eps in [0.000001, 0.0036] {
|
||||
let expected = reference_guided(&g, &g, w, h, r, eps);
|
||||
let actual = guided_luma(&g, w, h, r, eps);
|
||||
assert_bits(&actual, &expected);
|
||||
for amount in [0.01, 0.45, 1.0] {
|
||||
let mut actual_p = p.clone();
|
||||
let mut actual_q = q.clone();
|
||||
guided_chroma(&g, [&mut actual_p, &mut actual_q], w, h, r, eps, amount);
|
||||
for (original, actual) in [(&p, &actual_p), (&q, &actual_q)] {
|
||||
let filtered = reference_guided(&g, original, w, h, r, eps);
|
||||
let expected: Vec<f32> = original.iter().zip(filtered).map(|(&v, d)| v + (d - v) * amount).collect();
|
||||
assert_bits(actual, &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduce_noise_guides_both_chroma_planes_by_the_cleaned_luma() {
|
||||
// Caller wiring: Lab keeps its planes as they are, so each output plane is compared with
|
||||
// the independent filters run on the planes in order (luma by itself, chroma by the
|
||||
// cleaned luma).
|
||||
let (w, h) = (19, 13);
|
||||
let rect = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut src = Image::new(rect, 3);
|
||||
for (i, px) in src.data.as_chunks_mut::<3>().0.iter_mut().enumerate() {
|
||||
px[0] = (i * 37 % 101) as f32 / 100.0;
|
||||
px[1] = (i * 13 % 71) as f32 / 70.0 - 0.5;
|
||||
px[2] = (i * 29 % 83) as f32 / 41.0 - 1.0;
|
||||
}
|
||||
let ctx = Ctx { bounds: rect, mode: ColorMode::Lab, alpha: false };
|
||||
let spec = DenoiseSpec { strength: 6.0, preserve: 30.0, color: 50.0, sharpen: 0.0, jpeg: false };
|
||||
let out = reduce_noise(&src, rect, &ctx, &spec);
|
||||
|
||||
let plane = |c: usize| -> Vec<f32> { src.data.as_chunks::<3>().0.iter().map(|px| px[c]).collect() };
|
||||
let (luma, chroma) = (plane(0), [plane(1), plane(2)]);
|
||||
let s = spec.strength / 10.0;
|
||||
let eps = (0.004 + 0.05 * s).powi(2) * (1.05 - spec.preserve / 100.0);
|
||||
let smooth = reference_guided(&luma, &luma, w, h, (1.0 + spec.strength * 0.4).round() as usize, eps);
|
||||
let cleaned: Vec<f32> = luma.iter().zip(&smooth).map(|(&v, &d)| v + (d - v) * s.sqrt()).collect();
|
||||
let col = spec.color / 100.0;
|
||||
let chroma_r = (1.0 + col * 8.0).round() as usize;
|
||||
let chroma_eps = (0.01 + 0.05 * col).powi(2);
|
||||
let mut expected = vec![cleaned];
|
||||
for p in &chroma {
|
||||
let filtered = reference_guided(&expected[0], p, w, h, chroma_r, chroma_eps);
|
||||
expected.push(p.iter().zip(filtered).map(|(&v, f)| v + (f - v) * col).collect());
|
||||
}
|
||||
for (c, expected) in expected.iter().enumerate() {
|
||||
let actual: Vec<f32> = out.as_chunks::<3>().0.iter().map(|px| px[c]).collect();
|
||||
assert_bits(&actual, expected);
|
||||
assert!(actual.iter().zip(&plane(c)).any(|(a, o)| (a - o).abs() > 1e-3), "plane {c} was filtered");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_bits(actual: &[f32], expected: &[f32]) {
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (i, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "pixel {i}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+56
-10
@@ -210,34 +210,80 @@ pub fn local_sharpen(
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
let (x0, y0, x1, y1) = win;
|
||||
let ow = x1 - x0;
|
||||
let (ow, oh) = (x1 - x0, y1 - y0);
|
||||
if ow == 0 || oh == 0 || ch == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
|
||||
let mut out = vec![0.0f32; blur.len()];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let o = ((y - y0) * ow + (x - x0)) * ch;
|
||||
let stride = ow * ch;
|
||||
|
||||
// Rows are independent, so process them in parallel with rayon (#5426).
|
||||
let process_row = |(oy, dst): (usize, &mut [f32])| {
|
||||
let y = y0 + oy;
|
||||
let blur_row = &blur[oy * stride..(oy + 1) * stride];
|
||||
for ox in 0..ow {
|
||||
let x = x0 + ox;
|
||||
let o = ox * ch;
|
||||
let i = (y * w + x) * ch;
|
||||
|
||||
// Precompute 3×3 neighbourhood offsets once per pixel if protecting detail.
|
||||
let offsets: [usize; 8] = if protect_detail {
|
||||
if x > 0 && x + 1 < w && y > 0 && y + 1 < h {
|
||||
let w_ch = w * ch;
|
||||
[i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch]
|
||||
} else {
|
||||
let get_n_idx = |dx: i32, dy: i32| -> usize {
|
||||
let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
|
||||
let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
|
||||
(ny * w + nx) * ch
|
||||
};
|
||||
[
|
||||
get_n_idx(-1, -1),
|
||||
get_n_idx(0, -1),
|
||||
get_n_idx(1, -1),
|
||||
get_n_idx(-1, 0),
|
||||
get_n_idx(1, 0),
|
||||
get_n_idx(-1, 1),
|
||||
get_n_idx(0, 1),
|
||||
get_n_idx(1, 1),
|
||||
]
|
||||
}
|
||||
} else {
|
||||
[0; 8]
|
||||
};
|
||||
|
||||
for c in 0..ch {
|
||||
let v = data[i + c];
|
||||
if Some(c) == alpha {
|
||||
out[o + c] = v;
|
||||
dst[o + c] = v;
|
||||
continue;
|
||||
}
|
||||
let mut s = v + amount * (v - blur[o + c]);
|
||||
let mut s = v + amount * (v - blur_row[o + c]);
|
||||
if protect_detail {
|
||||
let (mut lo, mut hi) = (v, v);
|
||||
for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||||
let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
|
||||
let nv = data[(ny * w + nx) * ch + c];
|
||||
for &n_idx in &offsets {
|
||||
let nv = data[n_idx + c];
|
||||
lo = lo.min(nv);
|
||||
hi = hi.max(nv);
|
||||
}
|
||||
s = s.clamp(lo, hi);
|
||||
}
|
||||
out[o + c] = s;
|
||||
dst[o + c] = s;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
out.par_chunks_mut(stride).enumerate().for_each(process_row);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
out.chunks_mut(stride).enumerate().for_each(process_row);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
+69
-10
@@ -37,8 +37,54 @@ impl EntropyScratch {
|
||||
}
|
||||
}
|
||||
|
||||
/// One shared table per combine, never per pixel or worker. Rows cover the contributing sample
|
||||
/// count, which can change at every pixel because of transparency or unequal layer lengths.
|
||||
/// The triangular layout stores log2(n) followed by -p*log2(p) for counts 1..=n. Keeping the
|
||||
/// division after the sum (rather than normalising each term) preserves the old output bits.
|
||||
struct EntropyTerms {
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
impl EntropyTerms {
|
||||
// At most 130 KiB, independent of image size and total layer count. Deeper stacks retain
|
||||
// the original calculation; pixels with fewer contributors can still use a cached row.
|
||||
const MAX_SAMPLES: usize = 256;
|
||||
|
||||
fn new(samples: usize, pixels: usize) -> Self {
|
||||
let mut values = Vec::new();
|
||||
// Tiny images do not amortise preparation, especially with hundreds of layers.
|
||||
if samples < 2 || pixels < 256 {
|
||||
return Self { values };
|
||||
}
|
||||
let max = samples.min(Self::MAX_SAMPLES);
|
||||
// max is capped at 256 before any size arithmetic or allocation.
|
||||
let len = (max + 1) * (max + 2) / 2;
|
||||
if values.try_reserve_exact(len).is_err() {
|
||||
return Self { values };
|
||||
}
|
||||
values.extend_from_slice(&[0.0; 3]); // Unused rows for zero and one sample.
|
||||
for samples in 2..=max {
|
||||
let n = samples as f32;
|
||||
values.push(n.log2());
|
||||
for count in 1..=samples {
|
||||
let p = count as f32 / n;
|
||||
values.push(-p * p.log2());
|
||||
}
|
||||
}
|
||||
Self { values }
|
||||
}
|
||||
|
||||
fn row(&self, samples: usize) -> Option<&[f32]> {
|
||||
if !(2..=Self::MAX_SAMPLES).contains(&samples) {
|
||||
return None;
|
||||
}
|
||||
let start = samples * (samples + 1) / 2;
|
||||
self.values.get(start..start + samples + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shannon entropy (bits) of the 8-bit values in `v`, over log2(N). `v.len() >= 2`.
|
||||
fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
fn entropy(v: &[f32], scratch: &mut EntropyScratch, terms: Option<&[f32]>) -> f32 {
|
||||
let n = v.len() as f32;
|
||||
for x in v {
|
||||
let bin = (x.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
@@ -57,8 +103,11 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
.touched
|
||||
.iter()
|
||||
.map(|&bin| {
|
||||
let p = scratch.counts[usize::from(bin)] as f32 / n;
|
||||
-p * p.log2()
|
||||
let count = scratch.counts[usize::from(bin)];
|
||||
terms.and_then(|row| row.get(count as usize)).copied().unwrap_or_else(|| {
|
||||
let p = count as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
})
|
||||
.sum();
|
||||
// Reset only the bins used, so the 256-entry array is never cleared wholesale.
|
||||
@@ -66,7 +115,7 @@ fn entropy(v: &[f32], scratch: &mut EntropyScratch) -> f32 {
|
||||
scratch.counts[usize::from(bin)] = 0;
|
||||
}
|
||||
scratch.touched.clear();
|
||||
h / n.log2()
|
||||
h / terms.and_then(|row| row.first()).copied().unwrap_or_else(|| n.log2())
|
||||
}
|
||||
|
||||
/// `x` to the power `k` by repeated squaring. Every step is one IEEE 754 multiplication, so the
|
||||
@@ -99,17 +148,23 @@ fn stat(v: &mut [f32], s: Stat, scratch: &mut EntropyScratch) -> f32 {
|
||||
Stat::Maximum => return v.iter().copied().fold(f32::MIN, f32::max),
|
||||
Stat::Range => return v.iter().copied().fold(f32::MIN, f32::max) - v.iter().copied().fold(f32::MAX, f32::min),
|
||||
Stat::Median => {
|
||||
// `total_cmp` is a total order whose ties are bit-identical values, so an unstable
|
||||
// sort gives exactly the stable sort's result.
|
||||
v.sort_unstable_by(f32::total_cmp);
|
||||
let k = v.len();
|
||||
return if k % 2 == 1 { v[k / 2] } else { (v[k / 2 - 1] + v[k / 2]) / 2.0 };
|
||||
// Nonempty above, so k / 2 is in bounds. Selection puts the upper middle value in
|
||||
// place in O(k); the lower partition need not be sorted. Use the same total order
|
||||
// for its maximum to preserve signed zeros, infinities and NaNs.
|
||||
let (lower, middle, _) = v.select_nth_unstable_by(k / 2, f32::total_cmp);
|
||||
return if k % 2 == 1 {
|
||||
*middle
|
||||
} else {
|
||||
// For even k >= 2 the lower partition contains the other middle value.
|
||||
lower.iter().max_by(|a, b| a.total_cmp(b)).map_or(*middle, |lo| (*lo + *middle) / 2.0)
|
||||
};
|
||||
}
|
||||
Stat::Entropy => {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
return entropy(v, scratch);
|
||||
return entropy(v, scratch, None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -147,7 +202,11 @@ pub fn combine(layers: &[Vec<[f32; 4]>], s: Stat) -> Vec<[f32; 4]> {
|
||||
// serial loop (Mean, Variance, StdDev, Skewness, Kurtosis); this alone cuts Mean's
|
||||
// instructions by a fifth.
|
||||
match s {
|
||||
Stat::Entropy => combine_with(layers, |v, sc| stat(v, Stat::Entropy, sc)),
|
||||
Stat::Entropy => {
|
||||
let pixels = layers.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let terms = EntropyTerms::new(layers.len(), pixels);
|
||||
combine_with(layers, |v, sc| if v.len() < 2 { 0.0 } else { entropy(v, sc, terms.row(v.len())) })
|
||||
}
|
||||
Stat::Kurtosis => combine_with(layers, |v, sc| stat(v, Stat::Kurtosis, sc)),
|
||||
Stat::Maximum => combine_with(layers, |v, sc| stat(v, Stat::Maximum, sc)),
|
||||
Stat::Mean => combine_with(layers, |v, sc| stat(v, Stat::Mean, sc)),
|
||||
|
||||
@@ -60,6 +60,86 @@ fn median_removes_outlier() {
|
||||
assert_eq!(combine(&layers, Stat::Median)[0][0], 0.5);
|
||||
}
|
||||
|
||||
fn sorted_median(v: &mut [f32]) -> f32 {
|
||||
v.sort_unstable_by(f32::total_cmp);
|
||||
let k = v.len();
|
||||
if k == 0 {
|
||||
0.0
|
||||
} else if k % 2 == 1 {
|
||||
v[k / 2]
|
||||
} else {
|
||||
(v[k / 2 - 1] + v[k / 2]) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_median_matches_sort(mut values: Vec<f32>) {
|
||||
let expected = sorted_median(&mut values.clone());
|
||||
let actual = stat(&mut values, Stat::Median, &mut EntropyScratch::new());
|
||||
if expected.is_nan() {
|
||||
// IEEE arithmetic does not specify which NaN payload an even median propagates.
|
||||
assert!(actual.is_nan());
|
||||
} else {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{values:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_selection_matches_sort_for_varied_stack_sizes_and_orders() {
|
||||
for len in 0..=257 {
|
||||
let ascending: Vec<f32> = (0..len).map(|i| i as f32 / 16.0 - 8.0).collect();
|
||||
assert_median_matches_sort(ascending.clone());
|
||||
assert_median_matches_sort(ascending.into_iter().rev().collect());
|
||||
assert_median_matches_sort(vec![0.25; len]);
|
||||
assert_median_matches_sort((0..len).map(|i| ((i * 31) % 7) as f32 - 3.0).collect());
|
||||
// Deterministic shuffled samples, including float bit patterns outside 0..=1.
|
||||
let mut seed = 0x9e37_79b9u32;
|
||||
assert_median_matches_sort(
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
f32::from_bits(seed)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_selection_preserves_total_float_order() {
|
||||
let special = [f32::from_bits(0xffc0_0001), f32::NEG_INFINITY, -f32::MAX, -1.0, -0.0, 0.0, 1.0, f32::MAX, f32::INFINITY, f32::from_bits(0x7fc0_0001)];
|
||||
for &a in &special {
|
||||
// An odd median returns the selected NaN's bits without doing arithmetic.
|
||||
assert_eq!(stat(&mut [a], Stat::Median, &mut EntropyScratch::new()).to_bits(), a.to_bits());
|
||||
for &b in &special {
|
||||
assert_median_matches_sort(vec![a, b]);
|
||||
for &c in &special {
|
||||
let mut values = [a, b, c];
|
||||
let expected = sorted_median(&mut values.clone());
|
||||
assert_eq!(stat(&mut values, Stat::Median, &mut EntropyScratch::new()).to_bits(), expected.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_median_stack_uses_only_visible_samples_per_channel() {
|
||||
for count in [32, 33, 128, 129] {
|
||||
let layers: Vec<Vec<[f32; 4]>> = (0..count)
|
||||
.map(|i| {
|
||||
let pixel = [((i * 31) % 137) as f32, -(i as f32), (i % 5) as f32, if i % 3 == 0 { 0.0 } else { 0.5 }];
|
||||
if i % 7 == 0 { vec![pixel] } else { vec![pixel, [0.0; 4]] }
|
||||
})
|
||||
.collect();
|
||||
let actual = combine(&layers, Stat::Median);
|
||||
for channel in 0..3 {
|
||||
let mut samples: Vec<f32> = layers.iter().filter(|l| l[0][3] > 0.0).map(|l| l[0][channel]).collect();
|
||||
assert_eq!(actual[0][channel].to_bits(), sorted_median(&mut samples).to_bits());
|
||||
}
|
||||
assert_eq!(actual[0][3], 0.5);
|
||||
assert_eq!(actual[1], [0.0; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Five layers over six pixels (the last layer is short). Covers opaque, partly transparent,
|
||||
/// fully transparent, HDR / negative, single-sample and tied-value pixels.
|
||||
fn fixture() -> Vec<Vec<[f32; 4]>> {
|
||||
@@ -243,6 +323,102 @@ fn entropy_is_deterministic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-lookup calculation, summing the 8-bit bins in ascending order. This deliberately
|
||||
/// does not call `entropy` or use its lookup/scratch, so it catches changes in rounding too.
|
||||
fn entropy_reference(v: &[f32]) -> f32 {
|
||||
if v.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut counts = [0u32; 256];
|
||||
for x in v {
|
||||
counts[(x.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
|
||||
}
|
||||
let n = v.len() as f32;
|
||||
let h: f32 = counts
|
||||
.iter()
|
||||
.filter(|&&count| count > 0)
|
||||
.map(|&count| {
|
||||
let p = count as f32 / n;
|
||||
-p * p.log2()
|
||||
})
|
||||
.sum();
|
||||
h / n.log2()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_terms_preserve_each_operation_and_bound_storage() {
|
||||
let terms = EntropyTerms::new(usize::MAX, usize::MAX);
|
||||
let max = EntropyTerms::MAX_SAMPLES;
|
||||
assert_eq!(terms.values.len(), (max + 1) * (max + 2) / 2);
|
||||
assert!(terms.values.len() * size_of::<f32>() < 130 * 1024);
|
||||
for samples in 2..=max {
|
||||
let row = terms.row(samples).unwrap();
|
||||
let n = samples as f32;
|
||||
assert_eq!(row[0].to_bits(), n.log2().to_bits());
|
||||
for (count, &term) in row.iter().enumerate().skip(1) {
|
||||
let p = count as f32 / n;
|
||||
assert_eq!(term.to_bits(), (-p * p.log2()).to_bits(), "{count}/{samples}");
|
||||
}
|
||||
}
|
||||
for samples in [0, 1, max + 1, usize::MAX] {
|
||||
assert!(terms.row(samples).is_none());
|
||||
}
|
||||
assert!(EntropyTerms::new(1, usize::MAX).values.is_empty());
|
||||
assert!(EntropyTerms::new(usize::MAX, 255).values.is_empty());
|
||||
assert!(EntropyTerms { values: Vec::new() }.row(2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_lookup_and_fallback_match_reference_bits() {
|
||||
let terms = EntropyTerms::new(EntropyTerms::MAX_SAMPLES, CHUNK);
|
||||
let mut scratch = EntropyScratch::new();
|
||||
let special = [f32::NAN, f32::NEG_INFINITY, f32::INFINITY, -3.0, -0.0, 0.0, 0.5 / 255.0, 0.5, 1.0, 4.0];
|
||||
// Move repeatedly between counts and distributions to exercise scratch reset, duplicate
|
||||
// bins, every cached denominator and the uncached side of the table boundary.
|
||||
for samples in (2..=EntropyTerms::MAX_SAMPLES + 2).chain([513, 3, 256, 2]) {
|
||||
for pattern in 0..4 {
|
||||
let v: Vec<f32> = (0..samples)
|
||||
.map(|i| match pattern {
|
||||
0 => 0.5,
|
||||
1 => (i % 256) as f32 / 255.0,
|
||||
2 => ((i * 137 + samples * 29) % 65536) as f32 / 65535.0,
|
||||
_ => special[i % special.len()],
|
||||
})
|
||||
.collect();
|
||||
let expected = entropy_reference(&v).to_bits();
|
||||
assert_eq!(entropy(&v, &mut scratch, terms.row(samples)).to_bits(), expected, "cached: n={samples}, pattern={pattern}");
|
||||
assert_eq!(entropy(&v, &mut scratch, None).to_bits(), expected, "fallback: n={samples}, pattern={pattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entropy_combine_matches_reference_with_changing_contributor_counts() {
|
||||
for (count, len) in [(19, 2 * CHUNK + 17), (EntropyTerms::MAX_SAMPLES + 1, 513)] {
|
||||
let layers: Vec<Vec<[f32; 4]>> = (0..count)
|
||||
.map(|k| {
|
||||
(0..len.saturating_sub(k % 13))
|
||||
.map(|i| {
|
||||
[
|
||||
((i * 31 + k * 7) % 256) as f32 / 255.0,
|
||||
((i * 17 + k) % 65536) as f32 / 65535.0,
|
||||
if (i + k).is_multiple_of(23) { f32::NAN } else { (i % 7) as f32 - 2.0 },
|
||||
if k > i % (count + 1) { 0.0 } else { 0.5 },
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let expected = bits(&combine_with(&layers, |v, _| entropy_reference(v)));
|
||||
assert_eq!(bits(&combine(&layers, Stat::Entropy)), expected);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for threads in [1, 3] {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
assert_eq!(pool.install(|| bits(&combine(&layers, Stat::Entropy))), expected, "{threads} threads");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chunking is invisible: a multi-chunk image (with a ragged last chunk and layers of unequal
|
||||
/// length) gives, pixel by pixel, what combining that pixel on its own gives.
|
||||
#[test]
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
mod liquify_freeze_formats;
|
||||
mod liquify_source_footprint;
|
||||
mod retouch_blur_exact;
|
||||
mod retouch_sharpen_exact;
|
||||
mod warp_clipped;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Frozen scalar sharpen from main, used as an exact-output oracle.
|
||||
use photocraft_algo::retouch::{local_blur, local_sharpen};
|
||||
|
||||
#[test]
|
||||
fn matches_scalar_sharpen_bit_for_bit_across_layouts_edges_and_hdr() {
|
||||
for (w, h) in [(1, 1), (3, 7), (37, 29)] {
|
||||
for ch in 1..=5 {
|
||||
for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
|
||||
let data: Vec<f32> = (0..w * h * ch)
|
||||
.map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
|
||||
.collect();
|
||||
for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
|
||||
for amount in [0.0, 0.5, 1.0, 2.5] {
|
||||
for protect_detail in [false, true] {
|
||||
let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);
|
||||
let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
|
||||
assert_eq!(
|
||||
a.to_bits(),
|
||||
b.to_bits(),
|
||||
"{w}x{h} ch={ch} alpha={alpha:?} win={win:?} amount={amount} protect={protect_detail} sample={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_channel_layout_remains_empty() {
|
||||
assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scalar_local_sharpen(
|
||||
data: &[f32],
|
||||
w: usize,
|
||||
h: usize,
|
||||
ch: usize,
|
||||
alpha: Option<usize>,
|
||||
win: (usize, usize, usize, usize),
|
||||
amount: f32,
|
||||
protect_detail: bool,
|
||||
) -> Vec<f32> {
|
||||
let (x0, y0, x1, y1) = win;
|
||||
let (ow, oh) = (x1 - x0, y1 - y0);
|
||||
if ow == 0 || oh == 0 || ch == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
|
||||
let mut out = vec![0.0f32; blur.len()];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let o = ((y - y0) * ow + (x - x0)) * ch;
|
||||
let i = (y * w + x) * ch;
|
||||
for c in 0..ch {
|
||||
let v = data[i + c];
|
||||
if Some(c) == alpha {
|
||||
out[o + c] = v;
|
||||
continue;
|
||||
}
|
||||
let mut s = v + amount * (v - blur[o + c]);
|
||||
if protect_detail {
|
||||
let (mut lo, mut hi) = (v, v);
|
||||
for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
|
||||
let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
|
||||
let nv = data[(ny * w + nx) * ch + c];
|
||||
lo = lo.min(nv);
|
||||
hi = hi.max(nv);
|
||||
}
|
||||
s = s.clamp(lo, hi);
|
||||
}
|
||||
out[o + c] = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -108,8 +108,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
||||
|| (),
|
||||
|_, y, row, _| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
|
||||
let p = tile.sample(u, v);
|
||||
let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
|
||||
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
|
||||
*o += k * if t.invert { 1.0 - l } else { l };
|
||||
}
|
||||
|
||||
@@ -339,8 +339,7 @@ pub(super) fn paint_pattern(
|
||||
m,
|
||||
|i| {
|
||||
let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5);
|
||||
let (u, v) = place.map(x, y);
|
||||
tile.sample(u, v)
|
||||
tile.sample_area(&place, x, y)
|
||||
},
|
||||
blend,
|
||||
opacity,
|
||||
|
||||
+357
-48
@@ -3,19 +3,32 @@
|
||||
//! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas
|
||||
//! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with
|
||||
//! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile
|
||||
//! exactly and scaled ones stay seamless.
|
||||
//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an
|
||||
//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a
|
||||
//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
|
||||
use photocraft_doc::{ColorMode, Pattern, PixelFormat, Rect, SampleType};
|
||||
|
||||
pub(crate) const PREPARED_PATTERN_BYTES: usize = 64 << 20;
|
||||
/// What one rendering call may keep of its patterns' working copies: every pattern it paints with
|
||||
/// stays converted for the call (four of the largest 8-bit ones on desktop), so none is converted
|
||||
/// once per output tile. A ceiling, not an allocation: only the patterns a call paints with are
|
||||
/// converted. Past it a pattern is converted under one lock and shared while in use (see
|
||||
/// [`PreparedPatterns::get`]). The web build keeps a quarter of that.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const PREPARED_PATTERN_BYTES: usize = 1 << 30;
|
||||
/// See the desktop value above.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) const PREPARED_PATTERN_BYTES: usize = 256 << 20;
|
||||
const MAX_PREPARED_PATTERNS: usize = 64;
|
||||
/// Absolute cap on a single pattern's RGBA conversion on the uncached path. A pattern this
|
||||
/// large cannot be cached, so re-expanding it for every output tile would be a DoS; above the
|
||||
/// cap it is skipped instead. Independent of the cache budget so small patterns still convert.
|
||||
const MAX_UNCACHED_PATTERN_BYTES: usize = 256 << 20;
|
||||
/// Absolute cap on a single pattern's working copy on the uncached path: the document's own cap
|
||||
/// on what paints (`Pattern::is_paintable`). A pattern above it is skipped, not converted.
|
||||
const MAX_UNCACHED_PATTERN_BYTES: usize = photocraft_doc::pattern::MAX_WORKING_BYTES as usize;
|
||||
// A call keeps at least two of the largest, or a second large fill is converted for every tile.
|
||||
const _: () = assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
|
||||
type PreparedSlot = Arc<OnceLock<Option<Arc<Tile>>>>;
|
||||
|
||||
struct PreparedState {
|
||||
@@ -23,6 +36,15 @@ struct PreparedState {
|
||||
reserved_bytes: usize,
|
||||
}
|
||||
|
||||
/// Patterns past the call's budget: converted one at a time under this lock, and shared by the
|
||||
/// workers using one at the same moment (a `Weak` per pattern), so the extra copies in memory are
|
||||
/// the patterns in use, not one per worker.
|
||||
#[derive(Default)]
|
||||
struct OverBudget {
|
||||
live: Mutex<HashMap<usize, Weak<Tile>>>,
|
||||
warned: AtomicBool,
|
||||
}
|
||||
|
||||
/// Demand-prepared pixels owned by one rendering call, never by the document.
|
||||
pub(crate) struct PreparedPatterns<'a> {
|
||||
patterns: &'a [Pattern],
|
||||
@@ -30,11 +52,18 @@ pub(crate) struct PreparedPatterns<'a> {
|
||||
/// The colour mode the patterns are painted into (see [`pattern_rgba`]).
|
||||
mode: ColorMode,
|
||||
state: Mutex<PreparedState>,
|
||||
over_budget: OverBudget,
|
||||
}
|
||||
|
||||
impl<'a> PreparedPatterns<'a> {
|
||||
pub(crate) fn new(patterns: &'a [Pattern], budget: usize) -> Self {
|
||||
Self { patterns, budget, mode: ColorMode::Rgb, state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }) }
|
||||
Self {
|
||||
patterns,
|
||||
budget,
|
||||
mode: ColorMode::Rgb,
|
||||
state: Mutex::new(PreparedState { slots: HashMap::new(), reserved_bytes: 0 }),
|
||||
over_budget: OverBudget::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Patterns painted into a document of colour mode `mode`.
|
||||
@@ -52,10 +81,7 @@ impl<'a> PreparedPatterns<'a> {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
let bytes = usize::try_from(p.width)
|
||||
.ok()
|
||||
.and_then(|w| usize::try_from(p.height).ok().and_then(|h| w.checked_mul(h)))
|
||||
.and_then(|pixels| pixels.checked_mul(std::mem::size_of::<[f32; 4]>()));
|
||||
let bytes = usize::try_from(p.working_bytes()).ok();
|
||||
// The borrowed immutable slice pins these addresses for the complete call.
|
||||
let key = std::ptr::from_ref(p) as usize;
|
||||
let slot = {
|
||||
@@ -82,14 +108,40 @@ impl<'a> PreparedPatterns<'a> {
|
||||
// Run on the requesting worker to preserve its active CMYK profile.
|
||||
slot.get_or_init(|| Tile::new_in(p, self.mode).map(Arc::new)).clone()
|
||||
}
|
||||
// Uncached path: a pattern whose full conversion would exceed an absolute cap is
|
||||
// not materialized at all. Otherwise every output tile would re-expand the entire
|
||||
// sparse pattern into RGBA floats (and could do so concurrently), exhausting CPU and
|
||||
// memory even though cache admission already refused to retain it. The cap is
|
||||
// independent of the (possibly zero) cache budget, so small patterns still convert
|
||||
// on demand when nothing is cached.
|
||||
// Uncached path: a pattern whose working copy would exceed an absolute cap is not
|
||||
// materialized at all. One past the call's budget is converted under one lock, and
|
||||
// shared while some worker still holds it, so workers painting tiles at the same
|
||||
// time do not each convert their own copy (CPU and memory), and it says so once.
|
||||
// The cap is independent of the (possibly zero) cache budget, so small patterns
|
||||
// still convert on demand when nothing is cached.
|
||||
None if bytes.is_none_or(|b| b > MAX_UNCACHED_PATTERN_BYTES) => None,
|
||||
None => Tile::new_in(p, self.mode).map(Arc::new),
|
||||
None => {
|
||||
// The lock is held through the conversion, so conversions of different
|
||||
// over-budget patterns run one at a time. That is the point (one extra copy
|
||||
// being built at once, not one per worker) and fine for a fallback: patterns
|
||||
// that fit the budget never come here.
|
||||
let mut live = self.over_budget.live.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(shared) = live.get(&key).and_then(Weak::upgrade) {
|
||||
return Some(shared);
|
||||
}
|
||||
if !self.over_budget.warned.swap(true, Ordering::Relaxed) {
|
||||
// Written, not printed: a warning must never panic, even with no stderr.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::io::Write as _;
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"photocraft: pattern \"{}\" is past this render's pattern memory ({} MiB) and is converted again as tiles need it",
|
||||
p.display_name(),
|
||||
self.budget >> 20
|
||||
);
|
||||
}
|
||||
}
|
||||
let tile = Tile::new_in(p, self.mode).map(Arc::new)?;
|
||||
live.retain(|_, w| w.strong_count() > 0);
|
||||
live.insert(key, Arc::downgrade(&tile));
|
||||
Some(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,29 +153,80 @@ impl<'a> PreparedPatterns<'a> {
|
||||
/// pattern in colour in its picker, filled in grey). Empty for a pattern that isn't
|
||||
/// [paintable](Pattern::is_paintable).
|
||||
pub fn pattern_rgba(p: &Pattern, mode: ColorMode) -> Vec<[f32; 4]> {
|
||||
let Some(n) = p.is_paintable().then(|| usize::try_from(p.pixel_count()).ok()).flatten() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut px = vec![[0.0f32; 4]; n];
|
||||
p.surface.read_rgba_into(p.rect(), &mut px);
|
||||
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
|
||||
if paints_in && p.surface.format().mode != mode {
|
||||
let fmt = PixelFormat::new(mode, SampleType::F32, true);
|
||||
let mut v = [0.0f32; 5];
|
||||
for q in &mut px {
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
|
||||
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
|
||||
}
|
||||
let mut px = Vec::new();
|
||||
if p.is_paintable() && pattern_rgba_rows(p, mode, 0, p.height, &mut px).is_none() {
|
||||
px = Vec::new();
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// A pattern converted once to straight-alpha RGBA for sampling.
|
||||
/// Rows `top..bottom` of [`pattern_rgba`] (of a paintable pattern), replacing `out`: a large
|
||||
/// pattern is read a band at a time instead of as one copy at 16 bytes per pixel. `None` when the
|
||||
/// memory for the rows cannot be had (on the web a failed allocation aborts the tab, so it is
|
||||
/// asked for, not assumed).
|
||||
pub fn pattern_rgba_rows(p: &Pattern, mode: ColorMode, top: u32, bottom: u32, out: &mut Vec<[f32; 4]>) -> Option<()> {
|
||||
let bottom = bottom.min(p.height);
|
||||
let top = top.min(bottom);
|
||||
out.clear();
|
||||
let pixels = (p.width as usize).checked_mul((bottom - top) as usize)?;
|
||||
out.try_reserve_exact(pixels).ok()?;
|
||||
out.resize(pixels, [0.0f32; 4]);
|
||||
p.surface.read_rgba_into(Rect::new(0, top as i32, p.width as i32, bottom as i32), out);
|
||||
let paints_in = matches!(mode, ColorMode::Grayscale | ColorMode::Bitmap | ColorMode::Duotone | ColorMode::Cmyk);
|
||||
if paints_in && p.surface.format().mode != mode {
|
||||
let fmt = PixelFormat::new(mode, SampleType::F32, true);
|
||||
let mut v = [0.0f32; 5];
|
||||
for q in out.iter_mut() {
|
||||
let n = photocraft_raster::from_rgba_into(&fmt, *q, &mut v);
|
||||
*q = photocraft_raster::to_rgba(&fmt, v.get(..n).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The pixels of a paintable pattern as they paint in a document of colour mode `mode`, rounded
|
||||
/// to the pattern's own sample grid by `quantize`, read a band of rows at a time: no full-size
|
||||
/// `f32` copy. A pattern with its own colours comes through exactly (its samples are on that
|
||||
/// grid); one converted to a grey or CMYK document is rounded to the grid it was drawn on. `None`
|
||||
/// when the memory cannot be had.
|
||||
fn quantized_rgba<T>(p: &Pattern, mode: ColorMode, quantize: impl Fn(f32) -> T) -> Option<Vec<[T; 4]>> {
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact((p.width as usize).checked_mul(p.height as usize)?).ok()?;
|
||||
let mut band = Vec::new();
|
||||
for top in (0..p.height).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
|
||||
pattern_rgba_rows(p, mode, top, top + photocraft_doc::pattern::BAND_ROWS, &mut band)?;
|
||||
out.extend(band.iter().map(|c| [quantize(c[0]), quantize(c[1]), quantize(c[2]), quantize(c[3])]));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `u8 / 255` as `f32`, for every `u8`: the value an 8-bit sample reads as.
|
||||
const UNIT8: [f32; 256] = {
|
||||
let mut table = [0.0f32; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
table[i] = i as f32 / 255.0;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
};
|
||||
|
||||
/// A pattern's pixels as the sampler holds them.
|
||||
enum Texels {
|
||||
/// Premultiplied `f32` RGBA, 16 bytes per pixel: any pattern that fits.
|
||||
Premultiplied(Vec<[f32; 4]>),
|
||||
/// Straight RGBA8, 4 bytes per pixel, premultiplied as each tap reads it (see [`UNIT8`]): a
|
||||
/// large 8-bit pattern, with the values the expanded copy would hold.
|
||||
Rgba8(Vec<[u8; 4]>),
|
||||
/// Straight RGBA16, 8 bytes per pixel, premultiplied as each tap reads it: a large 16-bit one.
|
||||
Rgba16(Vec<[u16; 4]>),
|
||||
}
|
||||
|
||||
/// A pattern converted once for sampling.
|
||||
pub struct Tile {
|
||||
w: usize,
|
||||
h: usize,
|
||||
/// Premultiplied RGBA.
|
||||
px: Vec<[f32; 4]>,
|
||||
texels: Texels,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
@@ -137,26 +240,83 @@ impl Tile {
|
||||
if !p.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
Tile::build(p, mode, p.pixel_count().saturating_mul(16) > photocraft_doc::pattern::MAX_WORKING_BYTES)
|
||||
}
|
||||
|
||||
/// [`Tile::new_in`], held at the pattern's own depth when `compact` and it has a smaller form.
|
||||
fn build(p: &Pattern, mode: ColorMode, compact: bool) -> Option<Tile> {
|
||||
let (w, h) = (p.width as usize, p.height as usize);
|
||||
let mut px = pattern_rgba(p, mode);
|
||||
for q in &mut px {
|
||||
for c in 0..3 {
|
||||
q[c] *= q[3];
|
||||
let texels = match p.surface.format().sample {
|
||||
// A large pattern is held at its own depth (`Pattern::working_bytes`).
|
||||
SampleType::U8 if compact => Texels::Rgba8(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)?),
|
||||
SampleType::U16 if compact => Texels::Rgba16(quantized_rgba(p, mode, |v| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16)?),
|
||||
_ => {
|
||||
let mut px = pattern_rgba(p, mode);
|
||||
if px.len() as u64 != p.pixel_count() {
|
||||
return None;
|
||||
}
|
||||
for q in &mut px {
|
||||
for c in 0..3 {
|
||||
q[c] *= q[3];
|
||||
}
|
||||
}
|
||||
Texels::Premultiplied(px)
|
||||
}
|
||||
}
|
||||
Some(Tile { w, h, px })
|
||||
};
|
||||
Some(Tile { w, h, texels })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn at(&self, x: i64, y: i64) -> [f32; 4] {
|
||||
let xi = x.rem_euclid(self.w as i64) as usize;
|
||||
let yi = y.rem_euclid(self.h as i64) as usize;
|
||||
self.px[yi * self.w + xi]
|
||||
let i = yi * self.w + xi;
|
||||
match &self.texels {
|
||||
Texels::Premultiplied(px) => px.get(i).copied().unwrap_or_default(),
|
||||
Texels::Rgba8(px) => {
|
||||
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
|
||||
let a = UNIT8[usize::from(a)];
|
||||
[UNIT8[usize::from(r)] * a, UNIT8[usize::from(g)] * a, UNIT8[usize::from(b)] * a, a]
|
||||
}
|
||||
Texels::Rgba16(px) => {
|
||||
let [r, g, b, a] = px.get(i).copied().unwrap_or_default();
|
||||
let unit = |v: u16| f32::from(v) / 65535.0;
|
||||
let a = unit(a);
|
||||
[unit(r) * a, unit(g) * a, unit(b) * a, a]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`).
|
||||
/// Returns straight alpha.
|
||||
pub fn sample(&self, u: f64, v: f64) -> [f32; 4] {
|
||||
unpremultiply(self.sample_premul(u, v))
|
||||
}
|
||||
|
||||
/// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's
|
||||
/// footprint in the tile when the placement shrinks it. Returns straight alpha.
|
||||
pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] {
|
||||
let n = pixel_taps(place.inv_scale);
|
||||
if n == 1 {
|
||||
let (u, v) = place.map(x, y);
|
||||
return self.sample(u, v);
|
||||
}
|
||||
let mut acc = [0.0f32; 4];
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n));
|
||||
let q = self.sample_premul(u, v);
|
||||
for c in 0..4 {
|
||||
acc[c] += q[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = 1.0 / (n * n) as f32;
|
||||
unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k])
|
||||
}
|
||||
|
||||
/// Bilinear, wrapping sample, premultiplied.
|
||||
fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] {
|
||||
let (u, v) = (u - 0.5, v - 0.5);
|
||||
let (x0, y0) = (u.floor(), v.floor());
|
||||
let (fx, fy) = ((u - x0) as f32, (v - y0) as f32);
|
||||
@@ -177,13 +337,32 @@ impl Tile {
|
||||
}
|
||||
}
|
||||
}
|
||||
if acc[3] <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
|
||||
const MAX_TAPS: usize = 8;
|
||||
|
||||
/// 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 {
|
||||
let s = inv_scale as f32;
|
||||
if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 }
|
||||
}
|
||||
|
||||
/// Offset in document pixels of tap `i` of `n`, centred on the pixel.
|
||||
fn tap_offset(i: usize, n: usize) -> f64 {
|
||||
(i as f64 + 0.5) / n as f64 - 0.5
|
||||
}
|
||||
|
||||
fn unpremultiply(acc: [f32; 4]) -> [f32; 4] {
|
||||
if acc[3] <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
[acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)]
|
||||
}
|
||||
|
||||
/// Where a pattern sits: document point → tile coordinates.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Placement {
|
||||
@@ -234,8 +413,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> {
|
||||
let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
out.push(tile.sample(u, v));
|
||||
out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -282,6 +460,114 @@ mod tests {
|
||||
assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none());
|
||||
}
|
||||
|
||||
/// Review of #8902: what `Pattern::is_paintable` admits, the compositor expands. A pattern that
|
||||
/// fits is expanded as before; a large one is held at its own depth, so Define Pattern's
|
||||
/// 8000 x 8000 fits at 8 bits. (Only the size is declared: the patterns themselves are empty.)
|
||||
#[test]
|
||||
fn the_most_paintable_pixels_still_expand() {
|
||||
let sparse = |name: &str, (width, height): (u32, u32), format: PixelFormat| Pattern {
|
||||
id: name.into(),
|
||||
name: name.into(),
|
||||
width,
|
||||
height,
|
||||
surface: Surface::new(format),
|
||||
};
|
||||
let float = PixelFormat::new(ColorMode::Rgb, SampleType::F32, true);
|
||||
let list = [
|
||||
sparse("max8", (8000, 8000), PixelFormat::RGBA8),
|
||||
sparse("max16", (5792, 5792), PixelFormat::RGBA16),
|
||||
sparse("max32", (4096, 4096), float),
|
||||
sparse("over8", (9000, 8000), PixelFormat::RGBA8),
|
||||
sparse("over16", (8000, 8000), PixelFormat::RGBA16),
|
||||
sparse("over32", (4097, 4096), float),
|
||||
];
|
||||
assert_eq!(list.each_ref().map(|p| p.is_paintable()), [true, true, true, false, false, false]);
|
||||
let prepared = PreparedPatterns::new(&list, PREPARED_PATTERN_BYTES);
|
||||
let held = |name: &str| prepared.get(name, "").map(|t| (matches!(t.texels, Texels::Rgba8(_)), matches!(t.texels, Texels::Rgba16(_))));
|
||||
assert_eq!(held("max8"), Some((true, false)), "an 8-bit 8000 x 8000 pattern expands to 4 bytes a pixel");
|
||||
assert_eq!(held("max16"), Some((false, true)), "a 16-bit one to 8");
|
||||
for name in ["over8", "over16", "over32"] {
|
||||
assert!(prepared.get(name, "").is_none(), "{name} is skipped, not allocated");
|
||||
}
|
||||
assert!(PreparedPatterns::new(&list, 0).get("max8", "").is_some(), "uncached too, at the cap");
|
||||
}
|
||||
|
||||
/// A render that paints with two large patterns (a pattern fill and a pattern overlay, say)
|
||||
/// converts each once, not once per output tile: both stay in the call's budget. A third that
|
||||
/// does not fit is converted when asked for (correct, just not kept).
|
||||
#[test]
|
||||
fn two_large_patterns_are_each_converted_once_per_call() {
|
||||
let sparse = |name: &str| Pattern { id: name.into(), name: name.into(), width: 5000, height: 5000, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
let list = [sparse("a"), sparse("b"), sparse("c")];
|
||||
assert!(list.iter().all(|p| p.working_bytes() == 100_000_000));
|
||||
let prepared = PreparedPatterns::new(&list, 250_000_000);
|
||||
let same = |name: &str| {
|
||||
let (first, second) = (prepared.get(name, "").unwrap(), prepared.get(name, "").unwrap());
|
||||
Arc::ptr_eq(&first, &second)
|
||||
};
|
||||
assert!(same("a"), "the first is kept");
|
||||
assert!(same("b"), "and the second");
|
||||
// A third past the budget is converted under one lock and shared while a worker holds it,
|
||||
// so concurrent tiles use one copy; it is converted again once nobody holds it.
|
||||
assert!(same("c"), "shared while in use");
|
||||
let held = prepared.get("c", "").unwrap();
|
||||
let other = prepared.get("c", "").unwrap();
|
||||
assert!(Arc::ptr_eq(&held, &other), "two workers at once get one copy");
|
||||
drop((held, other));
|
||||
assert!(prepared.get("c", "").is_some(), "and it converts again after");
|
||||
// The real budget holds two of the largest paintable ones.
|
||||
assert!(2 * photocraft_doc::pattern::MAX_WORKING_BYTES <= PREPARED_PATTERN_BYTES as u64);
|
||||
}
|
||||
|
||||
/// A large pattern's compact copy samples exactly as the expanded one: every 8-bit and 16-bit
|
||||
/// value survives the round trip through `f32`, and a tap reads the same premultiplied numbers.
|
||||
#[test]
|
||||
fn the_compact_copies_read_the_same_as_the_expanded_one() {
|
||||
for v in 0..=255u8 {
|
||||
assert_eq!(((f32::from(v) / 255.0) * 255.0 + 0.5) as u8, v);
|
||||
assert_eq!(UNIT8[usize::from(v)], f32::from(v) / 255.0);
|
||||
}
|
||||
for v in 0..=u16::MAX {
|
||||
assert_eq!(((f32::from(v) / 65535.0) * 65535.0 + 0.5) as u16, v);
|
||||
}
|
||||
for (format, grid) in [(PixelFormat::RGBA8, 255.0f32), (PixelFormat::RGBA16, 65535.0)] {
|
||||
let mut s = Surface::new(format);
|
||||
for y in 0..16i32 {
|
||||
for x in 0..16i32 {
|
||||
let level = |v: i32| (v as f32 / 255.0 * grid).round() / grid;
|
||||
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[level(x * 17), level(y * 17), level((x + y) * 8), level(255 - x * 13 - y * 3)]);
|
||||
}
|
||||
}
|
||||
let p = Pattern::new("ramp", s, 16, 16);
|
||||
let (expanded, compact) = (Tile::build(&p, ColorMode::Rgb, false).unwrap(), Tile::build(&p, ColorMode::Rgb, true).unwrap());
|
||||
assert!(matches!(expanded.texels, Texels::Premultiplied(_)));
|
||||
assert!(matches!((&compact.texels, grid as u32), (Texels::Rgba8(_), 255) | (Texels::Rgba16(_), 65535)));
|
||||
for y in 0..16 {
|
||||
for x in 0..16 {
|
||||
assert_eq!(compact.at(x, y), expanded.at(x, y), "{grid} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// And rendered, scaled, rotated and shifted across the wrap.
|
||||
let place = Placement::new(Rect::new(3, 5, 40, 40), true, (2.0, 1.0), 0.6, 20.0);
|
||||
assert_eq!(render(&compact, &place, Rect::new(-9, -7, 30, 25)), render(&expanded, &place, Rect::new(-9, -7, 30, 25)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory for the rows is asked for, not assumed: a pattern whose rows cannot be had (here a
|
||||
/// declared size past what any allocation could be) gives `None`, never an aborted allocation.
|
||||
#[test]
|
||||
fn rows_that_cannot_be_allocated_give_none() {
|
||||
let huge = Pattern { id: "huge".into(), name: "huge".into(), width: u32::MAX, height: u32::MAX, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
let mut out = Vec::new();
|
||||
assert!(pattern_rgba_rows(&huge, ColorMode::Rgb, 0, u32::MAX, &mut out).is_none());
|
||||
assert!(quantized_rgba(&huge, ColorMode::Rgb, |v| v as u8).is_none());
|
||||
assert!(pattern_rgba(&huge, ColorMode::Rgb).is_empty());
|
||||
assert!(Tile::new(&huge).is_none());
|
||||
let small = Pattern { id: "small".into(), name: "small".into(), width: 3, height: 2, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
assert_eq!(pattern_rgba_rows(&small, ColorMode::Rgb, 0, 2, &mut out), Some(()));
|
||||
assert_eq!(out.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_phase_scale_and_angle_move_the_origin() {
|
||||
let t = Tile::new(&checker()).unwrap();
|
||||
@@ -299,6 +585,29 @@ mod tests {
|
||||
assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minified_pattern_is_filtered_not_aliased() {
|
||||
// 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels.
|
||||
// The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single
|
||||
// bilinear tap would return pure white.
|
||||
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();
|
||||
let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0);
|
||||
let (u, _) = place.map(0.5, 0.5);
|
||||
assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe");
|
||||
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
|
||||
assert!((p[0] - 0.5).abs() < 0.05, "{p:?}");
|
||||
}
|
||||
// 100 % stays exact.
|
||||
let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0);
|
||||
let row = render(&t, &one, Rect::new(0, 0, 2, 1));
|
||||
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_at_hostile_phases_does_not_overflow() {
|
||||
let patterns = [checker()];
|
||||
|
||||
+137
-9
@@ -2,16 +2,30 @@
|
||||
//! and brush textures. Pure data; rendering lives in `photocraft-compose`, the PSD mapping
|
||||
//! (`Patt`/`Pat2`/`Pat3` global blocks) in `photocraft-io`.
|
||||
|
||||
use photocraft_color::SampleType;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 4000).
|
||||
/// Largest pattern edge a file may declare (the PSD reader's limit; Define Pattern stops at 8000).
|
||||
pub const MAX_EDGE: u32 = 30_000;
|
||||
|
||||
/// Most pixels a pattern may have to be painted. Renderers expand a pattern into a 16-byte-per-pixel
|
||||
/// working copy, so this keeps that copy at 1 GiB; larger patterns paint nothing.
|
||||
/// Most pixels a pattern may have to be painted (Define Pattern's 8000 × 8000 fits). Larger
|
||||
/// patterns paint nothing.
|
||||
pub const MAX_PAINT_PIXELS: u64 = 1 << 26;
|
||||
|
||||
/// Most bytes of working copy the CPU compositor holds for one pattern (`photocraft-compose`
|
||||
/// checks the two agree at build time). See [`Pattern::working_bytes`]. A browser tab has far less
|
||||
/// memory than a desktop process, and a failed allocation there aborts the tab, so the web build
|
||||
/// holds half as much: an 8-bit pattern paints up to 33,554,432 px (about 5792²) there.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub const MAX_WORKING_BYTES: u64 = 256 << 20;
|
||||
/// See the desktop value above.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const MAX_WORKING_BYTES: u64 = 128 << 20;
|
||||
|
||||
/// Rows of a pattern read at a time where a whole-tile `f32` copy would cost 16 bytes per pixel.
|
||||
pub const BAND_ROWS: u32 = 256;
|
||||
|
||||
/// One pattern tile. Pixels cover `0..width × 0..height` of `surface`, in any pixel format
|
||||
/// (depth and colour model are the pattern's own, as in Photoshop).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -36,13 +50,18 @@ impl Pattern {
|
||||
/// defining a pattern is deterministic and replayable).
|
||||
pub fn new(name: impl Into<String>, surface: Surface, width: u32, height: u32) -> Pattern {
|
||||
let name = name.into();
|
||||
let r = Rect::new(0, 0, width as i32, height as i32);
|
||||
let px = surface.read_region(r);
|
||||
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
|
||||
h1 = fnv(h1, &width.to_le_bytes());
|
||||
h1 = fnv(h1, &height.to_le_bytes());
|
||||
for v in &px {
|
||||
h1 = fnv(h1, &v.to_bits().to_le_bytes());
|
||||
// The samples row by row, a band at a time: the same sequence as one read of the whole
|
||||
// tile, without a full-size `f32` copy of a large pattern.
|
||||
let mut band = Vec::new();
|
||||
for top in (0..height).step_by(BAND_ROWS as usize) {
|
||||
let bottom = (top + BAND_ROWS).min(height);
|
||||
surface.read_region_into(Rect::new(0, top as i32, width as i32, bottom as i32), &mut band);
|
||||
for v in &band {
|
||||
h1 = fnv(h1, &v.to_bits().to_le_bytes());
|
||||
}
|
||||
}
|
||||
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", surface.format()).as_bytes());
|
||||
let id = format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
|
||||
@@ -70,9 +89,61 @@ impl Pattern {
|
||||
u64::from(self.width) * u64::from(self.height)
|
||||
}
|
||||
|
||||
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]).
|
||||
/// Bytes of working copy the compositor holds to paint this pattern. One that fits is expanded
|
||||
/// to premultiplied `f32` RGBA (16 per pixel). A pattern too large for that is held at its own
|
||||
/// depth instead, straight RGBA at 4 bytes per pixel for 8 bits and 8 for 16 bits: exactly its
|
||||
/// values, in a quarter or half the memory. A float one has no smaller exact form.
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
working_bytes_for(self.pixel_count(), self.surface.format().sample)
|
||||
}
|
||||
|
||||
/// Why this pattern paints nothing, for the person or agent who chose it; `None` when it
|
||||
/// paints. A pattern too large for the compositor's working copy renders as transparent, so
|
||||
/// every command that picks a pattern, and the import that brought it in, says so.
|
||||
pub fn paint_problem(&self) -> Option<String> {
|
||||
if self.is_paintable() {
|
||||
return None;
|
||||
}
|
||||
if self.is_empty() {
|
||||
return Some("it is empty".into());
|
||||
}
|
||||
let sample = self.surface.format().sample;
|
||||
Some(format!(
|
||||
"it is {} × {} px at {} bits per channel, and a pattern of that depth paints up to {} px in all",
|
||||
self.width,
|
||||
self.height,
|
||||
sample.bytes() * 8,
|
||||
max_paint_pixels(sample)
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`] and
|
||||
/// [`MAX_WORKING_BYTES`]).
|
||||
pub fn is_paintable(&self) -> bool {
|
||||
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS
|
||||
!self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS && self.working_bytes() <= MAX_WORKING_BYTES
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Pattern::working_bytes`] of a pattern of `pixels` pixels with `sample` samples.
|
||||
pub fn working_bytes_for(pixels: u64, sample: SampleType) -> u64 {
|
||||
let expanded = pixels.saturating_mul(16);
|
||||
if expanded <= MAX_WORKING_BYTES {
|
||||
return expanded;
|
||||
}
|
||||
match sample {
|
||||
SampleType::U8 => pixels.saturating_mul(4),
|
||||
SampleType::U16 => pixels.saturating_mul(8),
|
||||
SampleType::F32 => expanded,
|
||||
}
|
||||
}
|
||||
|
||||
/// Most pixels a pattern with `sample` samples may have and still paint: [`MAX_PAINT_PIXELS`] for
|
||||
/// 8 bits, fewer where the working copy is larger per pixel.
|
||||
pub fn max_paint_pixels(sample: SampleType) -> u64 {
|
||||
match sample {
|
||||
SampleType::U8 => MAX_PAINT_PIXELS,
|
||||
SampleType::U16 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 8),
|
||||
SampleType::F32 => MAX_PAINT_PIXELS.min(MAX_WORKING_BYTES / 16),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,4 +189,61 @@ mod tests {
|
||||
assert!(find(&list, "nope", "water").is_some());
|
||||
assert!(find(&list, "nope", "").is_none());
|
||||
}
|
||||
|
||||
/// The id is a hash of every sample in row-major order; reading a tall pattern a band of rows
|
||||
/// at a time must give the id one whole read gave, or saved documents would lose their
|
||||
/// pattern links.
|
||||
#[test]
|
||||
fn the_id_of_a_tall_pattern_matches_a_single_read_of_it() {
|
||||
let (w, h) = (7u32, BAND_ROWS * 2 + 37);
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
for y in 0..h as i32 {
|
||||
for x in 0..w as i32 {
|
||||
s.fill_rect(Rect::new(x, y, x + 1, y + 1), &[(x % 5) as f32 / 4.0, (y % 7) as f32 / 6.0, ((x + y) % 3) as f32 / 2.0, 1.0]);
|
||||
}
|
||||
}
|
||||
let name = "tall";
|
||||
let mut h1 = fnv(0xcbf2_9ce4_8422_2325, name.as_bytes());
|
||||
h1 = fnv(h1, &w.to_le_bytes());
|
||||
h1 = fnv(h1, &h.to_le_bytes());
|
||||
for v in s.read_region(Rect::new(0, 0, w as i32, h as i32)) {
|
||||
h1 = fnv(h1, &v.to_bits().to_le_bytes());
|
||||
}
|
||||
let h2 = fnv(h1 ^ 0x9e37_79b9_7f4a_7c15, format!("{:?}", s.format()).as_bytes());
|
||||
let expected =
|
||||
format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", (h1 >> 32) as u32, (h1 >> 16) as u16, h1 as u16, (h2 >> 48) as u16, h2 & 0xffff_ffff_ffff);
|
||||
assert_eq!(Pattern::new(name, s, w, h).id, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_bytes_keep_a_pattern_exact_in_less_memory() {
|
||||
let sparse =
|
||||
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
|
||||
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
|
||||
// What fits is expanded as before.
|
||||
assert_eq!(sparse(PixelFormat::RGBA8, 10, 10).working_bytes(), 1600);
|
||||
assert_eq!(sparse(float, 4096, 4096).working_bytes(), MAX_WORKING_BYTES);
|
||||
// Past that, a pattern is held at its own depth.
|
||||
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).working_bytes(), 256_000_000);
|
||||
assert_eq!(sparse(PixelFormat::RGBA16, 5000, 5000).working_bytes(), 200_000_000);
|
||||
assert!(sparse(PixelFormat::RGBA8, 8000, 8000).is_paintable());
|
||||
assert!(sparse(PixelFormat::RGBA16, 5792, 5792).is_paintable());
|
||||
assert!(!sparse(PixelFormat::RGBA16, 8000, 8000).is_paintable(), "512 MB at 16 bits");
|
||||
assert!(!sparse(float, 4097, 4096).is_paintable(), "float has no smaller exact form");
|
||||
assert!(!sparse(PixelFormat::RGBA8, 9000, 8000).is_paintable());
|
||||
assert!(!sparse(PixelFormat::RGBA8, 0, 8).is_paintable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_that_paints_nothing_says_why() {
|
||||
let sparse =
|
||||
|format: PixelFormat, width: u32, height: u32| Pattern { id: String::new(), name: String::new(), width, height, surface: Surface::new(format) };
|
||||
let float = PixelFormat::new(photocraft_color::ColorMode::Rgb, SampleType::F32, true);
|
||||
assert_eq!(sparse(PixelFormat::RGBA8, 8000, 8000).paint_problem(), None);
|
||||
assert_eq!(sparse(PixelFormat::RGBA8, 0, 4).paint_problem().as_deref(), Some("it is empty"));
|
||||
let reason = sparse(float, 5000, 5000).paint_problem().expect("float 5000 x 5000 paints nothing");
|
||||
assert!(reason.contains("5000 × 5000") && reason.contains("32 bits") && reason.contains("16777216"), "{reason}");
|
||||
let reason = sparse(PixelFormat::RGBA16, 8000, 8000).paint_problem().expect("16-bit 8000 x 8000 paints nothing");
|
||||
assert!(reason.contains("16 bits") && reason.contains("33554432"), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Live retouch latency on a materialized 24 MP layer. Run the same example at base and head
|
||||
//! in release mode on one machine. Setup, Undo and the full output fingerprint are not timed.
|
||||
//! This measures engine stroke work; canvas compositing/upload and presentation are excluded.
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn elapsed_ms(start: Instant) -> f64 {
|
||||
start.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn median(values: &mut [f64]) -> f64 {
|
||||
values.sort_by(f64::total_cmp);
|
||||
values.get(values.len() / 2).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Four pointer samples per 60 Hz frame, ~1000 px/s, over a 960 px stroke.
|
||||
// Large soft tips also exercise pixels beyond the canvas edges.
|
||||
let points: Vec<_> = (0..=240).map(|i| StrokePoint::new(2500.0 + f64::from(i) * 4.0, 2000.0, 1.0)).collect();
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?;
|
||||
s.execute("layer.new.layer", json!({}))?;
|
||||
s.edit("stripes", |doc, active| {
|
||||
let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
|
||||
let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
|
||||
let fmt = surface.format();
|
||||
for x in (0..6000).step_by(64) {
|
||||
let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 0.7] } else { [0.1, 0.4, 0.8, 1.0] };
|
||||
surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &photocraft_raster::from_rgba(&fmt, color));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] {
|
||||
for size in [300, 600, 1000, 2500] {
|
||||
for hardness in [0, 100] {
|
||||
let params = json!({
|
||||
"points":points.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>(),
|
||||
"size":size,"hardness":hardness,"strength":50,"spacing":25
|
||||
});
|
||||
let mut start_params = params.clone();
|
||||
start_params["points"] = json!([[2500, 2000]]);
|
||||
let (mut draws, mut worst_frames, mut releases) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut fingerprint = None;
|
||||
// One warm-up followed by three measured strokes, always starting from the same pixels.
|
||||
for sample in 0..4 {
|
||||
let start = Instant::now();
|
||||
let mut live = LiveDab::begin(&s, cmd, &start_params)?;
|
||||
let mut draw = elapsed_ms(start);
|
||||
let mut worst = draw;
|
||||
for batch in points.get(1..).unwrap_or_default().chunks(4) {
|
||||
let start = Instant::now();
|
||||
live.push(batch)?;
|
||||
let duration = elapsed_ms(start);
|
||||
draw += duration;
|
||||
worst = worst.max(duration);
|
||||
}
|
||||
let bounds = live.bounds();
|
||||
let start = Instant::now();
|
||||
s.offer_live_dab(live)?;
|
||||
let result = s.execute(cmd, params.clone())?;
|
||||
let release = elapsed_ms(start);
|
||||
if result.get("fromLive").and_then(|v| v.as_bool()) != Some(true) {
|
||||
return Err("the commit did not take the live pixels".into());
|
||||
}
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?;
|
||||
// Include finishing dabs, without depending on a new API unavailable at base.
|
||||
let damage = result.get("damage").and_then(|v| v.as_array()).ok_or("missing damage")?;
|
||||
if damage.len() != 4 {
|
||||
return Err("invalid damage length".into());
|
||||
}
|
||||
let mut coords = [0i32; 4];
|
||||
for (coord, value) in coords.iter_mut().zip(damage) {
|
||||
*coord = i32::try_from(value.as_i64().ok_or("invalid damage")?)?;
|
||||
}
|
||||
let [x, y, width, height] = coords;
|
||||
let bounds = bounds.union(&Rect::new(x, y, x.saturating_add(width), y.saturating_add(height)));
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
// Hash every channel of the modified rectangle, one row at a time to bound memory.
|
||||
for y in bounds.y0..bounds.y1 {
|
||||
for channel in surface.read_region(Rect::new(bounds.x0, y, bounds.x1, y + 1)) {
|
||||
hash = (hash ^ u64::from(channel.to_bits())).wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
if fingerprint.is_some_and(|previous| previous != hash) {
|
||||
return Err("repeated strokes produced different pixels".into());
|
||||
}
|
||||
fingerprint = Some(hash);
|
||||
s.execute("edit.undo", json!({}))?;
|
||||
if sample > 0 {
|
||||
draws.push(draw);
|
||||
worst_frames.push(worst);
|
||||
releases.push(release);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{cmd} depth={depth} size={size} hardness={hardness} draw_ms={:.3} worst_frame_ms={:.3} release_ms={:.3} fingerprint={:016x}",
|
||||
median(&mut draws),
|
||||
median(&mut worst_frames),
|
||||
median(&mut releases),
|
||||
fingerprint.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Reproducible soft Sharpen strokes on a 24 MP striped document. Run in release mode.
|
||||
//! Setup, Undo and the output fingerprint are excluded from the timing.
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::{EngineError, Session};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
for depth in [8, 16] {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?;
|
||||
session.execute("layer.new.layer", json!({}))?;
|
||||
session.edit("stripes", |doc, active| {
|
||||
let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
|
||||
let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
|
||||
for x in (0..6000).step_by(64) {
|
||||
let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };
|
||||
surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
for size in [100, 500, 1000] {
|
||||
let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25,"protectDetail":true});
|
||||
let mut times = Vec::new();
|
||||
let mut fingerprint = 0u64;
|
||||
for sample in 0..6 {
|
||||
let start = Instant::now();
|
||||
session.execute("paint.sharpen", params.clone())?;
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
if sample > 0 {
|
||||
times.push(ms);
|
||||
}
|
||||
if sample == 5 {
|
||||
let st = session.active().ok_or("no document")?;
|
||||
let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?;
|
||||
for y in (1500..2500).step_by(17) {
|
||||
for x in (2000..4000).step_by(13) {
|
||||
for channel in surface.rgba(x, y) {
|
||||
fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
session.execute("edit.undo", json!({}))?;
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}",
|
||||
times[2], times[0], times[4]
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
+13
-14
@@ -168,7 +168,7 @@ pub(crate) use layer_params::{NEW_LAYER_PATH, explicit_layer, layer_listing, lay
|
||||
use ops::{arrange, merge_group, set_mask, set_mask_and_deselect};
|
||||
pub(crate) use ops::{layer_copy, translate_layer};
|
||||
use params::{bad, set_clipped};
|
||||
pub(crate) use params::{check_f32_values, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
|
||||
pub(crate) use params::{check_f32_values, cmyk_param, color_param, int, int_i32, parse_hex, u32_id, u32_id_param};
|
||||
|
||||
pub fn command_specs() -> &'static [CommandSpec] {
|
||||
®istry().specs
|
||||
@@ -778,13 +778,22 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Solid Color…",
|
||||
["Layer", "New Fill Layer"],
|
||||
None,
|
||||
r##"{"color":"#rrggbb"=foreground,"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
|
||||
r##"{"color":"#rrggbb"=foreground,"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document's fill holds exactly these; default the foreground's CMYK pick),"path":"work"|saved path name|{…path}? (the active path: becomes the vector mask, making a shape; else the selection is the layer mask)}"##,
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let c = color_param("layer.newFillLayer.solidColor", p, "color", s.tools.foreground)?;
|
||||
// The inks the colour was picked as, so CMYK 50/50/50/100 stays 50/50/50/100 instead
|
||||
// of what its sRGB stand-in converts back to through the document profile (#8568).
|
||||
let inks = match cmyk_param("layer.newFillLayer.solidColor", p, "cmyk")? {
|
||||
Some(k) => Some(k),
|
||||
None => s.tools.picked_cmyk([c[0], c[1], c[2]]),
|
||||
};
|
||||
let default_mask = layer_panel_option(s, "useDefaultMasksOnFillLayers", true);
|
||||
let id = s.edit("New Color Fill Layer", |doc, active| {
|
||||
let fill = Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode);
|
||||
let fill = match inks {
|
||||
Some(k) if doc.mode == ColorMode::Cmyk => Fill::Solid(Color { mode: ColorMode::Cmyk, c: k, alpha: c[3] }),
|
||||
_ => Fill::Solid(Color::rgba(c[0], c[1], c[2], c[3])).in_mode(doc.mode),
|
||||
};
|
||||
let mut l = Layer::new(doc.next_layer_name("Color Fill"), LayerContent::Fill(fill));
|
||||
mask_new_layer(doc, &mut l, p, "layer.newFillLayer.solidColor", default_mask)?;
|
||||
let id = crate::layer_place::insert_new(doc, *active, l, crate::layer_place::Place::Auto);
|
||||
@@ -923,17 +932,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
r##"{"foreground":"#rrggbb"|[r,g,b,a?]?,"background":same?,"foregroundCmyk":[c,m,y,k]? (inks 0..1 the foreground was picked as: a CMYK document is filled with exactly these),"backgroundCmyk":same?}"##,
|
||||
always,
|
||||
|s, p| {
|
||||
let cmyk = |key: &str| -> Result<Option<[f32; 4]>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let inks: Option<Vec<f32>> = v
|
||||
.as_array()
|
||||
.filter(|a| a.len() == 4)
|
||||
.and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
|
||||
match inks.as_deref() {
|
||||
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
|
||||
_ => Err(bad("tools.setColors", format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
|
||||
}
|
||||
};
|
||||
let cmyk = |key: &str| cmyk_param("tools.setColors", p, key);
|
||||
let (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?);
|
||||
let foreground = color_param("tools.setColors", p, "foreground", s.tools.foreground)?;
|
||||
let background = color_param("tools.setColors", p, "background", s.tools.background)?;
|
||||
|
||||
@@ -367,9 +367,18 @@ fn stop(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A step Photoshop plays as a silent no-op although its menu item is greyed: Deselect with an
|
||||
/// open document and nothing selected (actions often start with it to clear any selection, #6744).
|
||||
fn noop_step(s: &Session, id: &str) -> bool {
|
||||
id == "select.deselect" && s.active().is_some_and(|d| d.doc.selection.is_none())
|
||||
/// open document and nothing selected (actions often start with it to clear any selection, #6744),
|
||||
/// and an Arrange that can't move the layer, such as Bring to Front on a new adjustment layer that
|
||||
/// is already on top (#8453). The CLI's `run` and `batch` scripts skip these too (#8802).
|
||||
pub fn noop_step(s: &Session, id: &str, params: &Value) -> bool {
|
||||
let Some(d) = s.active() else { return false };
|
||||
let dir = match id {
|
||||
"select.deselect" => return d.doc.selection.is_none(),
|
||||
"layer.arrange.bringForward" | "layer.arrange.bringToFront" => 1,
|
||||
"layer.arrange.sendBackward" | "layer.arrange.sendToBack" => -1,
|
||||
_ => return false,
|
||||
};
|
||||
crate::commands::layer_param(s, params).is_ok_and(|layer| super::ops::arrange_at_end(&d.doc, layer, dir))
|
||||
}
|
||||
|
||||
fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<Value>) {
|
||||
@@ -386,7 +395,7 @@ fn run_recorded(s: &mut Session, action: &Action, from: usize) -> (u64, Option<V
|
||||
failed = Some(json!({"step": i, "id": id, "error": e.to_string()}));
|
||||
break;
|
||||
}
|
||||
if noop_step(s, id) {
|
||||
if noop_step(s, id, params) {
|
||||
ran += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -294,6 +294,43 @@ fn record_explicit_transform_keeps_one_replayable_step() {
|
||||
assert_eq!(pixel(&s, 0, 12, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorded_single_layer_transform_replays_after_layer_ids_shift() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
|
||||
let source = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.edit("source pixel", |doc, _| {
|
||||
doc.layer_mut(photocraft_doc::LayerId(source)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
s.execute("actions.record", json!({"name": "Move target"})).unwrap();
|
||||
s.execute("edit.transform", json!({"layer": source, "dx": 4, "interpolation": "nearest"})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let recorded = s.execute("actions.get", json!({"action": "Move target"})).unwrap();
|
||||
assert_eq!(recorded["steps"][0][1], json!({"dx": 4, "interpolation": "nearest"}));
|
||||
|
||||
// Add a layer before the target, shifting its id; playback must resolve the current
|
||||
// selection rather than keep the source document's numeric layer id.
|
||||
s.execute("file.new", json!({"width": 20, "height": 8, "background": "transparent"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "Inserted"})).unwrap();
|
||||
let target = s.execute("layer.new.layer", json!({"name": "Target"})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_ne!(source, target);
|
||||
s.edit("target pixel", |doc, _| {
|
||||
doc.layer_mut(photocraft_doc::LayerId(target)).unwrap().surface_mut().unwrap().fill_rect(photocraft_geom::Rect::new(0, 2, 2, 4), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let target_index = s.active().unwrap().doc.layers.iter().position(|layer| layer.id.0 == target).unwrap();
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": "Move target"})).unwrap();
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 1);
|
||||
assert_eq!(pixel(&s, target_index, 4, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(pixel(&s, target_index, 0, 2), vec![0.0; 4]);
|
||||
}
|
||||
|
||||
/// A Background with a red bar and a slanted ruler line, ready for Straighten.
|
||||
fn ruled_background(s: &mut Session, depth: u32) {
|
||||
s.execute("file.new", json!({"width": 80, "height": 60, "depth": depth})).unwrap();
|
||||
@@ -1040,3 +1077,48 @@ fn button_color_is_one_of_the_photoshop_swatches() {
|
||||
}
|
||||
assert!(s.actions.list[0].color.is_empty(), "rejected colours change nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_that_cannot_move_is_a_noop_in_playback() {
|
||||
// #8453: imported actions make an adjustment layer, then Bring it to Front, where it already is.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let steps = vec![
|
||||
("layer.newAdjustmentLayer.curves".to_string(), json!({})),
|
||||
("layer.arrange.bringToFront".to_string(), json!({})),
|
||||
("layer.new.layer".to_string(), json!({"name": "After"})),
|
||||
];
|
||||
s.actions.list.push(Action::new("Repro", steps));
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
let played = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert_eq!(played["ran"], 3, "{played}");
|
||||
assert!(played.get("failed").is_none(), "{played}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), before + 2, "the no-op adds no history step");
|
||||
|
||||
// The predicate agrees with the command for every layer: top level, above the Background,
|
||||
// and inside a group.
|
||||
let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].clone();
|
||||
s.execute("layer.select", json!({"layer": a})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
s.execute("layer.new.layer", json!({"name": "B"})).unwrap();
|
||||
let cmds = [("layer.arrange.bringForward", 1), ("layer.arrange.bringToFront", 1), ("layer.arrange.sendBackward", -1), ("layer.arrange.sendToBack", -1)];
|
||||
let ids: Vec<_> = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
let bg = crate::background_cmds::background_id(&s.active().unwrap().doc);
|
||||
for id in ids.into_iter().filter(|id| Some(*id) != bg) {
|
||||
for (cmd, dir) in cmds {
|
||||
let at_end = super::super::ops::arrange_at_end(&s.active().unwrap().doc, id, dir);
|
||||
let ran = s.execute(cmd, json!({"layer": id.0}));
|
||||
assert_eq!(at_end, ran.is_err(), "{cmd} on {id:?}: {ran:?}");
|
||||
if ran.is_ok() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arranging the Background still stops playback: Photoshop refuses it too.
|
||||
let bg = bg.unwrap();
|
||||
s.actions.list.push(Action::new("Bg", vec![("layer.arrange.bringToFront".to_string(), json!({"layer": bg.0}))]));
|
||||
let played = s.execute("actions.play", json!({"action": 1})).unwrap();
|
||||
assert_eq!(played["ran"], 0, "{played}");
|
||||
assert!(played["failed"].is_object(), "{played}");
|
||||
}
|
||||
|
||||
@@ -291,12 +291,16 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
|
||||
return Ok(l.bounds);
|
||||
}
|
||||
let sel = doc.selection.clone();
|
||||
let table = crate::mode_cmds::paint_color_table(doc, p);
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
// A stroke in the table's colour starts on what the foreground painted as.
|
||||
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
let unpainted = table.as_ref().map(|_| surf.clone());
|
||||
let damage = if let Some(axis) = &symmetry {
|
||||
let reflected = axis.reflected_passes(&pts);
|
||||
let copies: Vec<_> = reflected
|
||||
@@ -321,6 +325,9 @@ fn stroke_with(s: &mut Session, cmd: &str, p: &Value, label: &str, brush: BrushS
|
||||
} else {
|
||||
render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom)
|
||||
};
|
||||
if let (Some(table), Some(pre)) = (&table, &unpainted) {
|
||||
crate::mode_cmds::keep_in_color_table(table, pre, surf, damage);
|
||||
}
|
||||
Ok(damage)
|
||||
})
|
||||
})?;
|
||||
@@ -600,9 +607,11 @@ impl LiveStroke {
|
||||
let base = (st.doc.id, st.revision);
|
||||
let mut doc = (*st.doc).clone();
|
||||
let sel = doc.selection.clone();
|
||||
let table = crate::mode_cmds::paint_color_table(&doc, p);
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
|
||||
erase_locked(&mut brush, lock, s.tools.background);
|
||||
if pencil && flag(p, "autoErase", false) {
|
||||
let fg = table.as_ref().map_or(fg, |t| crate::mode_cmds::table_color(t, fg));
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
|
||||
}
|
||||
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
@@ -676,7 +685,7 @@ impl LiveStroke {
|
||||
check_coords(pts, &self.cmd)?;
|
||||
self.points.extend_from_slice(pts);
|
||||
let cmyk = self.cmyk.clone();
|
||||
photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || {
|
||||
let dmg = photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || -> Result<Rect> {
|
||||
self.renderer.push(pts);
|
||||
if let Some(symmetry) = &self.symmetry {
|
||||
for ((mirror, distinct), reflected) in self.mirrors.iter_mut().zip(&mut self.mirror_distinct).zip(symmetry.reflected_passes(pts)) {
|
||||
@@ -722,7 +731,13 @@ impl LiveStroke {
|
||||
dmg = dmg.union(&self.tail);
|
||||
}
|
||||
Ok(dmg)
|
||||
})
|
||||
})?;
|
||||
// The preview shows the pixels the commit keeps (see `stroke_with`).
|
||||
if let Some(table) = crate::mode_cmds::paint_color_table(&self.doc, &self.params) {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?;
|
||||
crate::mode_cmds::keep_in_color_table(&table, &self.pre, surf, dmg);
|
||||
}
|
||||
Ok(dmg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -746,9 +746,10 @@ fn toggle_last_state(s: &mut Session) -> Result<Value> {
|
||||
Ok(json!(if redo { s.redo() } else { s.undo() }))
|
||||
}
|
||||
|
||||
/// Edit › Transform › Again: replay the last `edit.transform` on the active layer.
|
||||
/// Edit › Transform › Again: replay the last `edit.transform` on the selected layers (#6501).
|
||||
fn transform_again(s: &mut Session) -> Result<Value> {
|
||||
let p = transform_again_params(s)?;
|
||||
let ids = s.active().map(crate::transform_cmds::selection_targets).unwrap_or_default();
|
||||
let p = transform_again_params(s, &ids)?;
|
||||
s.execute("edit.transform", p)
|
||||
}
|
||||
|
||||
@@ -756,8 +757,8 @@ fn transform_again(s: &mut Session) -> Result<Value> {
|
||||
/// last transform on the copy, as one history step, so pressing it again steps and repeats
|
||||
/// (#352). The transform is worked out before anything changes, so a refusal leaves no copy.
|
||||
fn transform_again_copy(s: &mut Session) -> Result<Value> {
|
||||
let p = transform_again_params(s)?;
|
||||
let id = active_id(s)?;
|
||||
let p = transform_again_params(s, &[id])?;
|
||||
let journal = s.journal.len();
|
||||
// The steps are recorded (Again on a Copy is not replayable itself): with one layer selected,
|
||||
// "duplicate the current layer" plays back on any document, a layer id only on this one.
|
||||
@@ -780,8 +781,9 @@ fn transform_again_copy(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `edit.transform` params that repeat the last transform on the active layer.
|
||||
fn transform_again_params(s: &Session) -> Result<Value> {
|
||||
/// The `edit.transform` params that repeat the last transform on the layers `ids` (their frame
|
||||
/// together is the box it starts from).
|
||||
fn transform_again_params(s: &Session, ids: &[LayerId]) -> Result<Value> {
|
||||
let (_, last) =
|
||||
s.journal.iter().rev().find(|(id, _)| id == "edit.transform").cloned().ok_or(EngineError::Other("there is no transform to repeat".into()))?;
|
||||
let (rect, quad) = (last.get("rect").cloned(), last.get("quad").cloned());
|
||||
@@ -800,8 +802,8 @@ fn transform_again_params(s: &Session) -> Result<Value> {
|
||||
)
|
||||
.ok_or(EngineError::Other("the last transform can't be repeated".into()))?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let b = crate::transform_cmds::transform_bounds(&d.doc, l);
|
||||
d.doc.layer(active_id(s)?).ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let b = crate::transform_cmds::transform_bounds_of(&d.doc, ids);
|
||||
if b.is_empty() {
|
||||
return Err(EngineError::Other("nothing to transform".into()));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::damage::layer_bounds;
|
||||
use super::damage::align_bounds;
|
||||
use super::moves::{move_layers, with_links};
|
||||
use super::selection::{selected, top_level};
|
||||
use super::{Document, EngineError, LayerId, Rect, Result, Session, Value, bad, json};
|
||||
@@ -44,7 +44,7 @@ impl Edge {
|
||||
|
||||
/// Selected top-level layers that have content bounds, bottom-to-top.
|
||||
fn bounded_selection(doc: &Document, sel: &[LayerId]) -> Vec<(LayerId, Rect)> {
|
||||
top_level(doc, sel).into_iter().filter_map(|id| Some((id, layer_bounds(doc.layer(id)?)?))).collect()
|
||||
top_level(doc, sel).into_iter().filter_map(|id| Some((id, align_bounds(doc.layer(id)?, doc.bounds(), doc.pixel_format().sample)?))).collect()
|
||||
}
|
||||
|
||||
pub(super) fn align(s: &mut Session, p: &Value, kind: &str) -> Result<Value> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use photocraft_color::{PixelFormat, SampleType};
|
||||
|
||||
use super::{Document, Layer, LayerContent, Rect, Session, VideoData};
|
||||
|
||||
/// Calls `f` with the rect of every tile that differs between two surfaces of the same format and
|
||||
@@ -580,8 +582,9 @@ pub(crate) fn note_insert(s: &mut Session, before: &Document) {
|
||||
st.last_damage = step_damage(before, &st.doc);
|
||||
}
|
||||
|
||||
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
|
||||
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
|
||||
/// Content bounds of a layer's pixels as stored (type and shape layers: their cached render), or
|
||||
/// the union of a group's children. Fill and adjustment layers have none. Cheap: Layers list,
|
||||
/// artboards, copy and slices call it for every layer.
|
||||
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
|
||||
let r = match &l.content {
|
||||
LayerContent::Group(g) => g.children.iter().filter_map(layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
@@ -589,3 +592,68 @@ pub fn layer_bounds(l: &Layer) -> Option<Rect> {
|
||||
};
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The bounds Align and Distribute move by, in a document of bounds `canvas`: [`layer_bounds`],
|
||||
/// except that a shape layer is measured over everything it draws, beyond the canvas too (its
|
||||
/// cache is clipped to the canvas, #8660), by rendering it again. Only those commands pay for that
|
||||
/// render, once per click; `canvas` is the frame a shape's gradients are laid out in, so an opacity
|
||||
/// stop measures as it renders, and `sample` is the document's sample type (see
|
||||
/// [`shape_pixel_bounds`]).
|
||||
pub(crate) fn align_bounds(l: &Layer, canvas: Rect, sample: SampleType) -> Option<Rect> {
|
||||
let r = match &l.content {
|
||||
LayerContent::Group(g) => g.children.iter().filter_map(|c| align_bounds(c, canvas, sample)).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
LayerContent::Shape(sh) => shape_pixel_bounds(sh, canvas, sample).unwrap_or_else(|| {
|
||||
let cached = l.surface().map(|s| s.content_bounds()).unwrap_or(Rect::EMPTY);
|
||||
// A fill that shows everywhere covers the canvas, rendered yet or not.
|
||||
if cached.is_empty() && unbounded_fill(sh) { canvas } else { cached }
|
||||
}),
|
||||
_ => return layer_bounds(l),
|
||||
};
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// Whether the shape's fill shows everywhere (an inverted path, or below 100 % density), so it has
|
||||
/// no extent of its own.
|
||||
fn unbounded_fill(sh: &photocraft_doc::ShapeLayer) -> bool {
|
||||
sh.fill.is_some() && (sh.path.inverted || (sh.density.is_finite() && sh.density < 1.0))
|
||||
}
|
||||
|
||||
/// Largest area [`shape_pixel_bounds`] renders to find a shape's extent; beyond it the path and
|
||||
/// stroke bounds stand in.
|
||||
const SHAPE_BOUNDS_MAX_PX: u64 = 64 << 20;
|
||||
|
||||
/// Pixels [`shape_pixel_bounds`] renders at a time.
|
||||
const SHAPE_BOUNDS_BAND_PX: u64 = 1 << 20;
|
||||
|
||||
/// The pixels a shape layer draws, wherever they fall. Its cache is rendered clipped to the
|
||||
/// canvas, so a shape partly off the canvas measured from it looked smaller and Align moved it
|
||||
/// by the wrong amount, a little more on each click (#8660). The shape is rendered again over
|
||||
/// everything it can draw (its path, stroke and feather), unclipped, and measured from that. A fill
|
||||
/// that shows everywhere (an inverted path, or below 100 % density) has no extent of its own and
|
||||
/// keeps the canvas-clipped measure (`None`).
|
||||
pub(super) fn shape_pixel_bounds(sh: &photocraft_doc::ShapeLayer, canvas: Rect, sample: SampleType) -> Option<Rect> {
|
||||
if unbounded_fill(sh) {
|
||||
return None;
|
||||
}
|
||||
let reach = photocraft_compose::shape_bounds(sh)?.inflate(1);
|
||||
if u64::from(reach.width()) * u64::from(reach.height()) > SHAPE_BOUNDS_MAX_PX {
|
||||
return photocraft_compose::hard_shape_bounds(sh);
|
||||
}
|
||||
// Gradients lay out in the document's frame (as the cached render does); the feather and the
|
||||
// clip cover the shape's whole reach, beyond the canvas too.
|
||||
let shape = photocraft_vector::CompiledShape::with_frames(sh, photocraft_vector::DEFAULT_TOLERANCE, reach, canvas);
|
||||
// Alpha at the document's own precision, as the document stores it: an 8-bit render drops the
|
||||
// faint stroke of a 16-bit or float document (the opaque body then aligns by a different
|
||||
// extent than the document draws), and a float one would find alpha an 8-bit document never
|
||||
// stores. A band of rows at a time keeps the working copy a few MB however large the shape.
|
||||
let format = PixelFormat::new(photocraft_color::ColorMode::Grayscale, sample, true);
|
||||
let band_rows = i32::try_from((SHAPE_BOUNDS_BAND_PX / u64::from(reach.width().max(1))).max(1)).unwrap_or(i32::MAX);
|
||||
let mut bounds = Rect::EMPTY;
|
||||
let mut top = reach.y0;
|
||||
while top < reach.y1 {
|
||||
let bottom = top.saturating_add(band_rows).min(reach.y1);
|
||||
bounds = bounds.union(&shape.render(format, Rect::new(reach.x0, top, reach.x1, bottom)).content_bounds());
|
||||
top = bottom;
|
||||
}
|
||||
(!bounds.is_empty()).then_some(bounds)
|
||||
}
|
||||
|
||||
@@ -242,16 +242,18 @@ fn fill_layers_without_default_masks_keep_selection_and_explicit_path_masks() {
|
||||
assert!(mask.is_none() && !vector, "{cmd}: no selection, no path: no mask at all");
|
||||
}
|
||||
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
|
||||
for (cmd, params) in KINDS {
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
|
||||
let (mask, vector) = create(&mut s, cmd, params, None);
|
||||
let mask = mask.unwrap_or_else(|| panic!("{cmd}: the selection still becomes the layer mask"));
|
||||
assert!(!vector, "{cmd}");
|
||||
assert!(mask.surface.pixel(6, 6)[0] > 0.99, "{cmd}: inside the selection reveals");
|
||||
assert!(mask.surface.pixel(20, 12)[0] < 0.01, "{cmd}: outside the selection hides");
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "{cmd}: the selection went into the mask (#8801)");
|
||||
}
|
||||
|
||||
// An explicit path wins over the selection, as with the option on.
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 8, "height": 6})).unwrap();
|
||||
for selected in [true, false] {
|
||||
if !selected {
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
|
||||
@@ -139,6 +139,14 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
|
||||
if id == "layer.moveTo" {
|
||||
return recorded_move(s, p, pre_selected);
|
||||
}
|
||||
if id == "edit.transform" {
|
||||
// Multi-layer transforms carry the set of ids separately. A single-layer transform
|
||||
// uses the ordinary `layer` target, which should follow the sole selected layer when
|
||||
// the action is replayed in a document with different ids.
|
||||
if p.get("layers").is_some_and(Value::is_array) {
|
||||
return recorded_transform(s, p, pre_selected);
|
||||
}
|
||||
}
|
||||
let Some(layer_id) = p.get("layer").and_then(Value::as_u64).map(LayerId) else { return p };
|
||||
let is_sole_selected = match pre_selected {
|
||||
Some(sel) => sel == [layer_id],
|
||||
@@ -154,6 +162,27 @@ pub(crate) fn recorded_step(s: &Session, id: &str, p: Value, pre_selected: Optio
|
||||
p
|
||||
}
|
||||
|
||||
/// A Free Transform of several layers lists their ids (the box's session keeps them while it is
|
||||
/// open). When they are the layers the selection gave it, the step drops `layers`, so it replays on
|
||||
/// the layers selected then, in any document, rather than on these ids (#6501).
|
||||
fn recorded_transform(s: &Session, mut p: Value, pre_selected: Option<&[LayerId]>) -> Value {
|
||||
let Some(st) = s.active() else { return p };
|
||||
let Some(listed) = p.get("layers").and_then(Value::as_array) else { return p };
|
||||
let Some(listed) = listed.iter().map(|v| v.as_u64().map(LayerId)).collect::<Option<Vec<_>>>() else { return p };
|
||||
let selected = pre_selected.map_or_else(|| st.selected_layers(), <[LayerId]>::to_vec);
|
||||
let mut moved = top_level(&st.doc, &listed);
|
||||
let mut targets = crate::transform_cmds::targets_of(&st.doc, &selected, st.active_layer);
|
||||
moved.sort_unstable();
|
||||
moved.dedup();
|
||||
targets.sort_unstable();
|
||||
if moved == targets
|
||||
&& let Some(fields) = p.as_object_mut()
|
||||
{
|
||||
fields.remove("layers");
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn selection_target(s: &Session, p: &Value) -> Result<LayerId> {
|
||||
let Some(name) = p.get("name") else {
|
||||
if p.get("occurrence").is_some() {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use photocraft_color::SampleType;
|
||||
|
||||
use super::damage::{align_bounds, shape_pixel_bounds};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -636,6 +639,86 @@ fn align_each_edge() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #8660: a shape partly off the canvas aligns by everything it draws, not the part on the
|
||||
/// canvas, so one click aligns the layers and a second moves nothing.
|
||||
#[test]
|
||||
fn align_uses_a_shape_layers_full_extent_off_the_canvas() {
|
||||
let mut s = session(8);
|
||||
let drawn = |s: &Session, id: LayerId| align_bounds(doc(s).layer(id).unwrap(), doc(s).bounds(), SampleType::U8).unwrap();
|
||||
let shape = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 10, 40, 20], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let measured = align_bounds(doc(&s).layer(shape).unwrap(), doc(&s).bounds(), SampleType::U8);
|
||||
assert_eq!(measured, Some(Rect::new(-20, 10, 20, 30)), "Align: the shape's own extent, not the canvas-clipped render");
|
||||
// Everything else keeps the cheap measure of the cached render (no re-render per layer).
|
||||
assert_eq!(bounds(&s, shape), Rect::new(0, 10, 20, 30), "layer_bounds: the cache, clipped to the canvas");
|
||||
let pixels = rect_layer(&mut s, Rect::new(30, 40, 60, 50));
|
||||
select_all(&mut s, &[shape, pixels]);
|
||||
s.execute("layer.align.leftEdges", json!({})).unwrap();
|
||||
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50));
|
||||
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
|
||||
s.execute("layer.align.leftEdges", json!({})).unwrap();
|
||||
assert_eq!(bounds(&s, pixels), Rect::new(-20, 40, 10, 50), "a second click moves nothing");
|
||||
// Centred: the union is -20..20 wide on the shape's side and 30 px for the pixel layer.
|
||||
s.execute("layer.align.horizontalCenters", json!({})).unwrap();
|
||||
assert_eq!(drawn(&s, shape), Rect::new(-20, 10, 20, 30));
|
||||
assert_eq!(bounds(&s, pixels), Rect::new(-15, 40, 15, 50));
|
||||
// A feathered shape is measured by its soft edge too, which reaches past the path.
|
||||
let soft = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [-20, 50, 40, 20], "fill": "#00ff00"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("feather", |doc, _| {
|
||||
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
|
||||
sh.feather = 4.0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Measured as Align does, in the document's frame: the feather off the canvas still counts.
|
||||
let b = drawn(&s, soft);
|
||||
assert!(b.x0 < -20 && b.x1 > 20 && b.y0 < 50 && b.y1 > 70, "{b:?}");
|
||||
// A fill that shows everywhere keeps the canvas-clipped measure.
|
||||
s.edit("invert", |doc, _| {
|
||||
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(soft).map(|l| &mut l.content) {
|
||||
sh.feather = 0.0;
|
||||
sh.path.inverted = true;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let LayerContent::Shape(sh) = &doc(&s).layer(soft).unwrap().content else { panic!("shape") };
|
||||
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), SampleType::U8), None);
|
||||
// Without a cached render yet, such a fill still measures as the canvas, not as empty.
|
||||
let mut uncached = doc(&s).layer(soft).unwrap().clone();
|
||||
if let LayerContent::Shape(sh) = &mut uncached.content {
|
||||
sh.cache = None;
|
||||
}
|
||||
assert_eq!(align_bounds(&uncached, doc(&s).bounds(), SampleType::U8), Some(doc(&s).bounds()));
|
||||
}
|
||||
|
||||
/// Review of #8779: shape extents are measured at the document's own alpha precision. A faint
|
||||
/// stroke (0.001 opacity) is stored by a 16-bit or float document, so it counts in the extent
|
||||
/// and Align moves the opaque body as the document draws it; an 8-bit document stores it as
|
||||
/// nothing, so it does not widen the measure.
|
||||
#[test]
|
||||
fn align_bounds_follow_the_documents_alpha_precision() {
|
||||
for (depth, sample, expected) in [
|
||||
(8, SampleType::U8, Rect::new(60, 60, 120, 120)),
|
||||
(16, SampleType::U16, Rect::new(50, 50, 130, 130)),
|
||||
(32, SampleType::F32, Rect::new(50, 50, 130, 130)),
|
||||
] {
|
||||
let mut s = session(depth);
|
||||
let id = LayerId(s.execute("shape.create", json!({"kind": "rect", "rect": [60, 60, 60, 60], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("faint stroke", |doc, _| {
|
||||
if let Some(LayerContent::Shape(sh)) = doc.layer_mut(id).map(|l| &mut l.content) {
|
||||
sh.stroke = Some(photocraft_doc::ShapeStroke { width: 20.0, opacity: 0.001, ..Default::default() });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(doc(&s).pixel_format().sample, sample);
|
||||
let LayerContent::Shape(sh) = &doc(&s).layer(id).unwrap().content else { panic!("shape") };
|
||||
assert_eq!(shape_pixel_bounds(sh, doc(&s).bounds(), sample), Some(expected), "depth {depth}");
|
||||
assert_eq!(align_bounds(doc(&s).layer(id).unwrap(), doc(&s).bounds(), sample), Some(expected), "depth {depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_single_layer_to_selection() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn blend_from_str(s: &str) -> Option<BlendMode> {
|
||||
|
||||
/// The active selection as the mask of a layer being created, or None without a selection.
|
||||
/// Photoshop masks a new adjustment or fill layer with the selection, so the adjustment acts on
|
||||
/// the selected area only (#1250); the selection itself stays, as Reveal Selection leaves it.
|
||||
/// the selected area only (#1250). [`mask_new_layer`] then drops the selection (#8801).
|
||||
pub(crate) fn selection_mask(doc: &Document) -> Result<Option<LayerMask>> {
|
||||
doc.selection.as_ref().map(|sel| mask_at_depth(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() }, doc.depth)).transpose()
|
||||
}
|
||||
@@ -88,9 +88,10 @@ pub(crate) fn layer_panel_option(s: &Session, key: &str, fallback: bool) -> bool
|
||||
/// Mask a fill or adjustment layer being created, as Photoshop does: the path selected in the
|
||||
/// Paths panel (`"path"`: `"work"`, a saved path's name or a path object) becomes its vector
|
||||
/// mask, which makes a Solid Color fill a shape (#1419); without one, the selection becomes its
|
||||
/// layer mask ([`selection_mask`]); without either, a white mask reveals the whole layer (#1768).
|
||||
/// A path that isn't there is an error.
|
||||
pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
|
||||
/// layer mask ([`selection_mask`]) and is dropped, as Reveal Selection drops it (#5251, #8801);
|
||||
/// without either, a white mask reveals the whole layer (#1768). A path that isn't there is an
|
||||
/// error.
|
||||
pub(crate) fn mask_new_layer(doc: &mut Document, l: &mut Layer, p: &Value, cmd: &str, default_mask: bool) -> Result<()> {
|
||||
let path = match p.get("path") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(n)) if crate::vector_cmds::is_work(Some(n)) => Some(doc.work_path.clone().ok_or_else(|| bad(cmd, "no work path"))?),
|
||||
@@ -101,9 +102,13 @@ pub(crate) fn mask_new_layer(doc: &Document, l: &mut Layer, p: &Value, cmd: &str
|
||||
};
|
||||
match path {
|
||||
Some(path) => l.vector_mask = Some(photocraft_doc::VectorMask::new(path)),
|
||||
None => {
|
||||
l.mask = selection_mask(doc)?.or_else(|| default_mask.then(LayerMask::reveal_all)).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?;
|
||||
}
|
||||
None => match selection_mask(doc)? {
|
||||
Some(mask) => {
|
||||
l.mask = Some(mask);
|
||||
doc.selection = None;
|
||||
}
|
||||
None => l.mask = default_mask.then(LayerMask::reveal_all).map(|mask| mask_at_depth(mask, doc.depth)).transpose()?,
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use photocraft_algo::transform::{Homography, Interp};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::adjust::CurvePoint;
|
||||
use photocraft_doc::{ColorTable, Document, Duotone, DuotoneInk, Layer, LayerContent, LayerId, Size};
|
||||
use photocraft_geom::Affine;
|
||||
use photocraft_geom::{Affine, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -411,6 +411,62 @@ fn color_table(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The colour table painting on `p`'s target must stay inside: an Indexed Color document's,
|
||||
/// when the target is layer pixels (masks and channels are gray, not indexed).
|
||||
pub(crate) fn paint_color_table(doc: &Document, p: &Value) -> Option<ColorTable> {
|
||||
let pixels = crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels;
|
||||
doc.color_table.clone().filter(|_| pixels && doc.mode == ColorMode::Indexed)
|
||||
}
|
||||
|
||||
/// The colour `c` paints as in a document with `table` (see [`keep_in_color_table`]).
|
||||
pub(crate) fn table_color(table: &ColorTable, c: [f32; 4]) -> [f32; 4] {
|
||||
let mut px = [c];
|
||||
if !table.colors.is_empty() {
|
||||
quantize::quantize(&mut px, 1, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
|
||||
}
|
||||
let [c] = px;
|
||||
c
|
||||
}
|
||||
|
||||
/// Map the pixels of `r` that the stroke changed (they differ from `pre`) to their nearest
|
||||
/// colour-table entry, as the Indexed Color conversion does with no dither, so painting never
|
||||
/// leaves the table (#8669). One tile at a time: a long stroke's bounds can span the canvas.
|
||||
pub(crate) fn keep_in_color_table(table: &ColorTable, pre: &Surface, surf: &mut Surface, r: Rect) {
|
||||
if table.colors.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let mut enc = [0.0f32; 8];
|
||||
for tc in r.tiles() {
|
||||
let t = tc.rect().intersect(&r);
|
||||
if t.is_empty() || !surf.has_tiles_in(t) {
|
||||
continue;
|
||||
}
|
||||
let (before, mut data) = (pre.read_region(t), surf.read_region(t));
|
||||
let (mut changed, mut px) = (Vec::new(), Vec::new());
|
||||
for (i, (q, b)) in data.chunks_exact(n).zip(before.chunks_exact(n)).enumerate() {
|
||||
if q != b {
|
||||
changed.push(i);
|
||||
px.push(photocraft_raster::to_rgba(&fmt, q));
|
||||
}
|
||||
}
|
||||
if px.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w = px.len();
|
||||
quantize::quantize(&mut px, w, &table.colors, Dither::None, 0.0, table.transparent.map(usize::from));
|
||||
let mut mapped = changed.into_iter().zip(px).peekable();
|
||||
for (i, q) in data.chunks_exact_mut(n).enumerate() {
|
||||
if let Some((_, c)) = mapped.next_if(|(j, _)| *j == i) {
|
||||
photocraft_raster::from_rgba_into(&fmt, c, &mut enc);
|
||||
q.iter_mut().zip(enc).for_each(|(d, v)| *d = v);
|
||||
}
|
||||
}
|
||||
surf.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bitmap ----------
|
||||
|
||||
/// Bitmap's Output resolution range (ppi): what a new document may have.
|
||||
|
||||
@@ -749,3 +749,95 @@ fn color_table_rejects_an_invalid_transparent_index_without_editing() {
|
||||
s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap();
|
||||
assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0));
|
||||
}
|
||||
|
||||
/// A white 16×16 document in Indexed Color with the two-entry black/white table (#8669's repro).
|
||||
fn black_white_indexed(transparency: bool) -> Session {
|
||||
let mut s = Session::new();
|
||||
let background = if transparency { "transparent" } else { "white" };
|
||||
s.execute("file.new", json!({"width": 16, "height": 16, "background": background})).unwrap();
|
||||
paint(&mut s, |x, _| {
|
||||
if x < 2 {
|
||||
[0.0; 4]
|
||||
} else if x < 4 {
|
||||
[0.0, 0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[1.0; 4]
|
||||
}
|
||||
});
|
||||
s.execute("image.mode.indexedColor", json!({"palette": "exact", "forced": "none", "transparency": transparency, "dither": "none"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_stroke_stays_in_indexed_color_table() {
|
||||
let mut s = black_white_indexed(false);
|
||||
assert_eq!(doc(&s).color_table.as_ref().unwrap().colors, vec![[0, 0, 0], [255, 255, 255]]);
|
||||
s.execute("paint.stroke", json!({"points": [[8, 8, 1]], "size": 8, "color": "#ff0000", "opacity": 1, "flow": 1})).unwrap();
|
||||
// Red's nearest entry is black.
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
// Soft edges and partial opacity blend, and the blends map back onto the table too.
|
||||
s.execute("paint.stroke", json!({"points": [[4, 12], [14, 12]], "size": 6, "hardness": 0.0, "color": "#808080", "opacity": 0.5})).unwrap();
|
||||
let table = doc(&s).color_table.clone().unwrap();
|
||||
assert!(colours(doc(&s)).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(doc(&s)));
|
||||
assert_eq!(doc(&s).mode, ColorMode::Indexed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_stroke_keeps_indexed_transparency_binary() {
|
||||
let mut s = black_white_indexed(true);
|
||||
let table = doc(&s).color_table.clone().unwrap();
|
||||
assert!(table.transparent.is_some());
|
||||
s.execute("paint.stroke", json!({"points": [[0, 8], [15, 8]], "size": 8, "hardness": 0.0, "color": "#ff0000", "opacity": 0.7})).unwrap();
|
||||
let d = doc(&s);
|
||||
let surf = d.layers[0].surface().unwrap();
|
||||
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
|
||||
let a = surf.rgba(x, y)[3];
|
||||
assert!(a == 0.0 || a == 1.0, "alpha {a} at ({x},{y})");
|
||||
}
|
||||
assert!(colours(d).iter().all(|c| table.colors.contains(c)), "off-table pixels: {:?}", colours(d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_brush_preview_matches_indexed_commit() {
|
||||
let mut s = black_white_indexed(false);
|
||||
let params = json!({"points": [[4, 8], [12, 8]], "size": 7, "hardness": 0.0, "color": "#ff0000", "opacity": 0.6, "smoothing": 0.0, "seed": 1});
|
||||
let preview = crate::brush_cmds::LiveStroke::begin(&s, ¶ms).unwrap();
|
||||
let table = doc(&s).color_table.clone().unwrap();
|
||||
assert!(colours(&preview.doc).iter().all(|c| table.colors.contains(c)), "preview off the table: {:?}", colours(&preview.doc));
|
||||
s.execute("paint.stroke", params).unwrap();
|
||||
let (live, committed) = (preview.doc.layers[0].surface().unwrap(), doc(&s).layers[0].surface().unwrap());
|
||||
for (x, y) in (0..16).flat_map(|y| (0..16).map(move |x| (x, y))) {
|
||||
assert_eq!(live.rgba(x, y), committed.rgba(x, y), "preview/commit at ({x},{y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_brush_stroke_leaves_pixels_it_did_not_paint() {
|
||||
let mut s = black_white_indexed(false);
|
||||
// An off-table pixel inside the stroke's bounds but away from its dabs (Paint Bucket can
|
||||
// still leave one) is not the stroke's to remap.
|
||||
s.edit("setup", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let px = photocraft_raster::from_rgba(&surf.format(), [1.0, 0.0, 0.0, 1.0]);
|
||||
surf.write_region(Rect::new(13, 2, 14, 3), &px);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 2], [14, 14]], "size": 2, "hardness": 1.0, "color": "#ff0000", "smoothing": 0.0})).unwrap();
|
||||
let surf = doc(&s).layers[0].surface().unwrap();
|
||||
assert_eq!(surf.rgba(13, 2), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(surf.rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_auto_erase_knows_the_indexed_foreground() {
|
||||
let mut s = black_white_indexed(false);
|
||||
let pencil = json!({"points": [[8, 8]], "size": 3, "color": "#ff0000", "autoErase": true});
|
||||
s.execute("paint.pencil", pencil.clone()).unwrap();
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
// Red painted as black, so a stroke starting there auto-erases to the (white) background.
|
||||
let preview = crate::brush_cmds::LiveStroke::begin_with(&s, "paint.pencil", &pencil).unwrap();
|
||||
assert_eq!(preview.doc.layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
|
||||
s.execute("paint.pencil", pencil).unwrap();
|
||||
assert_eq!(doc(&s).layers[0].surface().unwrap().rgba(8, 8), [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,28 @@ use super::enabled::check_all_unlocked;
|
||||
use super::layer_params::{layer_param, mask_at_depth};
|
||||
use super::{Document, EngineError, Layer, LayerContent, LayerId, LayerMask, Result, Session, Value, json, pixels};
|
||||
|
||||
/// Is layer `id` already as far as Arrange can take it in direction `dir` (up when positive)? True
|
||||
/// at the top or bottom of its stack, or just above the Background, which stays at the bottom. The
|
||||
/// Background itself is not: arranging it is refused outright.
|
||||
pub(crate) fn arrange_at_end(doc: &Document, id: LayerId, dir: i32) -> bool {
|
||||
let Some(path) = doc.path_of(id) else { return false };
|
||||
let Some((&idx, parent)) = path.split_last() else { return false };
|
||||
let len = if parent.is_empty() {
|
||||
doc.layers.len()
|
||||
} else {
|
||||
match doc.layer_at(parent).and_then(Layer::children) {
|
||||
Some(children) => children.len(),
|
||||
None => return false,
|
||||
}
|
||||
};
|
||||
let bg = if parent.is_empty() { crate::background_cmds::background_id(doc) } else { None };
|
||||
if bg == Some(id) {
|
||||
return false;
|
||||
}
|
||||
let floor = usize::from(bg.is_some());
|
||||
if dir > 0 { idx.saturating_add(1) >= len } else { idx <= floor }
|
||||
}
|
||||
|
||||
pub(super) fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit("Arrange", |doc, _| {
|
||||
|
||||
@@ -69,6 +69,19 @@ pub(crate) fn check_f32_values(cmd: &str, value: &Value) -> Result<()> {
|
||||
check(cmd, value, 0)
|
||||
}
|
||||
|
||||
/// `p[key]` as CMYK inks 0..1 (`[c, m, y, k]`), the values a colour was picked as, or `None`
|
||||
/// when absent or null. A CMYK document stores exactly these instead of what the colour's sRGB
|
||||
/// stand-in converts back to (#4196, #8568).
|
||||
pub(crate) fn cmyk_param(cmd: &str, p: &Value, key: &str) -> Result<Option<[f32; 4]>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let inks: Option<Vec<f32>> =
|
||||
v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0) as f32)).collect());
|
||||
match inks.as_deref() {
|
||||
Some(&[c, m, y, k]) => Ok(Some([c, m, y, k])),
|
||||
_ => Err(bad(cmd, format!("`{key}` must be [c, m, y, k] numbers 0..1"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn color_param(cmd: &str, p: &Value, key: &str, default: [f32; 4]) -> Result<[f32; 4]> {
|
||||
Ok(match p.get(key) {
|
||||
Some(Value::Array(a)) if a.len() >= 3 => {
|
||||
|
||||
@@ -149,7 +149,17 @@ pub fn resolve(s: &Session, key: &str) -> Option<Pattern> {
|
||||
|
||||
pub(crate) fn resolve_param(s: &Session, cmd: &str, p: &Value) -> Result<Pattern> {
|
||||
let key = p.get("pattern").and_then(Value::as_str).unwrap_or("");
|
||||
resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))
|
||||
let pat = resolve(s, key).ok_or_else(|| bad(cmd, format!("no pattern \"{key}\" (see pattern.list)")))?;
|
||||
paintable(cmd, pat)
|
||||
}
|
||||
|
||||
/// `pat`, or the reason it would paint nothing: a pattern too large for the compositor renders as
|
||||
/// transparent, so a command that would use one says so up front.
|
||||
fn paintable(cmd: &str, pat: Pattern) -> Result<Pattern> {
|
||||
match pat.paint_problem() {
|
||||
Some(why) => Err(bad(cmd, format!("pattern \"{}\" would paint nothing: {why}", pat.display_name()))),
|
||||
None => Ok(pat),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies `pat` into the document's patterns unless it is already there (by id).
|
||||
@@ -168,6 +178,7 @@ pub fn resolve_effect(s: &Session, fx: photocraft_doc::Effect) -> Result<(photoc
|
||||
Effect::PatternOverlay { common, name, id, scale, angle, link, phase } => {
|
||||
let key = if id.is_empty() { name } else { id };
|
||||
let pat = resolve(s, &key).ok_or_else(|| bad("layer.layerStyle.patternOverlay", format!("no pattern \"{key}\" (see pattern.list)")))?;
|
||||
let pat = paintable("layer.layerStyle.patternOverlay", pat)?;
|
||||
Ok((Effect::PatternOverlay { common, name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase }, Some(pat)))
|
||||
}
|
||||
other => Ok((other, None)),
|
||||
@@ -180,6 +191,7 @@ fn info(p: &Pattern, in_doc: bool, in_library: bool) -> Value {
|
||||
"id": p.id, "name": p.display_name(), "storedName": p.name, "width": p.width, "height": p.height,
|
||||
"mode": format!("{:?}", f.mode), "depth": format!("{:?}", f.sample), "alpha": f.alpha,
|
||||
"inDocument": in_doc, "inLibrary": in_library,
|
||||
"paintable": p.is_paintable(), "paintProblem": p.paint_problem(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -190,19 +202,45 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
|
||||
/// The visible composite over `r` as a surface in the document's pixel format (with alpha).
|
||||
fn composite_surface(doc: &Document, r: Rect) -> Surface {
|
||||
let buf = photocraft_compose::render(doc, r);
|
||||
/// Pixels composited at a time by [`composite_surface`]: a band's render and conversion buffers
|
||||
/// stay a few tens of MB however large the pattern.
|
||||
const BAND_PIXELS: u32 = 1 << 20;
|
||||
|
||||
/// The visible composite over `r` as a surface in the document's pixel format (with alpha), and
|
||||
/// whether every pixel of it is opaque.
|
||||
fn composite_surface(doc: &Document, r: Rect) -> (Surface, bool) {
|
||||
composite_surface_in_bands(doc, r, (BAND_PIXELS / r.width().max(1)).max(1))
|
||||
}
|
||||
|
||||
/// [`composite_surface`], `band_rows` rows at a time. Rendering and converting one band at a
|
||||
/// time, and noting opacity as it goes, keeps an 8000 × 8000 pattern (64 MP, about 1 GB per full
|
||||
/// `f32` copy) from holding several full-size copies at once. Opacity is judged on the stored
|
||||
/// samples, as the surface quantizes them: an alpha of 0.999 in an 8-bit document is stored as 255.
|
||||
fn composite_surface_in_bands(doc: &Document, r: Rect, band_rows: u32) -> (Surface, bool) {
|
||||
let df = doc.pixel_format();
|
||||
let fmt = PixelFormat::new(df.mode, df.sample, true);
|
||||
let n = fmt.channels();
|
||||
let mut px = vec![0.0f32; buf.px.len() * n];
|
||||
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, o);
|
||||
}
|
||||
let (width, height) = (r.width(), r.height());
|
||||
let mut s = Surface::new(fmt);
|
||||
s.write_region(Rect::new(0, 0, r.width() as i32, r.height() as i32), &px);
|
||||
s
|
||||
let mut opaque = true;
|
||||
let mut px = Vec::new();
|
||||
let mut top = 0;
|
||||
while top < height {
|
||||
let rows = band_rows.min(height - top);
|
||||
let buf = photocraft_compose::render(doc, Rect::new(r.x0, r.y0 + top as i32, r.x1, r.y0 + (top + rows) as i32));
|
||||
px.clear();
|
||||
px.resize(buf.px.len() * n, 0.0f32);
|
||||
for (o, p) in px.chunks_exact_mut(n).zip(&buf.px) {
|
||||
photocraft_raster::from_rgba_into(&fmt, *p, o);
|
||||
}
|
||||
let band = Rect::new(0, top as i32, width as i32, (top + rows) as i32);
|
||||
s.write_region(band, &px);
|
||||
if opaque {
|
||||
opaque = s.read_region(band).chunks_exact(n).all(|q| q[n - 1] >= 1.0);
|
||||
}
|
||||
top += rows;
|
||||
}
|
||||
(s, opaque)
|
||||
}
|
||||
|
||||
/// The name Define Pattern gives a pattern without one: the active document's name without its
|
||||
@@ -219,6 +257,11 @@ pub fn default_name(s: &Session) -> String {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or(base)
|
||||
}
|
||||
|
||||
/// Longest edge Define Pattern accepts. Its area is limited too, to what the compositor holds
|
||||
/// (`Pattern::working_bytes`): 8000 x 8000 at 8 bits, and fewer pixels at 16 bits and float,
|
||||
/// where the working copy is larger per pixel. What is accepted always paints.
|
||||
const MAX_DEFINE_EDGE: u32 = 8000;
|
||||
|
||||
fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "edit.definePattern";
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
@@ -234,14 +277,20 @@ fn define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if r.is_empty() {
|
||||
return Err(bad(cmd, "the area is empty"));
|
||||
}
|
||||
if r.width() > 4000 || r.height() > 4000 {
|
||||
return Err(bad(cmd, "patterns are limited to 4000 × 4000 px"));
|
||||
if r.width() > MAX_DEFINE_EDGE || r.height() > MAX_DEFINE_EDGE {
|
||||
return Err(bad(cmd, "patterns are limited to 8000 × 8000 px"));
|
||||
}
|
||||
let mut surf = composite_surface(doc, r);
|
||||
// A pattern the compositor could not hold would define but paint nothing: 8 bits go to the
|
||||
// edge limit, 16-bit documents to 33,554,432 px in all, float to 16,777,216 (4096 × 4096).
|
||||
let sample = doc.pixel_format().sample;
|
||||
let max_px = photocraft_doc::pattern::max_paint_pixels(sample);
|
||||
if u64::from(r.width()) * u64::from(r.height()) > max_px {
|
||||
return Err(bad(cmd, format!("patterns in a {}-bit document are limited to {max_px} px in all", sample.bytes() * 8)));
|
||||
}
|
||||
let (mut surf, opaque) = composite_surface(doc, r);
|
||||
// Opaque patterns drop their alpha channel (as Photoshop does).
|
||||
let fmt = surf.format();
|
||||
let px = surf.read_region(Rect::new(0, 0, r.width() as i32, r.height() as i32));
|
||||
if px.chunks_exact(fmt.channels()).all(|q| q[fmt.channels() - 1] >= 1.0) {
|
||||
if opaque {
|
||||
let fmt = surf.format();
|
||||
surf = surf.convert(PixelFormat::new(fmt.mode, fmt.sample, false));
|
||||
}
|
||||
let name = match p.get("name").and_then(Value::as_str) {
|
||||
@@ -369,15 +418,15 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("brush.texturePattern", "the pattern is empty or too large to paint"));
|
||||
};
|
||||
// Brush textures are grey: luminance of the composite over white.
|
||||
let mut px = vec![[0.0f32; 4]; n];
|
||||
pat.surface.read_rgba_into(pat.rect(), &mut px);
|
||||
let lum: Vec<f32> = px
|
||||
.iter()
|
||||
.map(|q| {
|
||||
// Read a band of rows at a time: a whole-tile RGBA copy would cost 16 bytes a pixel.
|
||||
let mut lum: Vec<f32> = Vec::new();
|
||||
lum.try_reserve_exact(n).map_err(|_| bad("brush.texturePattern", "not enough memory for the pattern"))?;
|
||||
for_each_band(&pat, pat.width, pat.height, |band| {
|
||||
lum.extend(band.iter().map(|q| {
|
||||
let l = 0.299 * q[0] + 0.587 * q[1] + 0.114 * q[2];
|
||||
l * q[3] + (1.0 - q[3])
|
||||
})
|
||||
.collect();
|
||||
}));
|
||||
});
|
||||
let tile = photocraft_paint::GrayTile::from_f32(pat.width, pat.height, &lum);
|
||||
let t = &mut s.tools.brush.texture;
|
||||
t.pattern = photocraft_paint::Pattern::Tile(tile);
|
||||
@@ -393,13 +442,25 @@ pub fn average_color(p: &Pattern) -> Color {
|
||||
// A pattern too large to paint is averaged over its paintable top-left corner.
|
||||
let side = photocraft_doc::pattern::MAX_PAINT_PIXELS.isqrt() as u32;
|
||||
let (w, h) = (p.width.min(side), p.height.min(side));
|
||||
let mut px = vec![[0.0f32; 4]; w as usize * h as usize];
|
||||
p.surface.read_rgba_into(Rect::new(0, 0, w as i32, h as i32), &mut px);
|
||||
let n = px.len().max(1) as f32;
|
||||
let s = px.iter().fold([0.0f32; 3], |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]);
|
||||
let mut s = [0.0f32; 3];
|
||||
for_each_band(p, w, h, |band| s = band.iter().fold(s, |a, q| [a[0] + q[0], a[1] + q[1], a[2] + q[2]]));
|
||||
let n = (u64::from(w) * u64::from(h)).max(1) as f32;
|
||||
Color::rgb(s[0] / n, s[1] / n, s[2] / n)
|
||||
}
|
||||
|
||||
/// Calls `f` with the straight RGBA pixels of the top-left `w × h` corner of `p`, a band of rows
|
||||
/// at a time in row-major order.
|
||||
fn for_each_band(p: &Pattern, w: u32, h: u32, mut f: impl FnMut(&[[f32; 4]])) {
|
||||
let mut band = Vec::new();
|
||||
for top in (0..h).step_by(photocraft_doc::pattern::BAND_ROWS as usize) {
|
||||
let bottom = (top + photocraft_doc::pattern::BAND_ROWS).min(h);
|
||||
band.clear();
|
||||
band.resize(w as usize * (bottom - top) as usize, [0.0f32; 4]);
|
||||
p.surface.read_rgba_into(Rect::new(0, top as i32, w as i32, bottom as i32), &mut band);
|
||||
f(&band);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
@@ -551,6 +612,128 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of the 8000 px cap: compositing a band of rows at a time gives the pixels and the
|
||||
/// opacity of one pass, at every depth, whatever the band height.
|
||||
#[test]
|
||||
fn banded_compositing_matches_a_single_pass() {
|
||||
for depth in [8u64, 16, 32] {
|
||||
for transparent in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let background = if transparent { "transparent" } else { "white" };
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth, "background": background})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 3, "y": 4, "width": 20, "height": 13})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let area = Rect::new(2, 1, 38, 29);
|
||||
let all = Rect::new(0, 0, 36, 28);
|
||||
let (whole, whole_opaque) = composite_surface_in_bands(doc, area, 28);
|
||||
for band_rows in [1, 5, 27] {
|
||||
let (banded, opaque) = composite_surface_in_bands(doc, area, band_rows);
|
||||
assert_eq!(opaque, whole_opaque, "depth {depth}, transparent {transparent}, bands of {band_rows}");
|
||||
assert_eq!(banded.read_region(all), whole.read_region(all), "depth {depth}, transparent {transparent}, bands of {band_rows}");
|
||||
}
|
||||
assert_eq!(whole_opaque, !transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of the banded composite: opacity is the stored samples', so an alpha an 8-bit
|
||||
/// document stores as 255 is opaque and a float document's 0.999 is not.
|
||||
#[test]
|
||||
fn alpha_that_stores_as_opaque_counts_as_opaque() {
|
||||
for (depth, opaque) in [(8u64, true), (32, false)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8, "depth": depth, "background": "transparent"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
s.execute("layer.setProps", json!({"opacity": 0.999})).unwrap();
|
||||
let (_, all_opaque) = composite_surface_in_bands(&s.active().unwrap().doc, Rect::new(0, 0, 8, 8), 3);
|
||||
assert_eq!(all_opaque, opaque, "depth {depth}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of #8902: what Define Pattern accepts, the compositor paints. Areas over
|
||||
/// 4096 x 4096, past the compositor's whole-copy cap, define and fill at 8 and 16 bits (held
|
||||
/// at the pattern's own depth); a depth that has no smaller exact form is refused up front
|
||||
/// with its limit, instead of defining a pattern that paints nothing.
|
||||
#[test]
|
||||
fn defined_patterns_over_4096_squared_paint_or_are_refused() {
|
||||
for depth in [8, 16] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": depth})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
s.execute("edit.definePattern", json!({"name": "Big"})).unwrap();
|
||||
assert!(s.patterns.items.iter().any(|p| p.name == "Big" && p.is_paintable()), "depth {depth}");
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": depth})).unwrap();
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Big"})).unwrap();
|
||||
let [r, g, b, a] = pixel(&s, 5, 5);
|
||||
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "depth {depth}: {:?}", [r, g, b, a]);
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 4100, "height": 4100, "depth": 32})).unwrap();
|
||||
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("32-bit") && e.contains("16777216"), "{e}");
|
||||
assert!(s.patterns.items.iter().all(|p| p.name != "Pattern"), "nothing was defined");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 5600, "depth": 16})).unwrap();
|
||||
let e = s.execute("edit.definePattern", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("16-bit") && e.contains("33554432"), "{e}");
|
||||
}
|
||||
|
||||
/// A pattern of the largest area Define Pattern accepts at 8 bits is paintable: held at 4
|
||||
/// bytes a pixel, 256 MB, as the compositor's cap allows, and it renders.
|
||||
#[test]
|
||||
fn an_8000_by_8000_pattern_is_defined_and_paints() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8000, "height": 8000, "depth": 8})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
s.execute("edit.definePattern", json!({"name": "Largest"})).unwrap();
|
||||
let p = s.patterns.items.iter().find(|p| p.name == "Largest").unwrap();
|
||||
assert_eq!((p.width, p.height), (8000, 8000));
|
||||
assert!(p.is_paintable() && p.working_bytes() == 256_000_000);
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
|
||||
s.execute("layer.newFillLayer.pattern", json!({"pattern": "Largest"})).unwrap();
|
||||
let [r, g, b, a] = pixel(&s, 5, 5);
|
||||
assert!(a == 1.0 && (r - 0.2).abs() < 0.01 && (g - 0.4).abs() < 0.01 && (b - 0.6).abs() < 0.01, "{:?}", [r, g, b, a]);
|
||||
}
|
||||
|
||||
/// Review of #8902: a pattern too large to paint is named by every command that would use it
|
||||
/// and by pattern.list, instead of rendering as transparency with no message.
|
||||
#[test]
|
||||
fn a_pattern_that_would_paint_nothing_is_reported() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "depth": 8})).unwrap();
|
||||
// A 32-bit 5000 x 5000 pattern (declared only: 400 MB as a working copy, past the cap).
|
||||
let surface = photocraft_raster::Surface::new(photocraft_doc::PixelFormat::RGBA32F);
|
||||
s.patterns.items.push(Pattern { id: "huge".into(), name: "Huge".into(), width: 5000, height: 5000, surface });
|
||||
let list = s.execute("pattern.list", json!({})).unwrap();
|
||||
let huge = list.as_array().unwrap().iter().find(|p| p["name"] == "Huge").unwrap().clone();
|
||||
assert_eq!(huge["paintable"], json!(false));
|
||||
assert!(huge["paintProblem"].as_str().unwrap().contains("32 bits"), "{huge}");
|
||||
for (id, params) in [
|
||||
("layer.newFillLayer.pattern", json!({"pattern": "Huge"})),
|
||||
("layer.layerStyle.patternOverlay", json!({"pattern": "Huge"})),
|
||||
("edit.fill", json!({"contents": "pattern", "pattern": "Huge"})),
|
||||
("brush.texturePattern", json!({"pattern": "Huge"})),
|
||||
] {
|
||||
let e = s.execute(id, params).expect_err(id).to_string();
|
||||
assert!(e.contains("Huge") && e.contains("would paint nothing"), "{id}: {e}");
|
||||
}
|
||||
let e = s.execute("layer.newFillLayer.pattern", json!({"pattern": "Huge"})).unwrap_err().to_string();
|
||||
assert!(e.contains("would paint nothing") && e.contains("5000 × 5000"), "{e}");
|
||||
assert!(list.as_array().unwrap().iter().any(|p| p["name"] != "Huge" && p["paintable"] == json!(true)), "built-in patterns paint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_pattern_accepts_edges_over_4000_up_to_8000() {
|
||||
for (w, ok) in [(4100, true), (8000, true), (8001, false)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": 2, "depth": 8})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
let r = s.execute("edit.definePattern", json!({}));
|
||||
assert_eq!(r.is_ok(), ok, "width {w}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fill_layer_renders_at_every_depth_and_undoes() {
|
||||
for depth in [8u64, 16, 32] {
|
||||
@@ -642,6 +825,6 @@ mod tests {
|
||||
let huge = Pattern { id: "huge".into(), name: "Huge".into(), width: 65_536, height: 65_536, surface: Surface::new(PixelFormat::RGBA8) };
|
||||
s.patterns.items.push(huge);
|
||||
let e = s.execute("brush.texturePattern", json!({"pattern": "huge"})).unwrap_err();
|
||||
assert!(e.to_string().contains("too large"), "{e}");
|
||||
assert!(e.to_string().contains("would paint nothing") && e.to_string().contains("65536 × 65536"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,8 +250,8 @@ enum SpoolError {
|
||||
|
||||
/// Sends the job and says where it went and through what. A named print server takes the IPP
|
||||
/// route. Otherwise: the app's spooler when it is preferred; else `craft-print` (`lp`, or the
|
||||
/// Windows spooler) when it is there; else CUPS over IPP; and the app's spooler when that had
|
||||
/// no route either. A route that exists but fails (an unknown printer, a refused job) is an
|
||||
/// Windows spooler) when it is there, or the app's spooler when CUPS lists no printer for `lp`;
|
||||
/// else CUPS over IPP; and the app's spooler when that had no route either. A route that exists but fails (an unknown printer, a refused job) is an
|
||||
/// error, never a fall-through.
|
||||
fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -> Result<(String, String)> {
|
||||
let app = |sp: &Spooler| (sp.run)(job).map(|m| (m, sp.name.to_string())).map_err(other);
|
||||
@@ -261,7 +261,19 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
}
|
||||
if let Some(name) = system_spooler() {
|
||||
require_windows_pdf()?;
|
||||
return with_spooler(|| craft_print::submit(&job.pdf, lp)).map(|m| (m, name.to_string())).map_err(other);
|
||||
// lpstat is CUPS, not a Windows driver, so it runs outside SPOOLER. A listing that
|
||||
// can't finish in time can't tell, so lp gets the job as it did before the check.
|
||||
let route = match name {
|
||||
"lp" => lp_printers(LP_LISTING_TIMEOUT).map_or_else(|| Some(lp.clone()), |list| lp_route(lp, &list)),
|
||||
_ => Some(lp.clone()),
|
||||
};
|
||||
return match (route, spooler) {
|
||||
(Some(lp), _) => with_spooler(|| craft_print::submit(&job.pdf, &lp)).map(|m| (m, name.to_string())).map_err(other),
|
||||
// CUPS lists no printer. IPP would ask the same CUPS, or (it ignores client.conf)
|
||||
// another server whose printer the user never chose, so only the portal is left.
|
||||
(None, Some(sp)) => app(sp),
|
||||
(None, None) => Err(other("no printers found; set one up in the system's printer settings, or print to PDF")),
|
||||
};
|
||||
}
|
||||
}
|
||||
match cups_spool(job) {
|
||||
@@ -274,6 +286,78 @@ fn spool_job(spooler: Option<&Spooler>, job: &PrintJob, lp: &craft_print::Job) -
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a print job waits for CUPS' printer list (`lpstat`, whose `-e` browses the network).
|
||||
const LP_LISTING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// The most a printer listing may print; a longer one counts as no answer.
|
||||
const LP_LISTING_CAP: u64 = 1 << 20;
|
||||
|
||||
/// CUPS' printers as `craft_print::printers` lists them, or `None` when that can't be told: an
|
||||
/// `lpstat` can't start, outlasts `timeout`, prints more than `LP_LISTING_CAP`, or `lpstat -e`
|
||||
/// fails (CUPS unreachable, or listing forbidden by policy). `lpstat -p -d`'s own status is not
|
||||
/// a failure: it exits 1 whenever there are no queues, as on a host with only driverless
|
||||
/// printers. Each `lpstat` is killed and reaped at the deadline, so a hung one never outlives
|
||||
/// the job.
|
||||
fn lp_printers(timeout: std::time::Duration) -> Option<Vec<craft_print::Printer>> {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
let (_, queues) = output_by(craft_print::lpstat_command(), deadline)?;
|
||||
// `lpstat -e`, as craft-print runs it (its builder is private): the driverless printers.
|
||||
let mut driverless = std::process::Command::new("lpstat");
|
||||
driverless.arg("-e").env("LC_ALL", "C").env("LANG", "C").env("SOFTWARE", "Craft");
|
||||
let (listed, available) = output_by(driverless, deadline)?;
|
||||
listed.then(|| craft_print::printers_parsed(&queues, Some(&available)))
|
||||
}
|
||||
|
||||
/// Whether `command` succeeded, and its standard output; `None` when it can't start, prints more
|
||||
/// than `LP_LISTING_CAP`, or is still running at `deadline`, when it is killed and reaped. The
|
||||
/// output is read on its own thread, so a full pipe can't stall the wait, and that thread ends
|
||||
/// when the process does.
|
||||
fn output_by(mut command: std::process::Command, deadline: std::time::Instant) -> Option<(bool, String)> {
|
||||
use std::io::Read as _;
|
||||
use std::process::Stdio;
|
||||
let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
|
||||
let mut stdout = child.stdout.take()?;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let reader = std::thread::Builder::new().name("print-listing".into()).spawn(move || {
|
||||
let mut out = Vec::new();
|
||||
// One byte over the cap tells an oversized listing apart; stopping closes the pipe.
|
||||
let _ = sender.send((&mut stdout).take(LP_LISTING_CAP + 1).read_to_end(&mut out).map(|_| out));
|
||||
});
|
||||
let status = reader.ok().and_then(|_| {
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Some(status),
|
||||
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
_ => break None,
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some(status) = status else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
};
|
||||
let out = receiver.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())).ok()?.ok()?;
|
||||
let fits = u64::try_from(out.len()).is_ok_and(|n| n <= LP_LISTING_CAP);
|
||||
fits.then(|| (status.success(), String::from_utf8_lossy(&out).into_owned()))
|
||||
}
|
||||
|
||||
/// The job for `lp`, or `None` when no printer was asked for and CUPS lists none: `lp` has
|
||||
/// nowhere to send it, and the portal still may (a Flatpak bundles `lp`). A printer asked for by
|
||||
/// name always goes to `lp`, which knows it even where CUPS won't list it, and the portal could
|
||||
/// not honour the name. With no printer asked for, no default and one printer, that printer, as
|
||||
/// `resolve_printer` picks for IPP.
|
||||
fn lp_route(lp: &craft_print::Job, printers: &[craft_print::Printer]) -> Option<craft_print::Job> {
|
||||
let mut lp = lp.clone();
|
||||
if lp.printer.is_none()
|
||||
&& !printers.iter().any(|p| p.default)
|
||||
&& let [only] = printers
|
||||
{
|
||||
lp.printer = Some(only.name.clone());
|
||||
}
|
||||
(lp.printer.is_some() || !printers.is_empty()).then_some(lp)
|
||||
}
|
||||
|
||||
/// The spooler `craft-print` can talk to, as the job's `backend` name: `lp` when CUPS' command
|
||||
/// is on the path, the Windows spooler on Windows. `None` in a sandbox without `lp` and in the
|
||||
/// browser.
|
||||
|
||||
@@ -678,6 +678,14 @@ fn spool_transport_streams_pdf_and_propagates_refusal() {
|
||||
b"#!/bin/sh\nif [ \"$2\" = Refuse ]; then echo 'queue refused' >&2; exit 1; fi\nprintf '%s\\n' \"$@\" > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/args\"\n/bin/cat > \"$PHOTOCRAFT_TEST_PRINT_TRANSPORT/received.pdf\"\necho 'request id is Office-42 (0 file(s))'\n",
|
||||
).unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
// `lp` takes a job only when CUPS lists a printer (`lp_route`), so the fake CUPS lists its queues.
|
||||
let lpstat = dir.path().join("lpstat");
|
||||
std::fs::write(
|
||||
&lpstat,
|
||||
b"#!/bin/sh\nif [ \"$1\" = -e ]; then printf 'Office\\nRefuse\\n'; else printf 'printer Office is idle.\\nprinter Refuse is idle.\\n'; fi\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&lpstat, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "cmd::print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"])
|
||||
.env(CHILD, dir.path())
|
||||
@@ -972,6 +980,50 @@ fn a_preferred_app_spooler_takes_the_job() {
|
||||
assert!(std::path::Path::new(&out).exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_printer_listing_past_its_deadline_is_killed_and_reaped() {
|
||||
let soon = || std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut echo = std::process::Command::new("sh");
|
||||
echo.args(["-c", "echo printer Office is idle."]);
|
||||
assert_eq!(output_by(echo, soon()), Some((true, "printer Office is idle.\n".to_string())));
|
||||
let mut failed = std::process::Command::new("sh");
|
||||
failed.args(["-c", "exit 1"]);
|
||||
assert_eq!(output_by(failed, soon()), Some((false, String::new())), "a failed listing is told apart from an empty one");
|
||||
let mut flood = std::process::Command::new("sh");
|
||||
flood.args(["-c", "head -c 2000000 /dev/zero"]);
|
||||
assert_eq!(output_by(flood, soon()), None, "an oversized listing is no answer");
|
||||
// A hung lpstat: given up at the deadline, and killed, so nothing is left waiting on it.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pid_file = dir.path().join("pid");
|
||||
let mut hung = std::process::Command::new("sh");
|
||||
hung.arg("-c").arg(format!("echo $$ > '{}'; exec sleep 30", pid_file.display()));
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(output_by(hung, started + std::time::Duration::from_millis(300)), None);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "the deadline must cut the wait short");
|
||||
let pid = std::fs::read_to_string(&pid_file).unwrap();
|
||||
// `kill -0` fails for a process that no longer exists, on every Unix.
|
||||
let alive = std::process::Command::new("kill").args(["-0", pid.trim()]).stderr(std::process::Stdio::null()).status().unwrap().success();
|
||||
assert!(!alive, "the hung process must be killed and reaped");
|
||||
assert_eq!(output_by(std::process::Command::new("/nonexistent/lpstat"), soon()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lp_takes_the_job_only_when_cups_lists_a_printer_and_picks_the_only_one() {
|
||||
let p = |name: &str, default: bool| craft_print::Printer { name: name.into(), default, two_sided: None, description: String::new() };
|
||||
let job = craft_print::Job::default();
|
||||
// No printers: no lp route, so the portal still gets the job.
|
||||
assert!(lp_route(&job, &[]).is_none());
|
||||
assert_eq!(lp_route(&job, &[p("Office", false)]).and_then(|j| j.printer), Some("Office".into()));
|
||||
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", true)]).and_then(|j| j.printer), None, "lp picks the default itself");
|
||||
assert_eq!(lp_route(&job, &[p("Office", false), p("Lab", false)]).and_then(|j| j.printer), None);
|
||||
let asked = craft_print::Job { printer: Some("Lab".into()), ..Default::default() };
|
||||
assert_eq!(lp_route(&asked, &[p("Office", false)]).and_then(|j| j.printer), Some("Lab".into()));
|
||||
// A named printer goes to lp even when CUPS lists none: listing can be forbidden while
|
||||
// printing is not, and the portal can't honour a name.
|
||||
assert_eq!(lp_route(&asked, &[]).and_then(|j| j.printer), Some("Lab".into()));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod cups {
|
||||
use super::*;
|
||||
|
||||
@@ -220,6 +220,8 @@ mod sampling;
|
||||
pub use clone::{LiveRetouch, clone_preview, clone_preview_periodic};
|
||||
use clone::{blend_param, clamp_samples, clone_stamp};
|
||||
pub(crate) use dab::DabEffect;
|
||||
#[cfg(test)]
|
||||
use dab::DabTool;
|
||||
pub use dab::LiveDab;
|
||||
use dab::dab_cmd;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -125,7 +125,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
|
||||
/// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and
|
||||
/// turned into its per-dab effect, for the commit and the live preview alike.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum DabTool {
|
||||
pub(super) enum DabTool {
|
||||
Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool },
|
||||
Sponge { saturate: bool, vibrance: bool },
|
||||
Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 },
|
||||
@@ -168,7 +168,7 @@ impl DabNeighborhood {
|
||||
|
||||
impl DabTool {
|
||||
/// The tool for `cmd` and its history label.
|
||||
fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
|
||||
pub(super) fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
|
||||
Ok(match cmd {
|
||||
"paint.dodge" | "paint.burn" => {
|
||||
let burn = cmd == "paint.burn";
|
||||
@@ -216,7 +216,7 @@ impl DabTool {
|
||||
}
|
||||
|
||||
/// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`.
|
||||
fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect {
|
||||
pub(super) fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect {
|
||||
let sp = spacing;
|
||||
match self.clone() {
|
||||
Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)),
|
||||
@@ -380,7 +380,11 @@ pub struct LiveDab {
|
||||
/// The active document with the stroke so far.
|
||||
pub doc: std::sync::Arc<Document>,
|
||||
cmd: String,
|
||||
renderer: photocraft_paint::StrokeRenderer,
|
||||
ctx: photocraft_paint::BrushContext,
|
||||
generator: photocraft_paint::dynamics::DabGenerator,
|
||||
/// Only the current batch is retained; sequential effects don't use accumulated coverage.
|
||||
pending: Vec<photocraft_paint::Dab>,
|
||||
dual_pending: Vec<photocraft_paint::Dab>,
|
||||
done: usize,
|
||||
effect: DabEffect,
|
||||
neighborhood: DabNeighborhood,
|
||||
@@ -484,12 +488,17 @@ impl LiveDab {
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?;
|
||||
let pre_surf = surf.clone();
|
||||
let effect = effect(&pre_surf);
|
||||
// `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale).
|
||||
let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs();
|
||||
// Generate the same dabs as `apply_dab_stroke` (zoom 1), without a StrokeRenderer:
|
||||
// its accumulated coverage isn't used here, and each effect rasterizes its own footprint.
|
||||
let ctx = photocraft_paint::BrushContext::new(&stroke.brush);
|
||||
let generator = photocraft_paint::dynamics::DabGenerator::new(&stroke.brush, 1.0);
|
||||
let mut live = Self {
|
||||
doc: std::sync::Arc::new(doc),
|
||||
cmd: cmd.into(),
|
||||
renderer,
|
||||
ctx,
|
||||
generator,
|
||||
pending: Vec::new(),
|
||||
dual_pending: Vec::new(),
|
||||
done: 0,
|
||||
effect,
|
||||
neighborhood,
|
||||
@@ -523,11 +532,13 @@ impl LiveDab {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
/// Run the dabs the renderer has generated since the last call on the working copy; returns
|
||||
/// the rectangle they changed.
|
||||
/// Run this batch's primary dabs in order, rasterizing each footprint exactly once.
|
||||
fn run_new_dabs(&mut self) -> Rect {
|
||||
let ctx = self.renderer.ctx.clone();
|
||||
let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec();
|
||||
let ctx = &self.ctx;
|
||||
let mut new = std::mem::take(&mut self.pending);
|
||||
// As in `apply_dab_stroke`, sequential effects consume only primary footprints. Keep
|
||||
// generating dual dabs so the generator's state advances just as it did in StrokeRenderer.
|
||||
self.dual_pending.clear();
|
||||
let mut dmg = Rect::EMPTY;
|
||||
for (k, d) in new.iter().enumerate() {
|
||||
let r = ctx.dab_rect(d, false);
|
||||
@@ -535,11 +546,13 @@ impl LiveDab {
|
||||
continue;
|
||||
}
|
||||
let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d));
|
||||
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
|
||||
(self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k)));
|
||||
self.work.write(&self.pre_surf, ®ion.crop(r));
|
||||
dmg = dmg.union(&r);
|
||||
}
|
||||
self.done += new.len();
|
||||
self.done = self.done.saturating_add(new.len());
|
||||
new.clear();
|
||||
self.pending = new;
|
||||
dmg
|
||||
}
|
||||
|
||||
@@ -557,18 +570,20 @@ impl LiveDab {
|
||||
crate::brush_cmds::check_coords(pts, &self.cmd)?;
|
||||
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
|
||||
self.points.extend_from_slice(&pts);
|
||||
self.renderer.push(&pts);
|
||||
self.generator.push(&pts, &mut self.pending, &mut self.dual_pending);
|
||||
let dmg = self.run_new_dabs();
|
||||
let ctx = self.renderer.ctx.clone();
|
||||
let ctx = &self.ctx;
|
||||
// The finishing dabs, run on a scratch copy of the working pixels.
|
||||
let mut tail = None;
|
||||
if self.preview_tail {
|
||||
let ds = self.renderer.tail_dabs();
|
||||
let mut generator = self.generator.clone();
|
||||
let (mut ds, mut dual) = (Vec::new(), Vec::new());
|
||||
generator.finish(&mut ds, &mut dual);
|
||||
let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false)));
|
||||
if !r.is_empty() {
|
||||
let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo()));
|
||||
for (k, d) in ds.iter().enumerate() {
|
||||
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
|
||||
(self.effect)(&mut region, &ctx.footprint(d, self.done.saturating_add(k)));
|
||||
}
|
||||
tail = Some((r, region.crop(r)));
|
||||
}
|
||||
@@ -605,7 +620,7 @@ impl LiveDab {
|
||||
if now != tool || stroke.brush != *brush || id != self.id {
|
||||
return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke"));
|
||||
}
|
||||
self.renderer.finish();
|
||||
self.generator.finish(&mut self.pending, &mut self.dual_pending);
|
||||
let dmg = self.run_new_dabs();
|
||||
let shown = dmg.union(&self.tail);
|
||||
if !shown.is_empty() {
|
||||
|
||||
@@ -1851,6 +1851,104 @@ fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() {
|
||||
assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(layer_pixels(&s), want, "{cmd} {depth} {hardness}: redo restores the live commit");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare every displayed batch and the finishing dabs with the former StrokeRenderer path,
|
||||
/// including features whose coverage is unnecessary to sequential effects (dual tips/Wet Edges).
|
||||
#[test]
|
||||
fn live_dab_batches_match_the_accumulating_renderer() {
|
||||
use photocraft_paint::{GrayTile, StrokeRenderer, TipShape};
|
||||
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(200, 90, depth, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint_layer(&mut s, |x, y| {
|
||||
let mut c = texture(x, y);
|
||||
c[3] = 0.25 + ((x + y) % 7) as f32 * 0.1;
|
||||
c
|
||||
});
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
let pre = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
for case in ["soft", "hard", "sampled", "dynamics", "smoothing"] {
|
||||
s.tools.brush = BrushSettings::default();
|
||||
match case {
|
||||
"sampled" => s.tools.brush.tip = TipShape::Sampled(GrayTile::from_fn(9, 7, |x, y| ((x * 3 + y) % 11) as f32 / 10.0)),
|
||||
"dynamics" => {
|
||||
let b = &mut s.tools.brush;
|
||||
b.scattering.enabled = true;
|
||||
b.scattering.scatter.jitter = 0.5;
|
||||
b.scattering.count = 2;
|
||||
b.shape_dynamics.enabled = true;
|
||||
b.shape_dynamics.angle.jitter = 0.7;
|
||||
b.shape_dynamics.size.jitter = 0.3;
|
||||
b.dual_brush.enabled = true;
|
||||
b.dual_brush.size = 12.0;
|
||||
b.wet_edges = true;
|
||||
b.noise = true;
|
||||
b.texture.enabled = true;
|
||||
b.texture.each_tip = true;
|
||||
b.texture.pattern = photocraft_paint::Pattern::Tile(GrayTile::from_fn(3, 3, |x, y| (x + y) as f32 / 4.0));
|
||||
}
|
||||
"smoothing" => s.tools.brush.smoothing.amount = 0.65,
|
||||
_ => {}
|
||||
}
|
||||
let points = [
|
||||
StrokePoint::new(-5.0, 30.0, 0.3),
|
||||
StrokePoint::new(70.0, 40.0, 1.0),
|
||||
StrokePoint::new(150.0, 55.0, 0.6),
|
||||
StrokePoint::new(60.0, 35.0, 0.8),
|
||||
StrokePoint::new(61.0, 35.5, 0.2),
|
||||
];
|
||||
for (cmd, extra) in dab_cases() {
|
||||
let mut p = dab_opts(&[[-5.0, 30.0]], if case == "hard" { 100 } else { 0 }, &extra);
|
||||
p["points"] = json!([[-5.0, 30.0, 0.3]]);
|
||||
p["opacity"] = json!(57);
|
||||
p["flow"] = json!(63);
|
||||
let (stroke, _) = parse_brush(&s, &p, cmd).unwrap();
|
||||
let (tool, _) = DabTool::parse(&s, cmd, &p, &stroke).unwrap();
|
||||
let mut effect = tool.effect(pre.format(), stroke.brush.spacing);
|
||||
let mut reference = StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs();
|
||||
// A fixed region containing all dabs plus their read neighbourhoods.
|
||||
let orig = Region::read(&pre, Rect::new(-128, -128, 384, 256));
|
||||
let mut work = orig.clone();
|
||||
let mut shown = pre.clone();
|
||||
let mut done = 0;
|
||||
let mut live = LiveDab::begin(&s, cmd, &p).unwrap();
|
||||
let mut bounds = Rect::EMPTY;
|
||||
let mut render_new = |reference: &StrokeRenderer| {
|
||||
let mut damage = Rect::EMPTY;
|
||||
for (i, dab) in reference.dabs().iter().enumerate().skip(done) {
|
||||
let fp = reference.ctx.footprint(dab, i);
|
||||
effect(&mut work, &fp);
|
||||
damage = damage.union(&fp.rect);
|
||||
}
|
||||
done = reference.dabs().len();
|
||||
if !damage.is_empty() {
|
||||
photocraft_paint::retouch::mix_back(&mut shown, &orig, &work, damage, stroke.brush.opacity, None, false);
|
||||
}
|
||||
bounds = bounds.union(&damage);
|
||||
(bounds, shown.read_region(bounds))
|
||||
};
|
||||
for (batch_index, batch) in std::iter::once(&points[..1]).chain(points[1..].chunks(2)).enumerate() {
|
||||
reference.push(batch);
|
||||
if batch_index > 0 {
|
||||
live.push(batch).unwrap();
|
||||
}
|
||||
let (bounds, expected) = render_new(&reference);
|
||||
let actual = live.doc.layer(id).unwrap().surface().unwrap();
|
||||
assert!(actual.read_region(bounds) == expected, "{cmd} {depth} {case} batch={batch_index}");
|
||||
}
|
||||
reference.finish();
|
||||
let (bounds, expected) = render_new(&reference);
|
||||
let commit = live.into_commit(&s).unwrap();
|
||||
assert_eq!(commit.bounds, bounds, "{cmd} {depth} {case}: final damage");
|
||||
assert!(commit.surface.read_region(bounds) == expected, "{cmd} {depth} {case}: finishing dabs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use photocraft_color::{Color, ColorMode};
|
||||
use photocraft_doc::{Fill, LayerContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, check_pixels_unlocked, layer_param};
|
||||
use crate::commands::{CommandSpec, check_pixels_unlocked, cmyk_param, layer_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const SET: &str = "layer.fill.solidColor.set";
|
||||
@@ -57,7 +57,13 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Fill(Fill::Solid(original)) = layer.content else { return Err(bad("the target must be a Solid Color Fill layer")) };
|
||||
let color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
|
||||
let mut color = color_param(p.get("color").ok_or_else(|| bad("color is required"))?, original, st.doc.mode)?;
|
||||
// The inks the colour was picked as: a CMYK document keeps them exactly (#8568).
|
||||
if let Some(k) = cmyk_param(SET, p, "cmyk")?
|
||||
&& st.doc.mode == ColorMode::Cmyk
|
||||
{
|
||||
color = Color { mode: ColorMode::Cmyk, c: k, alpha: color.alpha };
|
||||
}
|
||||
if color != original {
|
||||
check_pixels_unlocked(&st.doc, id)?;
|
||||
if st.doc.effective_locks(id).transparency && color.alpha != original.alpha {
|
||||
@@ -78,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Solid Color…",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32}}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
|
||||
params: r##"{"layer":id? (default active),"document":id? (reject a stale dialog),"color":"#rrggbb"|"#rrggbbaa"|[r,g,b,a?]|{mode,c:[f32;4],alpha:f32},"cmyk":[c,m,y,k]? (inks 0..1 the colour was picked as: a CMYK document keeps exactly these)}; RGB values without alpha preserve it; stored Color preserves its model and precision"##,
|
||||
enabled: |s| s.active().map(|_| ()).ok_or_else(|| "no document open".into()),
|
||||
run: set,
|
||||
journal: true,
|
||||
@@ -89,6 +95,47 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #8568: CMYK 50/50/50/100 entered for a Solid Color fill in a CMYK document stays exactly
|
||||
/// those inks, on creation and on edit, instead of what its sRGB stand-in converts back to
|
||||
/// through the document profile. RGB documents ignore the inks.
|
||||
#[test]
|
||||
fn cmyk_inks_survive_solid_color_fills_in_a_cmyk_document() {
|
||||
let inks = [0.5, 0.5, 0.5, 1.0];
|
||||
let hex = |k: [f32; 4]| {
|
||||
let [r, g, b] = photocraft_color::convert::cmyk_to_rgb(k).map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
};
|
||||
let stored = |s: &Session, id: u64| match s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content {
|
||||
LayerContent::Fill(Fill::Solid(c)) => c,
|
||||
_ => panic!("not a solid fill"),
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
s.execute("image.mode.cmyk", json!({})).unwrap();
|
||||
let id = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks), "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: inks, alpha: 1.0 });
|
||||
// The sRGB stand-in alone does drift: that is what the inks prevent.
|
||||
let drifted = s.execute("layer.newFillLayer.solidColor", json!({"color": hex(inks)})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_ne!(stored(&s, drifted).c, inks);
|
||||
// Editing keeps the new inks too.
|
||||
let edit = [0.2, 0.3, 0.4, 0.5];
|
||||
s.execute(SET, json!({"layer": id, "color": hex(edit), "cmyk": edit})).unwrap();
|
||||
assert_eq!(stored(&s, id), Color { mode: ColorMode::Cmyk, c: edit, alpha: 1.0 });
|
||||
// A foreground picked as CMYK seeds a new fill with its inks, as fills do (#4196).
|
||||
s.execute("tools.setColors", json!({"foreground": hex(edit), "foregroundCmyk": edit})).unwrap();
|
||||
let seeded = s.execute("layer.newFillLayer.solidColor", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(stored(&s, seeded).c, edit);
|
||||
for bad in [json!([0.1, 0.2, 0.3]), json!("50,50,50,100"), json!([0.1, 0.2, 0.3, f64::NAN])] {
|
||||
assert!(s.execute("layer.newFillLayer.solidColor", json!({"cmyk": bad})).is_err(), "{bad}");
|
||||
assert!(s.execute(SET, json!({"layer": id, "color": "#000000", "cmyk": bad})).is_err(), "{bad}");
|
||||
}
|
||||
// An RGB document takes the colour, not the inks.
|
||||
let mut rgb = Session::new();
|
||||
rgb.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let id = rgb.execute("layer.newFillLayer.solidColor", json!({"color": "#336699", "cmyk": inks})).unwrap()["layer"].as_u64().unwrap();
|
||||
assert_eq!(stored(&rgb, id).mode, ColorMode::Rgb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_preserves_layer_and_masks_at_all_depths_and_undo_redo() {
|
||||
for depth in [8, 16, 32] {
|
||||
|
||||
@@ -265,6 +265,56 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The layers Free Transform moves with the Layers panel's selection, bottom-to-top: with several
|
||||
/// layers selected, every one that no selected group already carries, as in Photoshop (#6501), but
|
||||
/// the Background, which Show Transform Controls doesn't frame either. With one layer selected (or
|
||||
/// only the Background besides it), that layer; the active layer when nothing else is.
|
||||
pub fn selection_targets(st: &crate::DocState) -> Vec<LayerId> {
|
||||
targets_of(&st.doc, &st.selected_layers(), st.active_layer)
|
||||
}
|
||||
|
||||
/// [`selection_targets`] for the layers `selected`, `active` the active one: what an `edit.transform`
|
||||
/// without `layer` or `layers` moves, so a recorded step can drop a `layers` list that matches it.
|
||||
pub(crate) fn targets_of(doc: &Document, selected: &[LayerId], active: Option<LayerId>) -> Vec<LayerId> {
|
||||
if selected.len() < 2 {
|
||||
return active.into_iter().collect();
|
||||
}
|
||||
let ids: Vec<LayerId> = crate::layer_multi_cmds::top_level(doc, selected).into_iter().filter(|id| !doc.is_background(*id)).collect();
|
||||
if ids.is_empty() { active.into_iter().collect() } else { ids }
|
||||
}
|
||||
|
||||
/// The `layers` param: the layers one box transforms together (at most 10000, each in the
|
||||
/// document), without those a listed group carries. `None` when it isn't given.
|
||||
fn layers_param(doc: &Document, p: &Value) -> Result<Option<Vec<LayerId>>> {
|
||||
let a = match p.get("layers") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 10_000 => a,
|
||||
Some(v) => return Err(bad(format!("`layers` is a non-empty list of at most 10000 layer ids, got {v}"))),
|
||||
};
|
||||
let mut ids = Vec::with_capacity(a.len());
|
||||
for v in a {
|
||||
let id = v.as_u64().map(LayerId).ok_or_else(|| bad(format!("`layers` holds layer ids, got {v}")))?;
|
||||
if doc.layer(id).is_none() {
|
||||
return Err(EngineError::NoLayer(id));
|
||||
}
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(Some(crate::layer_multi_cmds::top_level(doc, &ids)))
|
||||
}
|
||||
|
||||
/// The box Free Transform opens on the layers `ids` together: their frames ([`transform_frame`])
|
||||
/// joined, as Show Transform Controls draws it. Empty when none of them has anything to move.
|
||||
pub fn transform_frame_of(doc: &Document, ids: &[LayerId]) -> Rect {
|
||||
ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_frame(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
/// [`transform_bounds`] of the layers `ids` together.
|
||||
pub fn transform_bounds_of(doc: &Document, ids: &[LayerId]) -> Rect {
|
||||
ids.iter().filter_map(|id| doc.layer(*id)).map(|l| transform_bounds(doc, l)).filter(|b| !b.is_empty()).fold(Rect::EMPTY, |a, b| a.union(&b))
|
||||
}
|
||||
|
||||
/// The memory (bytes) a single Free Transform warp's result may take: Preferences › Performance ›
|
||||
/// Memory Usage, which [`Session::apply_prefs`] sets, rather than a fixed 2³⁰ px, so a stitched
|
||||
/// layer of a few gigapixels transforms on a machine that has the memory for it (#7807). Only the
|
||||
@@ -734,6 +784,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
|
||||
let mut p = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if p.get("target").is_none()
|
||||
&& p.get("layer").is_none()
|
||||
&& p.get("layers").is_none()
|
||||
&& let (Some(m), Some(Value::Object(t))) = (p.as_object_mut(), crate::channel_cmds::targeted_channel(s))
|
||||
{
|
||||
m.extend(t);
|
||||
@@ -744,23 +795,40 @@ fn with_panel_target(s: &Session, p: &Value) -> Value {
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &with_panel_target(s, p);
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer);
|
||||
let lone = lone_target(&st.doc, id, p)?;
|
||||
let many = layers_param(&st.doc, p)?;
|
||||
if many.is_some()
|
||||
&& (!matches!(p.get("layer"), None | Some(Value::Null)) || !matches!(crate::channel_cmds::target_of(p), crate::channel_cmds::Target::Pixels))
|
||||
{
|
||||
return Err(bad("`layers` moves layers: pass it without `layer` or a mask or channel `target`"));
|
||||
}
|
||||
let explicit = crate::commands::explicit_layer(s, p)?;
|
||||
let id = explicit.or(st.active_layer);
|
||||
let lone = if many.is_some() { None } else { lone_target(&st.doc, id, p)? };
|
||||
let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) {
|
||||
(_, true) => None,
|
||||
(Some(id), false) => Some(id),
|
||||
(None, false) if many.is_some() => None,
|
||||
(None, false) => return Err(EngineError::Other("no active layer".into())),
|
||||
};
|
||||
// The layers the box moves: those listed, the one named, or else the layer selection (#6501).
|
||||
let ids: Vec<LayerId> = match (many, explicit) {
|
||||
(Some(ids), _) => ids,
|
||||
_ if lone.is_some() => Vec::new(),
|
||||
(None, Some(id)) => vec![id],
|
||||
(None, None) => selection_targets(st),
|
||||
};
|
||||
if let Some(missing) = ids.iter().find(|id| st.doc.layer(**id).is_none()) {
|
||||
return Err(EngineError::NoLayer(*missing));
|
||||
}
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(r) if r.len() == 4 => {
|
||||
let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
|
||||
[v[0], v[1], v[2], v[3]]
|
||||
}
|
||||
_ => {
|
||||
let b = match (lone, id) {
|
||||
(Some(surf), _) => target_bounds(&st.doc, surf),
|
||||
(None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?),
|
||||
(None, None) => Rect::EMPTY,
|
||||
let b = match lone {
|
||||
Some(surf) => target_bounds(&st.doc, surf),
|
||||
None => transform_bounds_of(&st.doc, &ids),
|
||||
};
|
||||
if b.is_empty() {
|
||||
return Err(EngineError::Other("nothing to transform".into()));
|
||||
@@ -790,22 +858,25 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] });
|
||||
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
|
||||
let lone = lone.is_some();
|
||||
let layers: Vec<u64> = ids.iter().map(|id| id.0).collect();
|
||||
let p = p.clone();
|
||||
// A background job when started with `Session::start` (#1015): the warp of a large layer runs
|
||||
// on a worker with progress, and can be cancelled.
|
||||
crate::jobs::edit_job(
|
||||
s,
|
||||
"Free Transform",
|
||||
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &p, lone, &h, affine, interp, ctl)),
|
||||
move |()| json!({"rect": rect}),
|
||||
move |doc, _, ctx| ctx.stage(0.0, 1.0, "Transforming", |ctl| transform_doc(doc, id, &ids, &p, lone, &h, affine, interp, ctl)),
|
||||
move |()| json!({"rect": rect, "layers": layers}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline.
|
||||
/// The edit of [`transform`]: the layers `ids` (or the lone mask or channel of `id`) and the
|
||||
/// selection outline.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn transform_doc(
|
||||
doc: &mut Document,
|
||||
id: Option<LayerId>,
|
||||
ids: &[LayerId],
|
||||
p: &Value,
|
||||
lone: bool,
|
||||
h: &Homography,
|
||||
@@ -834,21 +905,35 @@ fn transform_doc(
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let layer_sel = if is_group { None } else { sel.as_ref() };
|
||||
check_layer_budget(layer_sel, l, h)?;
|
||||
let taken = if h.is_identity() { Vec::new() } else { crate::smart_cmds::take_caches(std::slice::from_mut(l)) };
|
||||
apply_transform(layer_sel, group, l, h, affine, interp, ctl)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no active layer".into()));
|
||||
}
|
||||
// A group moves whole: the selection doesn't limit what is inside it.
|
||||
let layer_sel = |l: &Layer| if l.is_group() { None } else { sel.as_ref() };
|
||||
// Every layer is priced before any is built, so one that doesn't fit leaves all as they were.
|
||||
for &id in ids {
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
check_layer_budget(layer_sel(l), l, h)?;
|
||||
}
|
||||
let mut taken = Vec::new();
|
||||
for &id in ids {
|
||||
let group = group_locks(doc, id);
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
crate::extra_cmds::background_to_layer(doc, id);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let doc_sel = layer_sel(l);
|
||||
if !h.is_identity() {
|
||||
taken.extend(crate::smart_cmds::take_caches(std::slice::from_mut(l)));
|
||||
}
|
||||
apply_transform(doc_sel, group, l, h, affine, interp, ctl)?;
|
||||
}
|
||||
if !h.is_identity() {
|
||||
// Type layers re-render from their new transform.
|
||||
let snapshot = doc.clone();
|
||||
if let Some(l) = doc.layer_mut(id) {
|
||||
refresh_text(&snapshot, l);
|
||||
for &id in ids {
|
||||
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))?;
|
||||
// The selection outline moves with the pixels.
|
||||
@@ -865,7 +950,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Free Transform",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||
params: r##"{"layer":id?,"layers":[id,…]? (several layers moved by one box, one step; default with neither = the selected layers but the Background, as Photoshop's Free Transform with several selected),"rect":[x0,y0,x1,y1]? (source frame; default = the layers' content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||
enabled: has_layer,
|
||||
journal: true,
|
||||
run: transform,
|
||||
@@ -874,3 +959,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_tests;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Free Transform of several selected layers by one box (#6501).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 200 x 200 document: a red 40 x 20 block on "Paint" (x 10..50, y 10..30) and a type layer
|
||||
/// over it, both selected, the type layer active (as after creating it and ⌘-clicking Paint).
|
||||
fn two_layers() -> (Session, LayerId, LayerId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
|
||||
let paint = LayerId(s.execute("layer.new.layer", json!({"name": "Paint"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("paint", |doc, _| {
|
||||
doc.layer_mut(paint).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 10, 50, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let text = LayerId(s.execute("type.create", json!({"x": 80, "y": 120, "text": "Hi", "size": 30})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap();
|
||||
(s, paint, text)
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: LayerId) -> Rect {
|
||||
let st = s.active().unwrap();
|
||||
transform_bounds(&st.doc, st.doc.layer(id).unwrap())
|
||||
}
|
||||
|
||||
fn text_matrix(s: &Session, id: LayerId) -> [f64; 6] {
|
||||
let st = s.active().unwrap();
|
||||
let LayerContent::Text(t) = &st.doc.layer(id).unwrap().content else { panic!("not a type layer") };
|
||||
t.transform.m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_moves_every_selected_layer_in_one_step() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let (p0, t0, m0) = (bounds(&s, paint), bounds(&s, text), text_matrix(&s, text));
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
// Before #6501 only the active layer moved, whichever kind it was.
|
||||
let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 30, 40]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([paint.0, text.0]));
|
||||
assert_eq!(bounds(&s, paint), p0.translate(30, 40));
|
||||
assert_eq!(bounds(&s, text), t0.translate(30, 40));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.history.past_len(), steps + 1, "one history step");
|
||||
let history = s.execute("document.inspect", json!({})).unwrap()["history"].clone();
|
||||
assert_eq!(history.as_array().unwrap().last(), Some(&json!("Free Transform")));
|
||||
// The type stays editable: it keeps the transform instead of pixels.
|
||||
assert_eq!(text_matrix(&s, text)[4..], [m0[4] + 30.0, m0[5] + 40.0]);
|
||||
s.undo();
|
||||
assert_eq!((bounds(&s, paint), bounds(&s, text)), (p0, t0), "and one Undo takes both back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_frame_is_the_selected_layers_together() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let both = bounds(&s, paint).union(&bounds(&s, text));
|
||||
let m0 = text_matrix(&s, text);
|
||||
// 200 % about the joined frame's centre: each layer scales about that point, not its own.
|
||||
let r = s.execute("edit.transform", json!({"scaleX": 200, "scaleY": 200})).unwrap();
|
||||
let rect: Vec<f64> = serde_json::from_value(r["rect"].clone()).unwrap();
|
||||
assert_eq!(rect, [both.x0, both.y0, both.x1, both.y1].map(f64::from));
|
||||
let (cx, cy) = (f64::from(both.x0 + both.x1) / 2.0, f64::from(both.y0 + both.y1) / 2.0);
|
||||
let p = bounds(&s, paint);
|
||||
assert_eq!((f64::from(p.x0), f64::from(p.y0)), (2.0 * 10.0 - cx, 2.0 * 10.0 - cy));
|
||||
let m = text_matrix(&s, text);
|
||||
assert!((m[0] - 2.0).abs() < 1e-9 && (m[3] - 2.0).abs() < 1e-9, "{m:?}");
|
||||
assert!((m[4] - (2.0 * m0[4] - cx)).abs() < 1e-9 && (m[5] - (2.0 * m0[5] - cy)).abs() < 1e-9, "{m:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_layer_still_transforms_that_layer_alone() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let t0 = bounds(&s, text);
|
||||
let r = s.execute("edit.transform", json!({"layer": paint.0, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([paint.0]));
|
||||
assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30));
|
||||
assert_eq!(bounds(&s, text), t0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layers_param_names_the_layers_and_a_listed_group_carries_its_children() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let t0 = bounds(&s, text);
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
// The group and the layer in it: the layer moves once, with its group.
|
||||
let r = s.execute("edit.transform", json!({"layers": [paint.0, group.0], "matrix": [1, 0, 0, 1, 7, 0]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([group.0]));
|
||||
assert_eq!(bounds(&s, paint), Rect::new(17, 10, 57, 30));
|
||||
assert_eq!(bounds(&s, text), t0, "not listed, not moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_stays_out_of_a_selection_of_several() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let bg = s.active().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap();
|
||||
let r = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 3, 0]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([paint.0, text.0]));
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc.is_background(bg), "still the Background");
|
||||
assert_eq!(st.doc.layer(bg).unwrap().surface().unwrap().content_bounds(), Rect::new(0, 0, 200, 200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_limits_the_pixels_of_every_selected_layer() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let t0 = bounds(&s, text);
|
||||
// The left half of the block: only those pixels of Paint move; the type moves whole (#2630).
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 200})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 100]})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layer(paint).unwrap().surface().unwrap();
|
||||
assert_eq!(surf.content_bounds(), Rect::new(10, 10, 50, 130));
|
||||
assert_eq!(surf.sample_channel(20, 15, 3), 0.0, "the selected part left");
|
||||
assert_eq!(surf.sample_channel(40, 15, 3), 1.0, "the rest stayed");
|
||||
assert_eq!(surf.sample_channel(20, 115, 3), 1.0, "and landed below");
|
||||
assert_eq!(bounds(&s, text), t0.translate(0, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_layer_among_them_refuses_the_whole_transform() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
s.execute("layer.setProps", json!({"layer": text.0, "locks": {"position": true}})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": text.0, "mode": "add"})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let (before, revision) = (st.doc.clone(), st.revision);
|
||||
let err = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 5, 0]})).unwrap_err().to_string();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.revision == revision, "nothing moved, not even the unlocked layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_layer_past_the_pixel_budget_leaves_every_layer_as_it_was() {
|
||||
let (mut s, paint, _) = two_layers();
|
||||
// A 1 px dot, beside the 40 px block: only the block, stretched, outgrows the test budget.
|
||||
let dot = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.edit("dot", |doc, _| {
|
||||
doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 40, 11, 41), &[0.0, 1.0, 0.0, 1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let (before, revision) = (st.doc.clone(), st.revision);
|
||||
let k = 4e4;
|
||||
assert!(s.execute("edit.transform", json!({"layers": [dot.0, paint.0], "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err());
|
||||
let st = s.active().unwrap();
|
||||
assert!(st.doc == before && st.revision == revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_with_type_among_the_layers_is_refused() {
|
||||
let (mut s, _, _) = two_layers();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let quad = json!({"rect": [0, 0, 100, 100], "quad": [[0, 0], [100, 0], [120, 100], [-20, 100]]});
|
||||
assert!(s.execute("edit.transform", quad).is_err());
|
||||
assert!(s.active().unwrap().doc == before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_again_repeats_on_every_selected_layer() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let (p0, t0) = (bounds(&s, paint), bounds(&s, text));
|
||||
let both = p0.union(&t0);
|
||||
let r = [both.x0, both.y0, both.x1, both.y1].map(f64::from);
|
||||
let quad = [[r[0] + 10.0, r[1]], [r[2] + 10.0, r[1]], [r[2] + 10.0, r[3]], [r[0] + 10.0, r[3]]];
|
||||
// Nearest neighbour: no resampling fringe to blur the bounds.
|
||||
s.execute("edit.transform", json!({"rect": r, "quad": quad, "interpolation": "nearest"})).unwrap();
|
||||
s.execute("edit.transform.again", json!({})).unwrap();
|
||||
assert_eq!(bounds(&s, paint), p0.translate(20, 0));
|
||||
assert_eq!(bounds(&s, text), t0.translate(20, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_again_on_a_copy_copies_and_moves_the_active_layer_only() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 0]})).unwrap();
|
||||
let (p1, t1) = (bounds(&s, paint), bounds(&s, text));
|
||||
s.execute("edit.transform.againCopy", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let copy = st.active_layer.unwrap();
|
||||
assert!(copy != paint && copy != text);
|
||||
assert_eq!(bounds(&s, copy), t1.translate(10, 0), "the copy of the active type layer moved on");
|
||||
assert_eq!((bounds(&s, paint), bounds(&s, text)), (p1, t1), "the originals stayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_layer_beside_layers_counts_as_absent() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let t0 = bounds(&s, text);
|
||||
let r = s.execute("edit.transform", json!({"layers": [paint.0], "layer": null, "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
assert_eq!(r["layers"], json!([paint.0]));
|
||||
assert_eq!(bounds(&s, paint), Rect::new(15, 10, 55, 30));
|
||||
assert_eq!(bounds(&s, text), t0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_layers_params_are_errors_not_panics() {
|
||||
let (mut s, paint, _) = two_layers();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let m = json!([1, 0, 0, 1, 5, 0]);
|
||||
for p in [
|
||||
json!({"layers": [], "matrix": m}),
|
||||
json!({"layers": "all", "matrix": m}),
|
||||
json!({"layers": [paint.0, "x"], "matrix": m}),
|
||||
json!({"layers": [-1], "matrix": m}),
|
||||
json!({"layers": [987_654], "matrix": m}),
|
||||
json!({"layers": [u64::MAX], "matrix": m}),
|
||||
json!({"layers": (0..10_001).collect::<Vec<u64>>(), "matrix": m}),
|
||||
json!({"layers": [paint.0], "layer": paint.0, "matrix": m}),
|
||||
json!({"layers": [paint.0], "target": "quickMask", "matrix": m}),
|
||||
json!({"layers": [paint.0], "target": {"channel": 0}, "matrix": m}),
|
||||
] {
|
||||
assert!(s.execute("edit.transform", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.active().unwrap().doc == before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straighten_layer_rotates_every_selected_layer() {
|
||||
// Photoshop's Straighten Layer turns all the selected layers, not just the active one; it
|
||||
// goes through `edit.transform` without a layer, so it takes the selection like Free Transform.
|
||||
let (mut s, paint, text) = two_layers();
|
||||
let (p0, m0) = (bounds(&s, paint), text_matrix(&s, text));
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
let r = s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
assert_eq!(r["cropped"], false);
|
||||
assert!(bounds(&s, paint) != p0, "the selected pixel layer turned too");
|
||||
assert!(text_matrix(&s, text)[1].abs() > 0.05 && m0[1].abs() < 1e-9, "the active type layer turned");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step");
|
||||
assert!(s.undo());
|
||||
assert_eq!((bounds(&s, paint), text_matrix(&s, text)), (p0, m0), "and one Undo takes both back");
|
||||
|
||||
// With one layer selected, only that layer turns.
|
||||
s.execute("layer.select", json!({"layer": paint.0})).unwrap();
|
||||
s.execute("image.analysis.rulerTool", json!({"start": [10, 30], "end": [70, 25]})).unwrap();
|
||||
s.execute("image.analysis.straightenLayer", json!({})).unwrap();
|
||||
assert!(bounds(&s, paint) != p0);
|
||||
assert_eq!(text_matrix(&s, text), m0, "an unselected layer stays put");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_transform_of_the_selected_layers_replays_on_the_selection_in_another_document() {
|
||||
let (mut s, paint, text) = two_layers();
|
||||
s.execute("actions.record", json!({"name": "Move both"})).unwrap();
|
||||
// What the canvas box commits: the ids it opened on, listed (#6501).
|
||||
s.execute("edit.transform", json!({"layers": [paint.0, text.0], "matrix": [1, 0, 0, 1, 30, 40]})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
let step = s.actions.list[0].steps[0].1.clone();
|
||||
assert!(step.get("layers").is_none(), "the step plays on the selection, not on these ids: {step}");
|
||||
// Another document, its ids shifted by an extra layer under the two that get selected.
|
||||
s.execute("file.new", json!({"width": 200, "height": 200, "background": "white"})).unwrap();
|
||||
let spare = LayerId(s.execute("layer.new.layer", json!({"name": "Spare"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
assert_ne!((a, b), (paint, text), "the ids differ from the recorded ones");
|
||||
s.edit("paint", |doc, _| {
|
||||
for (id, x) in [(spare, 100), (a, 10), (b, 60)] {
|
||||
doc.layer_mut(id).unwrap().surface_mut().unwrap().fill_rect(Rect::new(x, 10, x + 20, 30), &[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
let (a0, b0, spare0) = (bounds(&s, a), bounds(&s, b), bounds(&s, spare));
|
||||
let r = s.execute("actions.play", json!({"action": 0})).unwrap();
|
||||
assert!(r.get("failed").is_none(), "{r}");
|
||||
assert_eq!((bounds(&s, a), bounds(&s, b)), (a0.translate(30, 40), b0.translate(30, 40)), "the selected layers moved");
|
||||
assert_eq!(bounds(&s, spare), spare0, "the unselected layer stayed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_transform_of_other_layers_than_the_selection_keeps_their_ids() {
|
||||
let (mut s, paint, _) = two_layers();
|
||||
s.execute("actions.record", json!({"name": "Paint only"})).unwrap();
|
||||
s.execute("edit.transform", json!({"layers": [paint.0], "matrix": [1, 0, 0, 1, 5, 0]})).unwrap();
|
||||
s.execute("actions.stop", json!({})).unwrap();
|
||||
assert_eq!(s.actions.list[0].steps[0].1["layers"], json!([paint.0]));
|
||||
}
|
||||
@@ -345,7 +345,6 @@ fn cached_gradient_fill() -> Session {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("layer.newFillLayer.gradient", json!({"from": "#ff0000", "to": "#0000ff", "angle": 0})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.edit("cache", |doc, active| {
|
||||
let (fmt, canvas) = (doc.pixel_format(), doc.bounds());
|
||||
let l = doc.layer_mut(active.unwrap()).unwrap();
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save for Web (Legacy)…",
|
||||
&["File", "Export"],
|
||||
Some("Cmd+Alt+Shift+S"),
|
||||
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
|
||||
r##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp|webp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1–256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60 (JPEG, lossy WebP),"lossless":bool=false (WebP),"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"##,
|
||||
has_doc,
|
||||
save_for_web
|
||||
),
|
||||
|
||||
@@ -128,21 +128,34 @@ pub fn optimize(
|
||||
let mut color_table = Vec::new();
|
||||
let mut transparent_index = None;
|
||||
let mut indices = Vec::new();
|
||||
// True colour (PNG-24, WebP): straight RGBA when transparency is on and a pixel is
|
||||
// translucent, else RGB over the matte.
|
||||
let true_color = || {
|
||||
let alpha = st.transparency && region().any(|p| p[3] < 1.0);
|
||||
let k = if alpha { 4 } else { 3 };
|
||||
let mut data = Vec::with_capacity(w * h * k);
|
||||
for p in region() {
|
||||
let q = if alpha { *p } else { over(p) };
|
||||
data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]);
|
||||
}
|
||||
let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb };
|
||||
(data, layout)
|
||||
};
|
||||
let rgba = |data: &[u8], layout: photocraft_codecs::ChannelLayout| -> Vec<u8> {
|
||||
if layout == photocraft_codecs::ChannelLayout::Rgba {
|
||||
data.to_vec()
|
||||
} else {
|
||||
data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect()
|
||||
}
|
||||
};
|
||||
let meta = || photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() };
|
||||
let (bytes, colors) = match st.format {
|
||||
WebFormat::Png24 => {
|
||||
let alpha = st.transparency && region().any(|p| p[3] < 1.0);
|
||||
let k = if alpha { 4 } else { 3 };
|
||||
let mut data = Vec::with_capacity(w * h * k);
|
||||
for p in region() {
|
||||
let q = if alpha { *p } else { over(p) };
|
||||
data.extend_from_slice(&[to8(q[0]), to8(q[1]), to8(q[2]), to8(q[3])][..k]);
|
||||
}
|
||||
let (data, layout) = true_color();
|
||||
if preview {
|
||||
look = if alpha { data.clone() } else { data.as_chunks::<3>().0.iter().flat_map(|c| [c[0], c[1], c[2], 255]).collect() };
|
||||
look = rgba(&data, layout);
|
||||
}
|
||||
let layout = if alpha { photocraft_codecs::ChannelLayout::Rgba } else { photocraft_codecs::ChannelLayout::Rgb };
|
||||
let meta = photocraft_codecs::Metadata { dpi: Some((dpi, dpi)), xmp: xmp.map(str::to_string), ..Default::default() };
|
||||
let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta);
|
||||
let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(meta());
|
||||
let opts = photocraft_codecs::EncodeOptions {
|
||||
png_interlaced: st.interlaced,
|
||||
embed_icc: icc.is_some(),
|
||||
@@ -151,6 +164,29 @@ pub fn optimize(
|
||||
};
|
||||
(photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &opts).map_err(e)?, None)
|
||||
}
|
||||
WebFormat::Webp => {
|
||||
let (data, layout) = true_color();
|
||||
let icc = icc.filter(|_| st.embed_icc);
|
||||
// WebP holds EXIF (the metadata choice's "All" passes it in), so it rides along with the XMP.
|
||||
let webp_meta = photocraft_codecs::Metadata { exif: exif.map(<[u8]>::to_vec), ..meta() };
|
||||
let img = photocraft_codecs::Image::from_u8(ww, hh, layout, data).map_err(e)?.with_icc(icc.map(<[u8]>::to_vec)).with_meta(webp_meta);
|
||||
let opts = photocraft_codecs::EncodeOptions {
|
||||
webp_lossless: st.lossless,
|
||||
webp_quality: st.quality.min(100),
|
||||
embed_icc: icc.is_some(),
|
||||
embed_metadata: exif.is_some() || xmp.is_some(),
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::WebP, &opts).map_err(e)?;
|
||||
if preview {
|
||||
// Lossless shows the source; lossy shows the compression artefacts.
|
||||
look = match (!st.lossless).then(|| photocraft_codecs::decode(&bytes).ok()).flatten() {
|
||||
Some(img) => img.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec(),
|
||||
None => rgba(img.data(), layout),
|
||||
};
|
||||
}
|
||||
(bytes, None)
|
||||
}
|
||||
WebFormat::Jpeg => {
|
||||
let mut rgb = Vec::with_capacity(w * h * 3);
|
||||
for p in region() {
|
||||
|
||||
@@ -172,10 +172,11 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u
|
||||
if let Some(authorize) = s.authorize {
|
||||
authorize(cmd, p)?;
|
||||
}
|
||||
// Settings are checked first, so a refused export leaves a floating selection where it is.
|
||||
let st = WebSettings::from_params(p, cmd)?;
|
||||
// Direct platform calls must export the moved selection as it appears on the canvas,
|
||||
// just as Session::execute does before dispatching the native command.
|
||||
crate::float_cmds::before_command(s, cmd)?;
|
||||
let st = WebSettings::from_params(p, cmd)?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc = d.doc.clone();
|
||||
let (wdoc, sx, sy) = web_document(&doc, p, &st)?;
|
||||
|
||||
@@ -10,6 +10,9 @@ pub enum WebFormat {
|
||||
Png24,
|
||||
Jpeg,
|
||||
Wbmp,
|
||||
/// WebP, lossy (our VP8 encoder) or lossless. Not in Photoshop's legacy dialog, but the
|
||||
/// format people prepare web images in now (#2415, #6391).
|
||||
Webp,
|
||||
}
|
||||
|
||||
impl WebFormat {
|
||||
@@ -20,6 +23,7 @@ impl WebFormat {
|
||||
"png24" | "png-24" | "png" | "png32" => WebFormat::Png24,
|
||||
"jpeg" | "jpg" => WebFormat::Jpeg,
|
||||
"wbmp" => WebFormat::Wbmp,
|
||||
"webp" => WebFormat::Webp,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -29,6 +33,7 @@ impl WebFormat {
|
||||
WebFormat::Png8 | WebFormat::Png24 => "png",
|
||||
WebFormat::Jpeg => "jpg",
|
||||
WebFormat::Wbmp => "wbmp",
|
||||
WebFormat::Webp => "webp",
|
||||
}
|
||||
}
|
||||
pub fn indexed(self) -> bool {
|
||||
@@ -68,8 +73,10 @@ pub struct WebSettings {
|
||||
/// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the
|
||||
/// web-safe cube.
|
||||
pub web_snap: f32,
|
||||
/// JPEG quality 0..=100.
|
||||
/// JPEG and lossy WebP quality 0..=100.
|
||||
pub quality: u8,
|
||||
/// WebP: lossless instead of lossy at `quality`.
|
||||
pub lossless: bool,
|
||||
pub progressive: bool,
|
||||
pub optimized: bool,
|
||||
pub embed_icc: bool,
|
||||
@@ -92,6 +99,7 @@ impl Default for WebSettings {
|
||||
interlaced: false,
|
||||
web_snap: 0.0,
|
||||
quality: 60,
|
||||
lossless: false,
|
||||
progressive: false,
|
||||
optimized: true,
|
||||
embed_icc: false,
|
||||
@@ -123,7 +131,7 @@ impl WebSettings {
|
||||
}
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
if let Some(f) = s("format") {
|
||||
st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp)")))?;
|
||||
st.format = WebFormat::from_id(f).ok_or_else(|| bad(cmd, format!("unknown format `{f}` (gif|png8|png24|jpeg|wbmp|webp)")))?;
|
||||
}
|
||||
if let Some(k) = s("palette") {
|
||||
st.palette = match k {
|
||||
@@ -148,7 +156,13 @@ impl WebSettings {
|
||||
if let Some(a) = p.get("ditherAmount").and_then(Value::as_f64) {
|
||||
st.dither_amount = (a as f32 / 100.0).clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(b) = p.get("transparency").and_then(Value::as_bool) {
|
||||
// An on/off option given as anything but true or false is an error, not its default.
|
||||
let flag = |k: &str| match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be true or false"))),
|
||||
};
|
||||
if let Some(b) = flag("transparency")? {
|
||||
st.transparency = b;
|
||||
}
|
||||
match s("matte") {
|
||||
@@ -159,11 +173,12 @@ impl WebSettings {
|
||||
for (k, slot) in [
|
||||
("interlaced", &mut st.interlaced),
|
||||
("progressive", &mut st.progressive),
|
||||
("lossless", &mut st.lossless),
|
||||
("optimized", &mut st.optimized),
|
||||
("embedIcc", &mut st.embed_icc),
|
||||
("convertToSrgb", &mut st.convert_to_srgb),
|
||||
] {
|
||||
if let Some(b) = p.get(k).and_then(Value::as_bool) {
|
||||
if let Some(b) = flag(k)? {
|
||||
*slot = b;
|
||||
}
|
||||
}
|
||||
@@ -217,6 +232,7 @@ impl WebSettings {
|
||||
WebFormat::Png24 => "png24",
|
||||
WebFormat::Jpeg => "jpeg",
|
||||
WebFormat::Wbmp => "wbmp",
|
||||
WebFormat::Webp => "webp",
|
||||
};
|
||||
let palette = match self.palette {
|
||||
PaletteKind::Exact => "exact",
|
||||
@@ -242,7 +258,7 @@ impl WebSettings {
|
||||
WebMetadata::All => "all",
|
||||
};
|
||||
let matte = self.matte.map_or("none".to_string(), |c| format!("#{:02x}{:02x}{:02x}", to8(c[0]), to8(c[1]), to8(c[2])));
|
||||
let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb});
|
||||
let mut p = json!({"format": format, "palette": palette, "colors": self.colors, "dither": dither, "ditherAmount": (self.dither_amount * 100.0).round(), "transparency": self.transparency, "matte": matte, "interlaced": self.interlaced, "webSnap": (self.web_snap * 100.0).round(), "quality": self.quality, "lossless": self.lossless, "progressive": self.progressive, "optimized": self.optimized, "embedIcc": self.embed_icc, "metadata": metadata, "convertToSrgb": self.convert_to_srgb});
|
||||
if let Some(table) = &self.color_table {
|
||||
p["colorTable"] = json!(table.iter().map(|c| rgb_hex(*c)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
@@ -201,7 +201,7 @@ fn estimates_every_format_at_every_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
let mut sizes = Vec::new();
|
||||
for f in ["gif", "png8", "png24", "jpeg", "wbmp"] {
|
||||
for f in ["gif", "png8", "png24", "jpeg", "wbmp", "webp"] {
|
||||
let r = s.execute("file.export.saveForWebLegacy", json!({"format": f, "colors": 16})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(64), Some(48)), "{f}");
|
||||
assert!(r["bytes"].as_u64().unwrap() > 20, "{f}");
|
||||
@@ -299,6 +299,107 @@ fn jpeg_quality_progressive_and_metadata() {
|
||||
assert!(decode(&lo).meta.xmp.is_none());
|
||||
}
|
||||
|
||||
/// WebP (#2415, #6391): lossy at the chosen quality or lossless, keeping translucency unless
|
||||
/// transparency is off, with the chosen metadata and an optional colour profile.
|
||||
#[test]
|
||||
fn webp_lossy_lossless_transparency_and_metadata() {
|
||||
let dir = tmp("webp");
|
||||
let mut s = session(16);
|
||||
s.execute("file.fileInfo", json!({"copyright": "© Example", "description": "secret notes"})).unwrap();
|
||||
s.execute("edit.assignProfile", json!({"profile": "srgb"})).unwrap();
|
||||
let path = |name: &str| format!("{dir}/{name}");
|
||||
let mut export = |name: &str, p: Value| {
|
||||
let mut p = p;
|
||||
p["format"] = json!("webp");
|
||||
p["path"] = json!(path(name));
|
||||
s.execute("file.export.saveForWebLegacy", p).unwrap();
|
||||
std::fs::read(path(name)).unwrap()
|
||||
};
|
||||
let lo = export("lo.webp", json!({"quality": 5}));
|
||||
let hi = export("hi.webp", json!({"quality": 95, "embedIcc": true}));
|
||||
let lossless = export("lossless.webp", json!({"lossless": true, "metadata": "none"}));
|
||||
let opaque = export("opaque.webp", json!({"transparency": false, "matte": "#00ff00"}));
|
||||
for bytes in [&lo, &hi, &lossless, &opaque] {
|
||||
assert_eq!((&bytes[0..4], &bytes[8..12]), (&b"RIFF"[..], &b"WEBP"[..]));
|
||||
}
|
||||
assert!(lo.len() < hi.len(), "quality drives the size: {} vs {}", lo.len(), hi.len());
|
||||
let has = |b: &[u8], fourcc: &[u8]| b.windows(4).any(|w| w == fourcc);
|
||||
assert!(has(&hi, b"VP8 ") && has(&hi, b"ALPH"), "lossy keeps translucency in an alpha chunk");
|
||||
assert!(has(&lossless, b"VP8L") && !has(&lossless, b"VP8 "));
|
||||
assert!(!has(&opaque, b"ALPH"), "transparency off composites over the matte");
|
||||
|
||||
// Lossless is the PNG-24 export's pixels exactly.
|
||||
s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "path": path("same.png")})).unwrap();
|
||||
let (a, b) = (decode(&path("lossless.webp")), decode(&path("same.png")));
|
||||
let rgba = |i: &photocraft_codecs::Image| i.convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8).data().to_vec();
|
||||
assert_eq!(rgba(&a), rgba(&b));
|
||||
// The transparent corner stays transparent; with transparency off it is the matte.
|
||||
assert_eq!(rgba(&a)[3], 0);
|
||||
let o = rgba(&decode(&path("opaque.webp")));
|
||||
assert!(o[3] == 255 && o[1] > 200 && o[0] < 60, "{:?}", &o[..4]);
|
||||
|
||||
let meta = decode(&path("hi.webp"));
|
||||
let xmp = meta.meta.xmp.unwrap_or_default();
|
||||
assert!(xmp.contains("Example") && !xmp.contains("secret"), "copyright only");
|
||||
assert!(meta.icc.is_some(), "Embed Color Profile");
|
||||
assert!(decode(&path("lo.webp")).icc.is_none());
|
||||
assert!(a.meta.xmp.is_none(), "metadata none");
|
||||
}
|
||||
|
||||
/// Sizes WebP cannot hold are an error, not a crash.
|
||||
#[test]
|
||||
fn webp_too_large_is_an_error() {
|
||||
let mut s = session(8);
|
||||
for lossless in [false, true] {
|
||||
let r = s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "width": 20000, "height": 2}));
|
||||
assert!(r.is_err(), "lossless {lossless}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_writer_checks_settings_before_dropping_floating_pixels() {
|
||||
let mut s = session(8);
|
||||
s.execute("select.rect", json!({"x": 8, "y": 8, "width": 4, "height": 4})).unwrap();
|
||||
s.execute("select.float", json!({"dx": 32})).unwrap();
|
||||
let rev = s.active().unwrap().revision;
|
||||
let params = json!({"format": "webp", "path": "moved.webp", "lossless": "yes"});
|
||||
let mut output = Vec::new();
|
||||
let refused = save_for_web_with_writer(&mut s, ¶ms, &mut |_, bytes| {
|
||||
output.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
});
|
||||
assert!(refused.unwrap_err().to_string().contains("lossless"));
|
||||
assert!(output.is_empty());
|
||||
assert!(s.file_menu.last_web.is_none());
|
||||
assert_eq!(s.active().unwrap().revision, rev, "the document is untouched");
|
||||
assert!(crate::float_cmds::floating(s.active().unwrap()).is_some(), "a refused export must not commit a floating move");
|
||||
}
|
||||
|
||||
/// An on/off option that isn't true or false is refused (`"lossless": "true"` would otherwise
|
||||
/// write lossy WebP), and the refused export writes and remembers nothing.
|
||||
#[test]
|
||||
fn non_boolean_flags_are_refused_without_changing_anything() {
|
||||
let dir = tmp("flags");
|
||||
let mut s = session(8);
|
||||
let out = format!("{dir}/out.webp");
|
||||
let flags = ["transparency", "interlaced", "progressive", "lossless", "optimized", "embedIcc", "convertToSrgb"];
|
||||
for k in flags {
|
||||
for v in [json!("true"), json!(1), json!([true])] {
|
||||
let rev = s.active().unwrap().revision;
|
||||
let mut p = json!({"format": "webp", "path": out});
|
||||
p[k] = v.clone();
|
||||
let err = s.execute("file.export.saveForWebLegacy", p).unwrap_err().to_string();
|
||||
assert!(err.contains(k), "{k}: {v}: {err}");
|
||||
assert_eq!(s.active().unwrap().revision, rev, "{k}: the document is untouched");
|
||||
assert!(!std::path::Path::new(&out).exists(), "{k}: nothing written");
|
||||
assert!(s.file_menu.last_web.is_none(), "{k}: nothing remembered");
|
||||
}
|
||||
}
|
||||
// null is the same as leaving it out.
|
||||
s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "path": out, "lossless": null})).unwrap();
|
||||
assert!(std::path::Path::new(&out).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_export_with_html_table() {
|
||||
let dir = tmp("slices");
|
||||
@@ -859,6 +960,9 @@ fn web_settings_from_params() {
|
||||
assert!((st.web_snap - 0.5).abs() < 1e-6);
|
||||
assert!(WebSettings::from_params(&json!({"preset": "Nope"}), "x").is_err());
|
||||
assert!(WebSettings::from_params(&json!({"palette": "weird"}), "x").is_err());
|
||||
let webp = WebSettings::from_params(&json!({"format": "webp", "lossless": true, "quality": 70}), "x").unwrap();
|
||||
assert_eq!((webp.format, webp.lossless, webp.quality), (WebFormat::Webp, true, 70));
|
||||
assert_eq!(WebSettings::from_params(&webp.to_params(), "x").unwrap(), webp);
|
||||
for p in PRESETS {
|
||||
let st = preset_settings(p).unwrap();
|
||||
assert_eq!(WebSettings::from_params(&st.to_params(), "x").unwrap(), st, "{p} round-trips through params");
|
||||
@@ -871,7 +975,7 @@ fn previews_show_the_optimised_pixels() {
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let (wd, _, _) = web_document(&doc, &json!({}), &WebSettings::default()).unwrap();
|
||||
let buf = photocraft_compose::flatten(&wd);
|
||||
for f in ["jpeg", "gif", "png8", "png24", "wbmp"] {
|
||||
for f in ["jpeg", "gif", "png8", "png24", "wbmp", "webp"] {
|
||||
let st = WebSettings::from_params(&json!({"format": f, "quality": 90, "transparency": false}), "x").unwrap();
|
||||
let o = optimize(&buf.px, 64, wd.bounds(), &st, None, None, None, 72.0, true).unwrap();
|
||||
assert_eq!(o.preview.len(), 64 * 48 * 4, "{f}");
|
||||
@@ -1092,6 +1196,33 @@ fn save_for_web_all_writes_the_photos_exif() {
|
||||
assert_eq!(WebSettings::from_params(&json!({"metadata": "allExceptCameraInfo"}), "x").unwrap().to_params()["metadata"], "allExceptCameraInfo");
|
||||
}
|
||||
|
||||
/// WebP holds EXIF in both modes: "All" keeps an EXIF-only photo's camera data (no XMP needed to
|
||||
/// trigger the metadata), every other choice leaves it out.
|
||||
#[test]
|
||||
fn save_for_web_webp_all_keeps_an_exif_only_photos_exif() {
|
||||
let dir = tmp("webp_exif");
|
||||
let mut s = session(8);
|
||||
s.edit("exif", |doc, _| {
|
||||
doc.metadata.exif = Some(std::sync::Arc::new(camera_exif()));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for lossless in [false, true] {
|
||||
for metadata in ["all", "none", "copyright", "allExceptCameraInfo"] {
|
||||
let path = format!("{dir}/{metadata}-{lossless}.webp");
|
||||
s.execute("file.export.saveForWebLegacy", json!({"format": "webp", "lossless": lossless, "metadata": metadata, "path": path})).unwrap();
|
||||
let meta = decode(&path).meta;
|
||||
if metadata == "all" {
|
||||
let exif = meta.exif.unwrap_or_else(|| panic!("EXIF kept (lossless {lossless})"));
|
||||
assert_eq!(photocraft_algo::exif::read(&exif).make.as_deref(), Some("TestMake"), "lossless {lossless}");
|
||||
assert!(meta.xmp.is_none(), "the photo had no XMP");
|
||||
} else {
|
||||
assert!(meta.exif.is_none(), "{metadata} leaves the camera data out (lossless {lossless})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_export_all_writes_the_photos_exif() {
|
||||
let dir = tmp("quick_exif");
|
||||
|
||||
@@ -49,6 +49,18 @@ pub struct KysSet {
|
||||
pub windows: bool,
|
||||
}
|
||||
|
||||
/// The set name in the root `filename` attribute (`$$$/.../{name}={name}`). The name may itself
|
||||
/// contain `/` or `=`, so split on the key prefix and the doubled name, not on the last separator.
|
||||
fn filename_to_name(f: &str) -> &str {
|
||||
let Some(rest) = f.strip_prefix("$$$/") else { return f.rsplit(['=', '/']).next().unwrap_or(f).trim() };
|
||||
let rest = rest.strip_prefix("FileName/Presets/KeyboardCustomization/").unwrap_or(rest);
|
||||
let mid = rest.len() / 2;
|
||||
if rest.len() % 2 == 1 && rest.is_char_boundary(mid) && rest.as_bytes()[mid] == b'=' && rest[..mid] == rest[mid + 1..] {
|
||||
return rest[..mid].trim();
|
||||
}
|
||||
rest.split_once('=').map_or(rest, |(_, n)| n).trim()
|
||||
}
|
||||
|
||||
/// Parse a `.kys` file (or Photoshop's live `Keyboard Shortcuts.psp`, the same XML). Rows
|
||||
/// without a name or a shortcut are skipped.
|
||||
pub fn parse(text: &str) -> Result<KysSet, String> {
|
||||
@@ -61,7 +73,7 @@ pub fn parse(text: &str) -> Result<KysSet, String> {
|
||||
if root.tag_name().name() != "photoshop-keyboard-shortcuts" {
|
||||
return Err("not a keyboard shortcut set (.kys): no <photoshop-keyboard-shortcuts> root".into());
|
||||
}
|
||||
let name = root.attribute("filename").map(|f| f.rsplit(['=', '/']).next().unwrap_or(f).trim()).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = root.attribute("filename").map(filename_to_name).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let mut set = KysSet { name, ..Default::default() };
|
||||
for node in root.children().filter(|n| n.is_element()) {
|
||||
match node.tag_name().name() {
|
||||
@@ -119,6 +131,7 @@ pub fn to_ours(set: &KysSet, sc: &str) -> Option<String> {
|
||||
}
|
||||
let (mods, key) = match sc.strip_suffix("++") {
|
||||
Some(m) => (m, "+"),
|
||||
None if sc == "+" => ("", "+"),
|
||||
None => sc.rsplit_once('+').unwrap_or(("", sc)),
|
||||
};
|
||||
let mods: Vec<&str> = mods
|
||||
@@ -262,6 +275,7 @@ pub fn photoshop_key(sc: &str) -> Option<String> {
|
||||
// The key is the last part; `Cmd++` ends in an empty part, so take it from the string.
|
||||
let (mods, key) = match n.strip_suffix("++") {
|
||||
Some(m) => (m, "+"),
|
||||
None if n == "+" => ("", "+"),
|
||||
None => n.rsplit_once('+').unwrap_or(("", n.as_str())),
|
||||
};
|
||||
let has = |m: &str| mods.split('+').any(|x| x == m);
|
||||
@@ -309,6 +323,13 @@ pub fn write(name: &str, entries: &[Entry]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn set_name_with_separators_round_trips() {
|
||||
for n in ["Retouch 1/2", "a=b", "x/y=z"] {
|
||||
assert_eq!(parse(&write(n, &[])).unwrap().name.as_deref(), Some(n));
|
||||
}
|
||||
}
|
||||
|
||||
const SET: &str = "\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<photoshop-keyboard-shortcuts version=\"4\" filename=\"$$$/FileName/Presets/KeyboardCustomization/texcuts=texcuts\">
|
||||
\t<command kind=\"static\" name=\"Levels...\" id=\"1801\"><shortcut>Cmd+L</shortcut></command>
|
||||
@@ -323,6 +344,28 @@ mod tests {
|
||||
Candidate { id: id.into(), label: label.into(), default: default.map(str::to_string) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_plus_key_is_kept_when_saving() {
|
||||
assert_eq!(photoshop_key("+").as_deref(), Some("+"));
|
||||
assert_eq!(photoshop_key("Cmd++").as_deref(), Some("Cmd++"));
|
||||
let e = Entry { id: "tools.zoom".into(), label: "Zoom Tool".into(), tool: true, keys: vec!["+".into()] };
|
||||
assert!(write("Mine", &[e]).contains(">+</tool>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_plus_command_survives_a_windows_set_with_a_ctrl_row() {
|
||||
let xml = "<photoshop-keyboard-shortcuts>\
|
||||
<command name=\"Copy\"><shortcut>Ctrl+C</shortcut></command>\
|
||||
<command name=\"Zoom In\"><shortcut>+</shortcut></command>\
|
||||
</photoshop-keyboard-shortcuts>";
|
||||
let set = parse(xml).unwrap();
|
||||
assert!(set.windows);
|
||||
assert_eq!(to_ours(&set, "+").as_deref(), Some("+"));
|
||||
let p = plan([c("edit.copy", "Copy", Some("Cmd+C")), c("view.zoomIn", "Zoom In", None)], &set);
|
||||
assert_eq!(p.set.get("view.zoomIn").map(String::as_str), Some("+"));
|
||||
assert!(p.unreadable.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_match_by_label_and_the_default_holder_wins_a_shared_label() {
|
||||
let set = parse(SET).unwrap();
|
||||
|
||||
@@ -265,8 +265,9 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
|
||||
assert_eq!(mask.value(10, 10), 1.0, "{id} at {depth}-bit: the mask hides the selected area");
|
||||
assert_eq!(mask.value(2, 2), 0.0, "{id} at {depth}-bit: the mask reveals outside the selection");
|
||||
assert!(mask.linked && mask.enabled, "{id}");
|
||||
// The selection stays, as after Layer › Layer Mask › Reveal Selection.
|
||||
assert!(d.doc.selection.is_some(), "{id} dropped the selection");
|
||||
// The selection went into the mask, so Photoshop drops it, as Layer › Layer Mask ›
|
||||
// Reveal Selection does (#5251, #8801).
|
||||
assert!(d.doc.selection.is_none(), "{id} at {depth}-bit kept the selection");
|
||||
// The white canvas is untouched outside the selection.
|
||||
assert_eq!(px(&mut s, 2, 2), vec![1.0, 1.0, 1.0, 1.0], "{id} at {depth}-bit changed pixels outside the selection");
|
||||
if blackens {
|
||||
@@ -274,6 +275,29 @@ fn new_adjustment_and_fill_layers_are_masked_by_the_selection() {
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.layer(layer).is_none(), "{id}: undo keeps the layer");
|
||||
let sel = s.active().unwrap().doc.selection.as_ref().unwrap_or_else(|| panic!("{id}: undo does not bring the selection back"));
|
||||
assert_eq!(sel.content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "{id}: undo restores the same selection");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_after_a_new_fill_layer_on_a_selection_reaches_the_whole_layer() {
|
||||
// #8801: the selection that masked a new fill or adjustment layer is gone, so the next
|
||||
// pixel edit on another layer is not limited to it.
|
||||
for (id, params) in [("layer.newFillLayer.solidColor", json!({"color": "#000000"})), ("layer.newAdjustmentLayer.invert", json!({}))] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#3366cc"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
s.execute(id, params).unwrap();
|
||||
s.execute("layer.setProps", json!({"visible": false})).unwrap();
|
||||
s.execute("layer.selectBottom", json!({})).unwrap();
|
||||
s.execute("layer.duplicate", json!({})).unwrap();
|
||||
s.execute("image.adjustments.desaturate", json!({})).unwrap();
|
||||
for x in [5, 35] {
|
||||
let p = px(&mut s, x, 10);
|
||||
assert!((p[0] - p[1]).abs() < 1e-3 && (p[1] - p[2]).abs() < 1e-3, "{id}: ({x}, 10) is not grey: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,6 +375,7 @@ fn new_fill_and_adjustment_layers_take_the_active_path_as_their_vector_mask() {
|
||||
let want = if path_name == "work" { d.doc.work_path.clone().unwrap() } else { d.doc.paths[0].path.clone() };
|
||||
assert_eq!(vm.path, want, "{id}: the vector mask is the path");
|
||||
assert!(l.mask.is_none(), "{id}: the path wins over the selection");
|
||||
assert!(d.doc.selection.is_some(), "{id}: an unused selection stays");
|
||||
for (x, y) in [(2, 2), (42, 32), (30, 30)] {
|
||||
assert_eq!(px(&mut s, x, y), vec![1.0, 1.0, 1.0, 1.0], "{id} ({path_name}) at {depth}-bit changed pixels outside the path at ({x}, {y})");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Cross-PR acceptance checks for the merged parity batch: each test pins one fix through the
|
||||
//! public command surface, so a bad merge between the PRs shows up here.
|
||||
use photocraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session(width: u32, height: u32) -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", json!({"width": width, "height": height})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_fill_layer_consumes_the_selection_it_took_as_mask() {
|
||||
let mut s = session(32, 24);
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 8})).unwrap();
|
||||
s.execute("layer.newFillLayer.solidColor", json!({"color": "#4080c0"})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn define_pattern_accepts_a_canvas_wider_than_the_old_4000_px_cap() {
|
||||
let mut s = session(4500, 8);
|
||||
s.execute("edit.definePattern", json!({"name": "Wide"})).unwrap();
|
||||
let mut s = session(8001, 8);
|
||||
assert!(s.execute("edit.definePattern", json!({"name": "TooWide"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_blur_accepts_radii_below_one_without_failing() {
|
||||
for radius in [0.1, 0.3, 0.7, 1.0] {
|
||||
let mut s = session(24, 16);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": radius})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,10 @@ fn newly_created_layer_masks_follow_document_depth() {
|
||||
assert_eq!(m.value(20, 20), white);
|
||||
if selected {
|
||||
assert_eq!(m.value(0, 0), 0.0);
|
||||
if command == "layer.layerMask.revealSelection" {
|
||||
// The selection became the mask: Photoshop drops it, as Hide Selection does.
|
||||
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
|
||||
} else {
|
||||
assert_eq!(d.doc.selection, before.selection, "the selection is unchanged");
|
||||
}
|
||||
// The selection became the mask: Photoshop drops it, as Hide Selection does
|
||||
// (#5251; new fill and adjustment layers too, #8801).
|
||||
assert!(d.doc.selection.is_none(), "{command}: the selection is dropped");
|
||||
assert!(before.selection.is_some());
|
||||
} else {
|
||||
assert_eq!(m.surface.tile_count(), 0, "uniform masks stay sparse");
|
||||
assert_eq!(m.value(-300_000, 300_000), white);
|
||||
@@ -72,7 +70,6 @@ fn soft_mask_edits_match_after_native_and_psd_reopen() {
|
||||
let mut s = session(depth, "rgb");
|
||||
s.execute("select.rect", json!({"x": 8, "y": 6, "width": 40, "height": 32, "antiAlias": false})).unwrap();
|
||||
s.execute("layer.newFillLayer.solidColor", json!({"color": "#000000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let native = photocraft_format::save_to_bytes(&before, &Default::default()).unwrap();
|
||||
let psd = photocraft_io::export(&before, "psd", &Default::default()).unwrap();
|
||||
@@ -138,7 +135,8 @@ fn mask_creation_preserves_selection_coverage_and_existing_masks() {
|
||||
before.selection.as_ref().unwrap().read_region(before.bounds()),
|
||||
"{depth}-bit coverage is unchanged"
|
||||
);
|
||||
let kept = if command == "layer.layerMask.revealSelection" { None } else { before.selection.clone() };
|
||||
// The selection went into the mask, except for the Gradient tool's fill layer.
|
||||
let kept = if command == "gradient.fill.create" { before.selection.clone() } else { None };
|
||||
assert_eq!(after.selection, kept, "{command}");
|
||||
assert_eq!(after.layers[0].mask, before.layers[0].mask, "creating a mask does not requantize an existing mask");
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ mod guide_layout_limit;
|
||||
mod guide_layout_preview;
|
||||
mod hdr_gamma_direction;
|
||||
mod huge_doc_flatten;
|
||||
mod integration_batch;
|
||||
mod jpeg_exif_resolution;
|
||||
mod keyboard_shortcut_bulk;
|
||||
mod kra;
|
||||
|
||||
@@ -101,7 +101,6 @@ fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() {
|
||||
let (mut s, a, _) = scene(mode, depth);
|
||||
s.execute("select.rect", json!({"x": B[0], "y": B[1], "width": B[2], "height": B[3]})).unwrap();
|
||||
let fill = id_of(&s.execute("layer.newFillLayer.solidColor", json!({"color": "#3060c0"})).unwrap());
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
(s, a, fill)
|
||||
};
|
||||
for &(mode, depth) in CASES {
|
||||
|
||||
@@ -34,19 +34,26 @@ fn background_save_outcomes_report_failure_then_recovery() {
|
||||
let root = temp_dir("retry-failed-write");
|
||||
let blocked = root.join("recovery");
|
||||
std::fs::write(&blocked, b"not a directory").unwrap();
|
||||
let saver = Autosaver::new(&blocked, "doc");
|
||||
// Not dropped on a failure: dropping joins the worker, which waits for a write in progress,
|
||||
// so a stuck write would hold the test past its own bound until nextest kills it.
|
||||
let saver = std::mem::ManuallyDrop::new(Autosaver::new(&blocked, "doc"));
|
||||
let doc = Arc::new(rich_doc(ColorMode::Rgb, SampleType::U8));
|
||||
saver.request_checked(doc.clone(), 7, None, SaveOptions::default()).unwrap();
|
||||
|
||||
// The bound only turns a worker that never reports into a failure instead of a hang. A real
|
||||
// write flushes every tile to disk and took over 30 s on a busy Windows CI runner (normally
|
||||
// 2-3 s), so it is generous, and still under the 10 minutes after which nextest's `ci`
|
||||
// profile kills a test.
|
||||
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
let receive = |saver: &Autosaver| {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let deadline = std::time::Instant::now() + PATIENCE;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if let Some(outcome) = saver.take_completed().into_iter().next() {
|
||||
return outcome;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("autosave worker did not report its completed write within 30 seconds");
|
||||
panic!("autosave worker did not report its completed write within {PATIENCE:?}");
|
||||
};
|
||||
let (revision, result) = receive(&saver);
|
||||
assert_eq!(revision, 7);
|
||||
@@ -60,7 +67,7 @@ fn background_save_outcomes_report_failure_then_recovery() {
|
||||
result.unwrap();
|
||||
assert_eq!(list_recovery(&blocked).len(), 1);
|
||||
assert_eq!(recover(&list_recovery(&blocked)[0]).unwrap(), *doc);
|
||||
drop(saver);
|
||||
drop(std::mem::ManuallyDrop::into_inner(saver));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1116,15 +1116,10 @@ fn wrap(i: i32, n: i32) -> i32 {
|
||||
return ((i % n) + n) % n;
|
||||
}
|
||||
|
||||
// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin),
|
||||
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA.
|
||||
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
|
||||
let dx = f32(d.x) + 0.5 - op.p3.x;
|
||||
let dy = f32(d.y) + 0.5 - op.p3.y;
|
||||
let c = op.p3.z;
|
||||
let s = op.p3.w;
|
||||
let u = (dx * c - dy * s) * op.p4.x - 0.5;
|
||||
let v = (dx * s + dy * c) * op.p4.x - 0.5;
|
||||
// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul).
|
||||
fn pattern_tap(u0: f32, v0: f32) -> vec4<f32> {
|
||||
let u = u0 - 0.5;
|
||||
let v = v0 - 0.5;
|
||||
let x0 = floor(u);
|
||||
let y0 = floor(v);
|
||||
let fx = u - x0;
|
||||
@@ -1143,6 +1138,28 @@ fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
|
||||
acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin),
|
||||
// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel
|
||||
// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU.
|
||||
fn pattern_sample(d: vec2<i32>) -> vec4<f32> {
|
||||
var n = 1;
|
||||
if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); }
|
||||
let c = op.p3.z;
|
||||
let s = op.p3.w;
|
||||
var acc = vec4(0.0);
|
||||
for (var j = 0; j < n; j++) {
|
||||
for (var i = 0; i < n; i++) {
|
||||
let ox = (f32(i) + 0.5) / f32(n) - 0.5;
|
||||
let oy = (f32(j) + 0.5) / f32(n) - 0.5;
|
||||
let dx = f32(d.x) + 0.5 + ox - op.p3.x;
|
||||
let dy = f32(d.y) + 0.5 + oy - op.p3.y;
|
||||
acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x);
|
||||
}
|
||||
}
|
||||
acc = acc / f32(n * n);
|
||||
if (acc.a <= 0.0) { return vec4(0.0); }
|
||||
return vec4(acc.rgb / acc.a, min(acc.a, 1.0));
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ pub const RESIDENT_BUDGET: u64 = 4 << 30;
|
||||
/// GPU memory pattern textures may hold before the least recently painted are dropped (those the
|
||||
/// current frame paints with always stay).
|
||||
pub const PATTERN_BUDGET: u64 = 256 << 20;
|
||||
/// The most a single pattern's RGBA32F texture may take. A pattern over it paints on the CPU, which
|
||||
/// holds a large 8-bit pattern at 4 bytes a pixel instead of 16 (`Pattern::working_bytes`).
|
||||
pub(crate) const MAX_PATTERN_TEXTURE_BYTES: u64 = 256 << 20;
|
||||
|
||||
/// A pattern texture no frame has painted with for this many frames is dropped.
|
||||
pub const PATTERN_IDLE_FRAMES: u64 = 240;
|
||||
|
||||
+18
-5
@@ -1,5 +1,5 @@
|
||||
use super::batch::tile_grid;
|
||||
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
|
||||
use super::consts::{ACC_FORMAT, CHUNK, CHUNK_UNIFORM, FX_BUDGET, MAP16, MAP32, MAX_PATTERN_TEXTURE_BYTES, OP_UNIFORM, PAGE, PATTERN_BUDGET, RESIDENT_BUDGET};
|
||||
use super::kit::{Kit, SHADER, tex_entry, uniform_entry};
|
||||
use super::paging::{cell_rect, field_reaches, first_error, grid_rects};
|
||||
use super::textures::{FxEntry, Resident, Tex, TexKind};
|
||||
@@ -372,19 +372,32 @@ impl Compositor {
|
||||
if s.lut.as_ref().is_some_and(|l| l.len() > 4096) {
|
||||
return Err(Unsupported(format!("effect blur on `{}` too wide for the GPU path", f.layer.name)));
|
||||
}
|
||||
if let Some(pat) = &s.pattern {
|
||||
self.check_pattern(pat)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for pass in &p.passes {
|
||||
if let Some(pat) = pass.pattern
|
||||
&& (pat.width > self.device_max || pat.height > self.device_max)
|
||||
{
|
||||
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
|
||||
if let Some(pat) = pass.pattern {
|
||||
self.check_pattern(pat)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A pattern's texture must fit the device and the per-pattern cap; a larger one renders on
|
||||
/// the CPU compositor.
|
||||
fn check_pattern(&self, pat: &photocraft_doc::Pattern) -> Result<(), Unsupported> {
|
||||
if pat.width > self.device_max || pat.height > self.device_max {
|
||||
return Err(Unsupported(format!("pattern `{}` larger than the GPU texture limit", pat.name)));
|
||||
}
|
||||
if u64::from(pat.width) * u64::from(pat.height) * 16 > MAX_PATTERN_TEXTURE_BYTES {
|
||||
return Err(Unsupported(format!("pattern `{}` too large for a GPU texture; the CPU path paints it", pat.name)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop GPU textures of a closed document.
|
||||
pub fn forget_doc(&mut self, doc: DocId) {
|
||||
self.residents.retain(|_, r| r.doc != doc);
|
||||
|
||||
@@ -2609,6 +2609,27 @@ fn closed_documents_release_their_pattern_textures() {
|
||||
check(&mut g, &a, "the pattern is uploaded again after being forgotten");
|
||||
}
|
||||
|
||||
/// A pattern whose RGBA32F texture would pass the per-pattern cap (Define Pattern accepts 8-bit
|
||||
/// ones up to 8000 x 8000) is declined by the GPU path and painted by the CPU compositor, which
|
||||
/// holds a large 8-bit pattern at a quarter of the texture's size.
|
||||
#[test]
|
||||
fn a_pattern_too_large_for_a_gpu_texture_falls_back_to_the_cpu() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = Document::new("big pattern", Size::new(8, 6), ColorMode::Rgb, SampleType::U8);
|
||||
// 100 MB as RGBA8, 400 MB as an RGBA32F texture.
|
||||
let surface = photocraft_raster::Surface::with_default(PixelFormat::RGBA8, &[1.0, 0.0, 0.0, 1.0]);
|
||||
let pat = Pattern { id: "big".into(), name: "big".into(), width: 5000, height: 5000, surface };
|
||||
assert!(pat.is_paintable());
|
||||
d.layers.push(Layer::new(
|
||||
"pattern",
|
||||
LayerContent::Fill(Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale: 1.0, angle: 0.0, link: true, phase: (0.0, 0.0) }),
|
||||
));
|
||||
d.patterns.push(pat);
|
||||
assert!(render_to_vec(&mut g.comp, &g.device, &g.queue, &d, d.bounds()).is_err(), "the GPU path declines it");
|
||||
assert_eq!(g.comp.pattern_cache_bytes(), 0, "nothing was uploaded");
|
||||
assert!(photocraft_compose::flatten(&d).px.iter().all(|p| *p == [1.0, 0.0, 0.0, 1.0]), "the CPU compositor paints it");
|
||||
}
|
||||
|
||||
/// #4971: pattern textures are bounded while their documents stay open, by a byte budget and
|
||||
/// by age. What the current frame paints with is never dropped.
|
||||
#[test]
|
||||
@@ -2688,7 +2709,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() {
|
||||
fn pattern_fill_layers() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let pat = checker_pattern();
|
||||
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] {
|
||||
for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] {
|
||||
let mut d = base_doc(70, 50);
|
||||
d.patterns.push(pat.clone());
|
||||
let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) };
|
||||
|
||||
@@ -69,9 +69,15 @@ const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
|
||||
(BlendMode::Luminosity, "luminosity"),
|
||||
];
|
||||
|
||||
/// A blend-mode enum from either its four-character code or its long string ID.
|
||||
/// A blend-mode enum from its four-character code, its long string ID, or the layer record's key:
|
||||
/// Photoshop 2026 writes `vLit`, `lddg`, `lbrn`… as an effect's `Md ` for some modes (#8567).
|
||||
pub(crate) fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
|
||||
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
|
||||
BLEND_NAMES
|
||||
.iter()
|
||||
.chain(&BLEND_LONG_NAMES)
|
||||
.find(|(_, n)| n.as_bytes() == v)
|
||||
.map(|(m, _)| *m)
|
||||
.or_else(|| <[u8; 4]>::try_from(v).ok().and_then(BlendMode::from_psd_key))
|
||||
}
|
||||
|
||||
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
|
||||
|
||||
@@ -198,6 +198,35 @@ fn blend_names_are_unique() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_record_blend_keys_parse() {
|
||||
for (key, mode) in [
|
||||
(b"vLit", BlendMode::VividLight),
|
||||
(b"lLit", BlendMode::LinearLight),
|
||||
(b"pLit", BlendMode::PinLight),
|
||||
(b"hMix", BlendMode::HardMix),
|
||||
(b"lbrn", BlendMode::LinearBurn),
|
||||
(b"lddg", BlendMode::LinearDodge),
|
||||
(b"dkCl", BlendMode::DarkerColor),
|
||||
(b"lgCl", BlendMode::LighterColor),
|
||||
(b"fsub", BlendMode::Subtract),
|
||||
(b"fdiv", BlendMode::Divide),
|
||||
] {
|
||||
assert_eq!(blend_from_id(key), Some(mode), "{}", String::from_utf8_lossy(key));
|
||||
}
|
||||
assert_eq!(blend_from_id(b"zzzz"), None);
|
||||
// The issue's Color Overlay: Vivid Light stored as `vLit`, not left as Normal.
|
||||
let key = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
|
||||
let d = Descriptor::new("null")
|
||||
.with("masterFXSwitch", Value::Boolean(true))
|
||||
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("enab", Value::Boolean(true)).with("Md ", key("vLit"))));
|
||||
let mut data = 0u32.to_be_bytes().to_vec();
|
||||
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
|
||||
let (_, fx) = parse_lfx2(&data).unwrap();
|
||||
let modes: Vec<BlendMode> = fx.iter().filter_map(|e| if let Effect::ColorOverlay { common, .. } = e { Some(common.blend) } else { None }).collect();
|
||||
assert_eq!(modes, vec![BlendMode::VividLight]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_string_blend_ids_parse() {
|
||||
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
|
||||
|
||||
@@ -110,6 +110,12 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
|
||||
}
|
||||
}
|
||||
|
||||
/// A warning for each of `patterns` too large to paint: such a pattern fills with transparency,
|
||||
/// so the file says so on opening.
|
||||
pub fn unpaintable_warnings(patterns: &[Pattern]) -> Vec<String> {
|
||||
patterns.iter().filter_map(|p| Some(format!("pattern \"{}\" would paint nothing: {}", p.display_name(), p.paint_problem()?))).collect()
|
||||
}
|
||||
|
||||
/// Patterns decoded from the document's raw pattern blocks (malformed blocks are skipped). All
|
||||
/// blocks share one memory budget, so repeating a block can't multiply it.
|
||||
pub fn from_global_blocks(doc: &Document) -> Vec<Pattern> {
|
||||
@@ -278,3 +284,26 @@ mod tests {
|
||||
assert_eq!(export_global_blocks(&back), back.metadata.psd_global_blocks);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unpaintable_tests {
|
||||
use super::*;
|
||||
use photocraft_doc::PixelFormat;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
#[test]
|
||||
fn a_pattern_too_large_to_paint_is_named_on_opening() {
|
||||
let sparse = |name: &str, format: PixelFormat, side: u32| Pattern {
|
||||
id: name.into(),
|
||||
name: name.into(),
|
||||
width: side,
|
||||
height: side,
|
||||
surface: Surface::new(format),
|
||||
};
|
||||
let patterns =
|
||||
[sparse("Small", PixelFormat::RGBA8, 64), sparse("Huge float", PixelFormat::RGBA32F, 5000), sparse("Largest 8-bit", PixelFormat::RGBA8, 8000)];
|
||||
let warnings = unpaintable_warnings(&patterns);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(warnings[0].contains("Huge float") && warnings[0].contains("would paint nothing") && warnings[0].contains("32 bits"), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,6 +1122,7 @@ fn read_initial_metadata(file: &PsdFile, global: &[PsdGlobalBlock], doc: &mut Do
|
||||
));
|
||||
}
|
||||
doc.patterns = crate::pattern_map::from_global_blocks(doc);
|
||||
warnings.extend(crate::pattern_map::unpaintable_warnings(&doc.patterns));
|
||||
doc.notes = crate::annotations_map::notes_from_blocks(doc);
|
||||
if crate::annotations_map::anno_unreadable(doc) {
|
||||
warnings.push("the notes (Anno block) could not be read; they are kept unchanged on PSD save unless a note is added".into());
|
||||
|
||||
@@ -154,6 +154,19 @@ fn halftone_pattern_unknown_nested_class_stays_verbatim() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a Gallery stack opaque when its own descriptor holds a setting beside the effect list,
|
||||
/// which a save would otherwise drop.
|
||||
#[test]
|
||||
fn halftone_pattern_unknown_outer_setting_stays_verbatim() {
|
||||
let valid = native_effect("ScrD", 2, 5);
|
||||
let gallery = Descriptor::new(GALLERY_CLASS).with("GEfs", Value::List(vec![Value::Descriptor(valid)])).with("futureSetting", Value::Integer(7));
|
||||
let item = native_item(gallery);
|
||||
let parsed = Descriptor::from_bytes(&item.to_bytes()).unwrap();
|
||||
let filter = filter_from_item(&parsed);
|
||||
assert_eq!(filter.command, UNSUPPORTED_FILTER);
|
||||
assert_eq!(item_for_filter(&filter).unwrap().to_bytes(), item.to_bytes());
|
||||
}
|
||||
|
||||
/// Rejects unsupported export values instead of silently changing their meaning.
|
||||
#[test]
|
||||
fn halftone_pattern_invalid_export_settings_are_reported() {
|
||||
|
||||
@@ -710,7 +710,11 @@ fn gallery_params(item: &Descriptor, f: &Descriptor) -> Option<J> {
|
||||
let mut params = Map::new();
|
||||
if g.key == "halftonePattern" {
|
||||
// Do not turn an unfamiliar shape or malformed setting into the engine's defaults:
|
||||
// keeping the whole item opaque preserves it through a subsequent save.
|
||||
// keeping the whole item opaque preserves it through a subsequent save. That covers
|
||||
// the stack's own descriptor too: a key beside `GEfs` would not be written back.
|
||||
if f.get("GEfs").is_some() && !(f.class_id.is(GALLERY_CLASS) && only_keys(f, &["GEfs"])) {
|
||||
return None;
|
||||
}
|
||||
if !e.class_id.is(GALLERY_CLASS)
|
||||
|| !only_keys(e, &["GEfk", "HlSz", "Cntr", "ScrT", "GELv"])
|
||||
|| !matches!(e.get("GEfk"), Some(Value::Enumerated { type_id, .. }) if type_id.is("GEft"))
|
||||
|
||||
@@ -199,14 +199,22 @@ type Reopened = (String, Option<i32>, Vec<String>);
|
||||
/// `TextIndex`, bottom first), with a type layer "Hi" made the way the Type tool makes one
|
||||
/// (`build_tysh`, no index of its own) inserted at `at`, saved and reopened: each layer's
|
||||
/// index and font features (but `calt`, on by default and written on as Photoshop does), and
|
||||
/// "Hi"'s style run in the saved `Txt2`.
|
||||
/// "Hi"'s style run in the saved `Txt2`. The kept layers are styled as their objects are (font
|
||||
/// and size too), so their objects are saved unchanged, which this checks.
|
||||
fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) -> (Vec<Reopened>, E) {
|
||||
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||||
for (text, index) in kept {
|
||||
let mut t = layer(text, &[(text.len(), Kerning::Metrics, 0.0)]);
|
||||
// `photoshop_txt2` writes MyriadPro-Regular at 12 pt (the document is at 72 dpi).
|
||||
t.runs[0].style.size_pt = 12.0;
|
||||
t.runs[0].style.postscript_name = Some("MyriadPro-Regular".into());
|
||||
t.sync_summary();
|
||||
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
|
||||
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
|
||||
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, *index).unwrap()));
|
||||
let raw = photocraft_text::psd::set_text_index(&photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None), *index).unwrap();
|
||||
// As opening the file does: a kept layer's style holds what its `Txt2` object says (#8415:
|
||||
// a layer whose style disagrees with its object is saved as a style edit).
|
||||
photocraft_text::psd::apply_txt2(&mut t, &raw, &photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap());
|
||||
t.psd_raw = Some(std::sync::Arc::new(raw));
|
||||
doc.layers.push(Layer::new(*text, LayerContent::Text(t)));
|
||||
}
|
||||
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
|
||||
@@ -228,6 +236,11 @@ fn add_type_layer(objects: &[(&str, &str)], kept: &[(&str, i32)], at: usize) ->
|
||||
(l.name.clone(), index, t.char_runs().iter().flat_map(|r| r.style.features.iter().filter(|f| f.tag != "calt").map(|f| f.tag.clone())).collect())
|
||||
})
|
||||
.collect();
|
||||
let source = photocraft_text::psd::parse_txt2(&photoshop_txt2(objects)).unwrap();
|
||||
let object = |txt2: &E, i: i32| txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(usize::try_from(i).ok()?)).cloned();
|
||||
for (text, index) in kept {
|
||||
assert_eq!(object(&txt2, *index), object(&source, *index), "{text}: the kept layer's object is saved unchanged");
|
||||
}
|
||||
let hi = usize::try_from(layers.iter().find(|l| l.0 == "Hi").and_then(|l| l.1).unwrap()).unwrap();
|
||||
let run = txt2.path(&["1", "1"]).and_then(E::as_array).unwrap()[hi].path(&["0", "6", "0"]).and_then(E::as_array).unwrap()[0].clone();
|
||||
(layers, run)
|
||||
|
||||
@@ -315,3 +315,65 @@ fn created_text_layer_roundtrips_through_psd() {
|
||||
assert_eq!(bt.transform, t.transform);
|
||||
assert!(bt.cache.as_ref().is_some_and(|c| c.content_bounds().width() > 10));
|
||||
}
|
||||
|
||||
/// Review of #8888: the older Photoshop's text objects (`/DocumentObjects /TextObjects`) in the
|
||||
/// real psd-tools `text.psd`. Saving the type layer unedited leaves them (and the font list they
|
||||
/// index) exactly as the file has them; editing only the size changes the size of the legacy style
|
||||
/// run and nothing else in it, and the object's text, paragraph runs and view are untouched.
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn corpus_text_psd_legacy_objects_are_edited_in_place() {
|
||||
use photocraft_text::engine_data::Value as E;
|
||||
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/text.psd");
|
||||
assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let doc = photocraft_io::import("text.psd", &bytes).unwrap().document;
|
||||
let txt2 = doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone()).expect("text.psd has a Txt2 block");
|
||||
let mut all = Vec::new();
|
||||
walk(&doc.layers, &mut all);
|
||||
let layer = all.iter().find_map(|l| if let LayerContent::Text(t) = &l.content { Some(t.clone()) } else { None }).expect("a type layer");
|
||||
|
||||
let before = photocraft_text::psd::parse_txt2(&txt2).expect("the file's Txt2 parses");
|
||||
let legacy = |block: &E| block.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).cloned().expect("the legacy object");
|
||||
let features = |entry: &E| {
|
||||
entry
|
||||
.path(&["Model", "StyleRun", "RunArray"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(<[E]>::first)
|
||||
.and_then(|r| r.path(&["RunData", "StyleSheet", "Features"]))
|
||||
.cloned()
|
||||
};
|
||||
let items = |value: &E| if let E::Dict(items) = value { items.clone() } else { Vec::new() };
|
||||
|
||||
// Unedited: the legacy object and the font list are as the file has them.
|
||||
let unedited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &layer)], Some(&txt2), doc.resolution_dpi)).unwrap();
|
||||
assert!(legacy(&unedited) == legacy(&before), "an unedited layer leaves the legacy text object alone");
|
||||
assert!(unedited.path(&["DocumentResources", "FontSet"]) == before.path(&["DocumentResources", "FontSet"]), "and the font list it indexes");
|
||||
|
||||
// Edited: only the size.
|
||||
let mut bigger = layer.clone();
|
||||
for run in &mut bigger.runs {
|
||||
run.style.size_pt += 7.0;
|
||||
}
|
||||
let edited = photocraft_text::psd::parse_txt2(&photocraft_text::psd::build_txt2_at(&[(0, &bigger)], Some(&txt2), doc.resolution_dpi)).unwrap();
|
||||
let (was, now) = (features(&legacy(&before)).expect("features"), features(&legacy(&edited)).expect("features"));
|
||||
let size = |f: &E| f.get("FontSize").and_then(E::as_f64).expect("FontSize");
|
||||
assert!((size(&now) - size(&was) - 7.0).abs() < 1e-6, "the legacy size follows the edit: {} -> {}", size(&was), size(&now));
|
||||
let rest = |f: &E| items(f).into_iter().filter(|(k, _)| k != "FontSize").collect::<Vec<_>>();
|
||||
let changed: Vec<String> =
|
||||
rest(&was).iter().zip(rest(&now).iter()).filter(|(a, b)| a != b).map(|(a, b)| format!("{}: {:?} -> {:?}", a.0, a.1, b.1)).collect();
|
||||
assert!(rest(&was) == rest(&now), "every other style key of the run is as the file has it; changed: {changed:?}");
|
||||
let others = |entry: &E, key: &str| entry.path(&["Model", key]).cloned();
|
||||
for key in ["Text", "ParagraphRun", "KernRun"] {
|
||||
assert!(others(&legacy(&edited), key) == others(&legacy(&before), key), "{key} is untouched");
|
||||
}
|
||||
assert!(legacy(&edited).get("View") == legacy(&before).get("View"), "the view is untouched");
|
||||
let lengths = |entry: &E| {
|
||||
entry
|
||||
.path(&["Model", "StyleRun", "RunArray"])
|
||||
.and_then(E::as_array)
|
||||
.map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64)).collect::<Vec<_>>())
|
||||
};
|
||||
assert!(lengths(&legacy(&edited)) == lengths(&legacy(&before)), "the runs still cover the text");
|
||||
}
|
||||
|
||||
@@ -585,10 +585,10 @@ impl History {
|
||||
/// beyond it unseen (#4530). The most recent state is still always kept.
|
||||
pub fn trim_with(&mut self, current: &Document, pinned: &[&Document]) -> usize {
|
||||
// While Clear History can be undone, the cleared states are what history holds: the
|
||||
// oldest of them go first, as the list's would.
|
||||
let (undo, redo) = match &mut self.cleared {
|
||||
Some(c) => (&mut c.undo, &c.redo),
|
||||
None => (&mut self.undo, &self.redo),
|
||||
// oldest of them go first, as the list's would. The source set before it stays pinned.
|
||||
let (undo, redo, brush_source) = match &mut self.cleared {
|
||||
Some(c) => (&mut c.undo, &c.redo, c.brush_source),
|
||||
None => (&mut self.undo, &self.redo, self.brush_source),
|
||||
};
|
||||
if self.max_bytes == 0 || undo.len() <= 1 {
|
||||
return 0;
|
||||
@@ -622,7 +622,7 @@ impl History {
|
||||
}
|
||||
// The History Brush's source is kept whatever its age, so its own tiles come first; the
|
||||
// newest states then fill what remains of the budget (the most recent one always).
|
||||
let source = self.brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
|
||||
let source = brush_source.and_then(|id| undo.iter().position(|s| s.id == id));
|
||||
if let Some(state) = source.and_then(|i| undo.get(i)) {
|
||||
total = total.saturating_add(tile_bytes(&state.doc, &mut seen));
|
||||
}
|
||||
@@ -632,7 +632,7 @@ impl History {
|
||||
continue;
|
||||
}
|
||||
total = total.saturating_add(tile_bytes(&s.doc, &mut seen));
|
||||
if total > self.max_bytes && keep >= 1 {
|
||||
if total > self.max_bytes && (keep >= 1 || source == Some(undo.len() - 1)) {
|
||||
break;
|
||||
}
|
||||
keep += 1;
|
||||
|
||||
@@ -197,6 +197,53 @@ fn clear_states_resets_the_brush_source_until_undo() {
|
||||
assert_eq!((h.brush_source(), h.row_doc(1, &cur).unwrap().name.as_str()), (Some(1), "a"));
|
||||
}
|
||||
|
||||
/// The byte budget keeps the source set before Clear History among the cleared states, so
|
||||
/// Undo Clear History brings it back as the History Brush's source.
|
||||
#[test]
|
||||
fn byte_budget_keeps_the_brush_source_of_the_cleared_states() {
|
||||
let mut h = History::new(50);
|
||||
let mut cur = Arc::new(base());
|
||||
for i in 0..5 {
|
||||
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
|
||||
let id = d.layers[0].id;
|
||||
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
|
||||
});
|
||||
}
|
||||
assert!(h.set_brush_source(1));
|
||||
let source = h.state(1).unwrap();
|
||||
assert!(h.clear_states());
|
||||
h.max_bytes = 1;
|
||||
assert_eq!(h.trim(&cur), 3);
|
||||
assert_counter(&h, &cur);
|
||||
assert!(h.is_cleared() && h.past_len() == 0, "the list stays cleared");
|
||||
cur = h.undo(cur).unwrap().0;
|
||||
assert_eq!(h.entries(), ["paint 0", "paint 3", "paint 4"], "the source is kept with the last step");
|
||||
assert_eq!(h.brush_source(), Some(0));
|
||||
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source), "the History Brush paints from the same state");
|
||||
}
|
||||
|
||||
/// When the History Brush's source is the newest past state, it is the state the byte budget
|
||||
/// always keeps, and no older state is kept beside it.
|
||||
#[test]
|
||||
fn byte_budget_counts_a_newest_brush_source_as_the_kept_state() {
|
||||
let mut h = History::new(50);
|
||||
let mut cur = Arc::new(base());
|
||||
for i in 0..5 {
|
||||
edit(&mut h, &mut cur, &format!("paint {i}"), |d| {
|
||||
let id = d.layers[0].id;
|
||||
d.layer_mut(id).unwrap().surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0, 0.0, 0.0, 1.0]);
|
||||
});
|
||||
}
|
||||
assert!(h.set_brush_source(4));
|
||||
let source = h.state(4).unwrap();
|
||||
h.max_bytes = 1;
|
||||
assert_eq!(h.trim(&cur), 4);
|
||||
assert_counter(&h, &cur);
|
||||
assert_eq!(h.past_len(), 1);
|
||||
assert_eq!(h.brush_source(), Some(0));
|
||||
assert!(Arc::ptr_eq(&h.row_doc(0, &cur).unwrap(), &source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_edit_clears_redo() {
|
||||
let mut h = History::default();
|
||||
|
||||
@@ -11,6 +11,10 @@ pub enum Value {
|
||||
Dict(Vec<(String, Value)>),
|
||||
Array(Vec<Value>),
|
||||
String(String),
|
||||
/// A string the file stored as plain bytes, without the `FE FF` mark (one char per byte).
|
||||
/// Kept apart from [`Value::String`] so it is written back as the same bytes: Photoshop
|
||||
/// rejects a `Txt2` whose raw strings come back as UTF-16 (#9091).
|
||||
RawString(String),
|
||||
Int(i64),
|
||||
Real(f64),
|
||||
Bool(bool),
|
||||
@@ -67,7 +71,7 @@ impl Value {
|
||||
}
|
||||
pub fn as_str(&self) -> Option<&str> {
|
||||
match self {
|
||||
Value::String(s) | Value::Name(s) => Some(s),
|
||||
Value::String(s) | Value::RawString(s) | Value::Name(s) => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -173,7 +177,7 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::String(decode_string(&raw)))
|
||||
Ok(decode_string(&raw))
|
||||
}
|
||||
Some(b'/') => Ok(Value::Name(self.name())),
|
||||
Some(_) => {
|
||||
@@ -205,12 +209,12 @@ impl Parser<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_string(raw: &[u8]) -> String {
|
||||
fn decode_string(raw: &[u8]) -> Value {
|
||||
if raw.len() >= 2 && raw[0] == 0xFE && raw[1] == 0xFF {
|
||||
let u: Vec<u16> = raw[2..].as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
String::from_utf16_lossy(&u)
|
||||
Value::String(String::from_utf16_lossy(&u))
|
||||
} else {
|
||||
raw.iter().map(|&c| c as char).collect()
|
||||
Value::RawString(raw.iter().map(|&c| c as char).collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +297,18 @@ fn write_value(v: &Value, depth: usize, out: &mut Vec<u8>) {
|
||||
}
|
||||
out.extend_from_slice(b" ]");
|
||||
}
|
||||
Value::String(s) => {
|
||||
Value::RawString(s) if s.chars().all(|c| u32::from(c) <= 0xFF) => {
|
||||
out.push(b'(');
|
||||
for c in s.chars() {
|
||||
let c = u32::from(c) as u8;
|
||||
if matches!(c, b'(' | b')' | b'\\') {
|
||||
out.push(b'\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push(b')');
|
||||
}
|
||||
Value::String(s) | Value::RawString(s) => {
|
||||
out.push(b'(');
|
||||
let mut raw = vec![0xFE, 0xFF];
|
||||
for u in s.encode_utf16() {
|
||||
@@ -382,6 +397,17 @@ mod tests {
|
||||
assert_eq!(v.path(&["EngineDict", "Runs"]).unwrap().as_array().unwrap()[0].get("A").and_then(Value::as_i64), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_strings_are_written_back_as_the_same_bytes() {
|
||||
let v = parse(b"<< /2 (0100\\)) /3 (\xFE\xFF\x00h) >>").unwrap();
|
||||
assert_eq!(v.get("2"), Some(&Value::RawString("0100)".into())));
|
||||
assert_eq!(v.get("2").and_then(Value::as_str), Some("0100)"));
|
||||
assert_eq!(v.get("3"), Some(&Value::String("h".into())));
|
||||
let bytes = write(&v);
|
||||
assert!(bytes.windows(8).any(|w| w == b"(0100\\))"), "{:?}", String::from_utf8_lossy(&bytes));
|
||||
assert_eq!(parse(&bytes).unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_parse_roundtrip() {
|
||||
let v = parse(SAMPLE).unwrap();
|
||||
|
||||
+318
-14
@@ -155,19 +155,9 @@ fn txt2_mode_runs(layer: &TextLayer) -> Vec<(i64, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
|
||||
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
|
||||
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
|
||||
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
|
||||
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
|
||||
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
|
||||
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
|
||||
/// larger than a template may be (it is copied once per run).
|
||||
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
|
||||
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
|
||||
return None;
|
||||
}
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
/// The engine text of `layer` (`\r` breaks and the trailing `\r`) as `(character run, UTF-16
|
||||
/// length)` spans: one per run that has text.
|
||||
fn char_spans(layer: &TextLayer) -> Vec<(usize, i64)> {
|
||||
let runs = layer.char_runs();
|
||||
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let mut spans: Vec<(usize, i64)> = Vec::new();
|
||||
@@ -183,6 +173,24 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) ->
|
||||
_ => spans.push((ri, n)),
|
||||
}
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// `Txt2` style runs for `layer`, one per character run: `base` (one of the file's style runs,
|
||||
/// `<< /0 << /0 << /6 style >> >> /1 length >>`, every key Photoshop wrote) with its style set
|
||||
/// from the run's own: font (`/0`, an index into `fonts`, the block's `/0 /1 /0` font list, which
|
||||
/// a missing font is appended to), size (`/1`), faux bold and italic, leading, scales and tracking
|
||||
/// (`/2`–`/8`, EngineData's order), fill colour (`/53`), the OpenType keys and the auto-kern mode
|
||||
/// (`/11`). Photoshop reads the character style from here rather than from EngineData, and quit
|
||||
/// on a run holding only the auto-kern mode. `None` when `base` has no style dictionary or is
|
||||
/// larger than a template may be (it is copied once per run).
|
||||
fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) -> Option<Vec<E>> {
|
||||
if !matches!(base.path(&["0", "0", "6"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
|
||||
return None;
|
||||
}
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
let runs = layer.char_runs();
|
||||
let spans = char_spans(layer);
|
||||
let default = CharStyle::default();
|
||||
let mut out = Vec::with_capacity(spans.len());
|
||||
let mut looked_up = std::collections::HashMap::new();
|
||||
@@ -220,6 +228,278 @@ fn txt2_style_runs(layer: &TextLayer, base: &E, fonts: &mut Vec<E>, dpi: f32) ->
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether the style runs of a `Txt2` text model (`/6 /0`) agree with `layer`'s character runs
|
||||
/// on every key [`txt2_style_runs`] writes, the auto-kern mode aside ([`with_modes`] refits that
|
||||
/// on its own): font (by name, through `fonts`, the block's font list), size, faux bold and
|
||||
/// italic, leading, scales, tracking, fill colour and the OpenType keys. A key the file's run
|
||||
/// lacks, or holds as another type, is no evidence of an edit and is skipped, so an unedited
|
||||
/// save still gets the file's block back byte for byte (#2374); a key that disagrees means the
|
||||
/// layer's style was edited since the file was written (#8415).
|
||||
fn txt2_style_matches(layer: &TextLayer, model: &E, fonts: &[E], dpi: f32) -> bool {
|
||||
let Some(file_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return true };
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
style_runs_match(layer, file_runs, "1", &["0", "0", "6"], |s, style| txt2_style_agrees(s, style, fonts, k))
|
||||
}
|
||||
|
||||
/// [`txt2_style_matches`] for the older Photoshop's object at `/DocumentObjects /TextObjects`
|
||||
/// (`entry`): its `Model /StyleRun /RunArray` runs hold the style under `RunData /StyleSheet
|
||||
/// /Features`, named keys, with the run length under `Length`. `fonts` is the block's
|
||||
/// `DocumentResources /FontSet /Resources`.
|
||||
fn td_style_matches(layer: &TextLayer, entry: &E, fonts: &[E], dpi: f32) -> bool {
|
||||
let Some(file_runs) = entry.path(&["Model", "StyleRun", "RunArray"]).and_then(E::as_array) else { return true };
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
style_runs_match(layer, file_runs, "Length", &["RunData", "StyleSheet", "Features"], |s, style| td_style_agrees(s, style, fonts, k))
|
||||
}
|
||||
|
||||
/// Whether `file_runs` (a run array whose runs hold their length under `len_key` and their style
|
||||
/// at `style_path`) agree, run for run over the layer's engine text, with the layer's character
|
||||
/// runs by `agrees`. A run without a style dictionary says nothing.
|
||||
fn style_runs_match(layer: &TextLayer, file_runs: &[E], len_key: &str, style_path: &[&str], agrees: impl Fn(&CharStyle, &E) -> bool) -> bool {
|
||||
let run_len = |i: usize| file_runs.get(i).and_then(|r| r.get(len_key)).and_then(E::as_i64).unwrap_or(0);
|
||||
let runs = layer.char_runs();
|
||||
let default = CharStyle::default();
|
||||
let ed_text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let (mut ri, mut run_end) = (0usize, runs.first().map_or(0, |r| r.len));
|
||||
let (mut fi, mut left) = (0usize, run_len(0));
|
||||
// Each (layer run, file run) pair is compared once, however long it is.
|
||||
let mut compared = None;
|
||||
for (b, ch) in ed_text.char_indices() {
|
||||
while b >= run_end && ri + 1 < runs.len() {
|
||||
ri += 1;
|
||||
run_end = run_end.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
}
|
||||
while left <= 0 && fi + 1 < file_runs.len() {
|
||||
fi += 1;
|
||||
left = run_len(fi);
|
||||
}
|
||||
if compared != Some((ri, fi)) {
|
||||
let s = runs.get(ri).map_or(&default, |r| &r.style);
|
||||
if let Some(style) = file_runs.get(fi).and_then(|r| r.path(style_path))
|
||||
&& !agrees(s, style)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
compared = Some((ri, fi));
|
||||
}
|
||||
left = left.saturating_sub(ch.len_utf16() as i64);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether one `Txt2` style dictionary agrees with `s` (see [`txt2_style_matches`]); `k` is the
|
||||
/// document's points per PostScript point, as in [`txt2_style_runs`].
|
||||
fn txt2_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
|
||||
let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want)));
|
||||
let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want);
|
||||
let font = style.get("0").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| fonts.get(i)).and_then(|f| f.path(&["0", "0", "0"]));
|
||||
if font.and_then(E::as_str).is_some_and(|name| name != postscript_for(s)) {
|
||||
return false;
|
||||
}
|
||||
let auto_leading = s.leading_pt.is_none();
|
||||
if !(number("1", s.size_pt * k) && flag("2", s.faux_bold) && flag("3", s.faux_italic) && flag("4", auto_leading)) {
|
||||
return false;
|
||||
}
|
||||
// An automatic leading's value is Photoshop's own; only a set one is ours to compare.
|
||||
if style.get("4").and_then(E::as_bool) == Some(false) && !auto_leading && !number("5", s.leading_pt.unwrap_or(0.0) * k) {
|
||||
return false;
|
||||
}
|
||||
if !(number("6", s.horizontal_scale) && number("7", s.vertical_scale) && number("8", s.tracking.round())) {
|
||||
return false;
|
||||
}
|
||||
if let Some(paint) = style.get("53").and_then(|p| p.get("0")) {
|
||||
let (kind, values) = fill_color(&s.color);
|
||||
if paint.get("0").and_then(E::as_i64).is_some_and(|v| v != kind) {
|
||||
return false;
|
||||
}
|
||||
if let Some(have) = paint.get("1").and_then(E::as_array) {
|
||||
let same =
|
||||
have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b)));
|
||||
if !same {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
|
||||
if !TXT2_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) {
|
||||
return false;
|
||||
}
|
||||
// A mask beyond the 20 sets is ignored on import (`add_txt2_features`), so here too.
|
||||
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
|
||||
style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets)
|
||||
}
|
||||
|
||||
/// EngineData's `FontCaps` code of a style: 0 normal, 1 small caps, 2 all caps.
|
||||
fn caps_code(s: &CharStyle) -> i64 {
|
||||
match s.caps {
|
||||
Caps::Normal => 0,
|
||||
Caps::SmallCaps => 1,
|
||||
Caps::AllCaps => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// EngineData's `FontBaseline` code of a style: 0 normal, 1 superscript, 2 subscript.
|
||||
fn baseline_code(s: &CharStyle) -> i64 {
|
||||
match s.baseline {
|
||||
FontBaseline::Normal => 0,
|
||||
FontBaseline::Superscript => 1,
|
||||
FontBaseline::Subscript => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenType flags of a legacy style (`Features`), by key, and the feature each turns on: the
|
||||
/// named keys of [`TXT2_FEATURES`].
|
||||
const TD_FEATURES: [(&str, &str); 7] = [
|
||||
("ContextualLigatures", "calt"),
|
||||
("Fractions", "frac"),
|
||||
("Ordinals", "ordn"),
|
||||
("Swash", "swsh"),
|
||||
("Titling", "titl"),
|
||||
("StylisticAlternates", "salt"),
|
||||
("Ornaments", "ornm"),
|
||||
];
|
||||
|
||||
/// The legacy key of the Stylistic Sets bit mask, as [`TXT2_STYLISTIC_SETS`] is for `Txt2`'s own.
|
||||
const TD_STYLISTIC_SETS: &str = "StylisticSets";
|
||||
|
||||
/// The PostScript name of font `index` in a legacy font list (`DocumentResources /FontSet
|
||||
/// /Resources`: `<< /Resource << /StreamTag /CoolTypeFont /Identifier << /Name (name) /Type n >> >> >>`).
|
||||
fn td_font_name(fonts: &[E], index: i64) -> Option<&str> {
|
||||
fonts.get(usize::try_from(index).ok()?)?.path(&["Resource", "Identifier", "Name"])?.as_str()
|
||||
}
|
||||
|
||||
/// Whether one legacy `Features` dictionary agrees with `s`: [`txt2_style_agrees`] for the named
|
||||
/// keys. A key the file's run lacks, or holds as another type, is no evidence of an edit.
|
||||
fn td_style_agrees(s: &CharStyle, style: &E, fonts: &[E], k: f32) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
|
||||
let number = |key: &str, want: f32| style.get(key).and_then(E::as_f64).is_none_or(|v| close(v, f64::from(want)));
|
||||
let flag = |key: &str, want: bool| style.get(key).and_then(E::as_bool).is_none_or(|v| v == want);
|
||||
if style.get("Font").and_then(E::as_i64).and_then(|i| td_font_name(fonts, i)).is_some_and(|name| name != postscript_for(s)) {
|
||||
return false;
|
||||
}
|
||||
let auto_leading = s.leading_pt.is_none();
|
||||
if !(number("FontSize", s.size_pt * k) && flag("FauxBold", s.faux_bold) && flag("FauxItalic", s.faux_italic) && flag("AutoLeading", auto_leading)) {
|
||||
return false;
|
||||
}
|
||||
if style.get("AutoLeading").and_then(E::as_bool) == Some(false) && !auto_leading && !number("Leading", s.leading_pt.unwrap_or(0.0) * k) {
|
||||
return false;
|
||||
}
|
||||
if !(number("HorizontalScale", s.horizontal_scale) && number("VerticalScale", s.vertical_scale) && number("Tracking", s.tracking.round())) {
|
||||
return false;
|
||||
}
|
||||
// Per run, like the rest: a baseline shift, the case and baseline position, the underline and
|
||||
// strikethrough (a non-zero position is on, whichever position the file chose), the ligatures
|
||||
// and the auto-kern mode.
|
||||
let whole = |key: &str, want: i64| style.get(key).and_then(E::as_i64).is_none_or(|v| v == want);
|
||||
let on = |key: &str, want: bool| style.get(key).and_then(E::as_i64).is_none_or(|v| (v != 0) == want);
|
||||
if !(number("BaselineShift", s.baseline_shift_pt * k)
|
||||
&& whole("FontCaps", caps_code(s))
|
||||
&& whole("FontBaseline", baseline_code(s))
|
||||
&& on("UnderlinePosition", s.underline)
|
||||
&& on("StrikethroughPosition", s.strikethrough)
|
||||
&& flag("Ligatures", s.ligatures)
|
||||
&& flag("DiscretionaryLigatures", s.discretionary_ligatures)
|
||||
&& whole("AutoKern", txt2_mode(s)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(paint) = style.get("FillColor").and_then(|p| p.get("Color")) {
|
||||
let (kind, values) = fill_color(&s.color);
|
||||
if paint.get("Type").and_then(E::as_i64).is_some_and(|v| v != kind) {
|
||||
return false;
|
||||
}
|
||||
if let Some(have) = paint.get("Values").and_then(E::as_array) {
|
||||
let same =
|
||||
have.len() == values.len() && have.iter().zip(&values).all(|(a, b)| matches!((a.as_f64(), b.as_f64()), (Some(a), Some(b)) if close(a, b)));
|
||||
if !same {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
|
||||
if !TD_FEATURES.iter().all(|&(key, tag)| flag(key, feature(tag).unwrap_or(tag == "calt"))) {
|
||||
return false;
|
||||
}
|
||||
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
|
||||
style.get(TD_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).is_none_or(|m| m == sets)
|
||||
}
|
||||
|
||||
/// The index of the PostScript font `name` in a legacy font list, appending it (shaped like the
|
||||
/// list's first entry, with its font type) when missing. `None` for an empty list.
|
||||
fn td_font(fonts: &mut Vec<E>, name: &str) -> Option<i64> {
|
||||
if let Some(i) = fonts.iter().position(|f| f.path(&["Resource", "Identifier", "Name"]).and_then(E::as_str) == Some(name)) {
|
||||
return i64::try_from(i).ok();
|
||||
}
|
||||
let mut entry = fonts.first()?.clone();
|
||||
entry.get_mut("Resource")?.get_mut("Identifier")?.set("Name", E::String(name.to_string()));
|
||||
fonts.push(entry);
|
||||
i64::try_from(fonts.len() - 1).ok()
|
||||
}
|
||||
|
||||
/// `entry` (an object at `/DocumentObjects /TextObjects`) with its `Model /StyleRun` runs remade
|
||||
/// from the layer's character runs: the entry's first style run with its `Features` set from each
|
||||
/// run's own, every other key Photoshop wrote kept, as [`txt2_style_runs`] does for the numbered
|
||||
/// objects. Photoshop re-renders a type layer from this copy when the file has one, so a
|
||||
/// style-only edit left here showed the old style on reopen. `None` when the first run has no
|
||||
/// `Features` dictionary, or is larger than a template may be: the entry is then left as the file
|
||||
/// has it.
|
||||
fn td_restyled(entry: &E, layer: &TextLayer, fonts: &mut Vec<E>, dpi: f32) -> Option<E> {
|
||||
let base = entry.path(&["Model", "StyleRun", "RunArray"])?.as_array()?.first()?;
|
||||
if !matches!(base.path(&["RunData", "StyleSheet", "Features"]), Some(E::Dict(_))) || !fits(base, &mut { TEMPLATE_NODES }) {
|
||||
return None;
|
||||
}
|
||||
let k = if dpi.is_finite() && dpi > 0.0 { dpi / 72.0 } else { 1.0 };
|
||||
let runs = layer.char_runs();
|
||||
let default = CharStyle::default();
|
||||
let mut out = Vec::new();
|
||||
for (ri, n) in char_spans(layer) {
|
||||
let s = runs.get(ri).map_or(&default, |r| &r.style);
|
||||
let mut run = base.clone();
|
||||
let features = run.get_mut("RunData")?.get_mut("StyleSheet")?.get_mut("Features")?;
|
||||
if let Some(font) = td_font(fonts, &postscript_for(s)) {
|
||||
features.set("Font", E::Int(font));
|
||||
}
|
||||
features.set("FontSize", real(s.size_pt * k));
|
||||
features.set("FauxBold", E::Bool(s.faux_bold));
|
||||
features.set("FauxItalic", E::Bool(s.faux_italic));
|
||||
features.set("AutoLeading", E::Bool(s.leading_pt.is_none()));
|
||||
// An automatic leading's value is Photoshop's own (`.01` in the files seen): kept.
|
||||
if let Some(leading) = s.leading_pt {
|
||||
features.set("Leading", real(leading * k));
|
||||
}
|
||||
features.set("HorizontalScale", real(s.horizontal_scale));
|
||||
features.set("VerticalScale", real(s.vertical_scale));
|
||||
features.set("Tracking", E::Int(s.tracking.round() as i64));
|
||||
features.set("AutoKern", E::Int(txt2_mode(s)));
|
||||
features.set("BaselineShift", real(s.baseline_shift_pt * k));
|
||||
features.set("FontCaps", E::Int(caps_code(s)));
|
||||
features.set("FontBaseline", E::Int(baseline_code(s)));
|
||||
// On keeps the position the file chose (a non-zero value), else the first one.
|
||||
for (key, on) in [("UnderlinePosition", s.underline), ("StrikethroughPosition", s.strikethrough)] {
|
||||
let kept = features.get(key).and_then(E::as_i64).filter(|v| *v != 0).unwrap_or(1);
|
||||
features.set(key, E::Int(if on { kept } else { 0 }));
|
||||
}
|
||||
features.set("Ligatures", E::Bool(s.ligatures));
|
||||
features.set("DiscretionaryLigatures", E::Bool(s.discretionary_ligatures));
|
||||
if let Some(paint @ E::Dict(_)) = features.get_mut("FillColor").and_then(|p| p.get_mut("Color")) {
|
||||
let (kind, values) = fill_color(&s.color);
|
||||
paint.set("Type", E::Int(kind));
|
||||
paint.set("Values", E::Array(values));
|
||||
}
|
||||
let feature = |tag: &str| s.features.iter().find(|f| f.tag == tag).map(|f| f.value > 0);
|
||||
for (key, tag) in TD_FEATURES {
|
||||
features.set(key, E::Bool(feature(tag).unwrap_or(tag == "calt")));
|
||||
}
|
||||
let sets = (0..20).filter(|n| feature(&format!("ss{:02}", n + 1)) == Some(true)).fold(0i64, |m, n| m | 1 << n);
|
||||
features.set(TD_STYLISTIC_SETS, E::Int(sets));
|
||||
run.set("Length", E::Int(n));
|
||||
out.push(run);
|
||||
}
|
||||
let mut entry = entry.clone();
|
||||
entry.get_mut("Model")?.get_mut("StyleRun")?.set("RunArray", E::Array(out));
|
||||
Some(entry)
|
||||
}
|
||||
|
||||
/// The index of the PostScript font `name` in a `Txt2` font list (`<< /0 << /99 /CoolTypeFont
|
||||
/// /0 << /0 name /2 type >> >> >>` entries), appending it (shaped like the list's first entry)
|
||||
/// when missing. `None` for an empty list: there is no entry to shape a new one on.
|
||||
@@ -290,6 +570,10 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
|
||||
// in place, never grown: a layer the block has no entry for has no stale copy to fix.
|
||||
let mut td: Option<Vec<E>> = prev.as_ref().and_then(|p| p.path(&["DocumentObjects", "TextObjects"])).and_then(E::as_array).map(<[E]>::to_vec);
|
||||
let mut td_changed = false;
|
||||
// The legacy block's font list (`DocumentResources /FontSet /Resources`), which its style runs index.
|
||||
let mut td_fonts: Vec<E> =
|
||||
prev.as_ref().and_then(|p| p.path(&["DocumentResources", "FontSet", "Resources"])).and_then(E::as_array).map(<[E]>::to_vec).unwrap_or_default();
|
||||
let td_fonts_before = td_fonts.len();
|
||||
let mut fonts: Vec<E> = prev.as_ref().and_then(|p| p.path(&["0", "1", "0"]).and_then(E::as_array)).map(<[E]>::to_vec).unwrap_or_default();
|
||||
let fonts_before = fonts.len();
|
||||
// Looked up once per save, not per layer: a crafted block with many objects must not cost
|
||||
@@ -332,12 +616,27 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
|
||||
*slot = fixed;
|
||||
td_changed = true;
|
||||
}
|
||||
// And its character style, when it no longer says what the layer says: a style-only
|
||||
// edit (font, size, faux bold, …) left the old style here, and Photoshop reads it back.
|
||||
if let Some(entry) = entries.get_mut(at)
|
||||
&& !td_style_matches(layer, entry, &td_fonts, dpi)
|
||||
&& let Some(restyled) = td_restyled(entry, layer, &mut td_fonts, dpi)
|
||||
{
|
||||
*entry = restyled;
|
||||
td_changed = true;
|
||||
}
|
||||
}
|
||||
// A stub an earlier save wrote beside Photoshop's objects counts as no object at all, so
|
||||
// it is replaced like a missing one (below) and re-saving such a file repairs it, whether
|
||||
// or not the layer's text changed.
|
||||
let slot = prev_objects.get(at).filter(|o| !is_stub(o) || all_stubs);
|
||||
let kept = slot.filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
// Kept only while its style runs still say what the layer says (#8415): Photoshop reads
|
||||
// the character style from them, so a style-only edit (font, faux bold, size, …) must
|
||||
// restyle the object the way an edited one is, or the file's old style shows on reopen.
|
||||
let kept = slot.filter(|o| {
|
||||
let model = o.get("0");
|
||||
model.and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()) && model.is_some_and(|m| txt2_style_matches(layer, m, &fonts, dpi))
|
||||
});
|
||||
// An edited object (#6968): the file's object at this slot with its text changed. It keeps
|
||||
// the file's structure — paragraph runs, the view naming its frame, the rest — with every
|
||||
// run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in
|
||||
@@ -444,6 +743,11 @@ pub fn build_txt2_at(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>, dpi
|
||||
&& let Some(objects @ E::Dict(_)) = outer.get_mut("DocumentObjects")
|
||||
{
|
||||
objects.set("TextObjects", E::Array(entries));
|
||||
if td_fonts.len() != td_fonts_before
|
||||
&& let Some(set @ E::Dict(_)) = outer.get_mut("DocumentResources").and_then(|r| r.get_mut("FontSet"))
|
||||
{
|
||||
set.set("Resources", E::Array(td_fonts));
|
||||
}
|
||||
}
|
||||
// Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers).
|
||||
if let (Some(bytes), Some(p)) = (previous, &prev)
|
||||
|
||||
@@ -36,7 +36,7 @@ fn fits_budget(v: &E, nodes: &mut usize, bytes: &mut usize) -> bool {
|
||||
fits_budget(x, nodes, bytes)
|
||||
}),
|
||||
E::Array(items) => items.iter().all(|x| fits_budget(x, nodes, bytes)),
|
||||
E::String(s) | E::Name(s) => {
|
||||
E::String(s) | E::RawString(s) | E::Name(s) => {
|
||||
let Some(rem) = bytes.checked_sub(s.len()) else { return false };
|
||||
*bytes = rem;
|
||||
true
|
||||
@@ -150,7 +150,7 @@ pub(super) fn td_edited_object(object: &E, text: &str) -> Option<E> {
|
||||
let E::Dict(entries) = model else { return None };
|
||||
// The text must be there to replace: refitted runs over a text the model doesn't hold would
|
||||
// be a half-edited object.
|
||||
if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_))) {
|
||||
if !entries.iter().any(|(k, v)| k == "Text" && matches!(v, E::String(_) | E::RawString(_))) {
|
||||
return None;
|
||||
}
|
||||
let total = utf16_len(text) as usize;
|
||||
|
||||
@@ -32,19 +32,32 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.get("0")) else {
|
||||
return;
|
||||
// The numbered object, else the older Photoshop's at `/DocumentObjects /TextObjects` (named
|
||||
// keys: run lengths under `Length`, the style under `RunData /StyleSheet /Features`, the
|
||||
// auto-kern mode under `AutoKern`).
|
||||
let numbered = txt2
|
||||
.path(&["1", "1"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(|a| a.get(index))
|
||||
.and_then(|o| o.get("0"))
|
||||
.and_then(|model| Some((model.get("0").and_then(E::as_str)?, model.path(&["6", "0"]).and_then(E::as_array)?)));
|
||||
let legacy = || {
|
||||
let model = txt2.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|a| a.get(index))?.get("Model")?;
|
||||
Some((model.get("Text").and_then(E::as_str)?, model.path(&["StyleRun", "RunArray"]).and_then(E::as_array)?))
|
||||
};
|
||||
let Some(text) = model.get("0").and_then(E::as_str) else {
|
||||
return;
|
||||
let (text, style_runs, is_numbered) = match numbered {
|
||||
Some((text, runs)) => (text, runs, true),
|
||||
None => {
|
||||
let Some((text, runs)) = legacy() else { return };
|
||||
(text, runs, false)
|
||||
}
|
||||
};
|
||||
if text.strip_suffix('\r').unwrap_or(text).replace('\r', "\n") != layer.text {
|
||||
return;
|
||||
}
|
||||
let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else {
|
||||
return;
|
||||
};
|
||||
let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6"]))).collect();
|
||||
let (len_key, style_path, kern_key): (&str, &[&str], &str) =
|
||||
if is_numbered { ("1", &["0", "0", "6"], "11") } else { ("Length", &["RunData", "StyleSheet", "Features"], "AutoKern") };
|
||||
let styles: Vec<(f64, Option<&E>)> = style_runs.iter().map(|r| (r.get(len_key).and_then(E::as_f64).unwrap_or(0.0), r.path(style_path))).collect();
|
||||
let lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::<Vec<_>>());
|
||||
// Txt2 style per byte segment, then split the model runs at segment boundaries.
|
||||
let mut segs: Vec<(usize, Option<&E>)> = lens.into_iter().zip(styles.iter().map(|m| m.1)).collect();
|
||||
@@ -65,13 +78,13 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) {
|
||||
let txt2_style = segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end);
|
||||
// Txt2 has the exact mode (EngineData's reading also marks neighbours of manual
|
||||
// kerns manual, like Photoshop's legacy reader).
|
||||
match txt2_style.and_then(|st| st.get("11")).and_then(E::as_i64) {
|
||||
match txt2_style.and_then(|st| st.get(kern_key)).and_then(E::as_i64) {
|
||||
Some(0) => style.kerning = Kerning::Off,
|
||||
Some(1) => style.kerning = Kerning::Metrics,
|
||||
Some(2) => style.kerning = Kerning::Optical,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(st) = txt2_style {
|
||||
if let Some(st) = txt2_style.filter(|_| is_numbered) {
|
||||
add_txt2_features(&mut style, st);
|
||||
}
|
||||
let len = piece_end - at;
|
||||
|
||||
@@ -1436,6 +1436,56 @@ fn edited_text_keeps_the_files_txt2_object() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`.
|
||||
/// Photoshop reads the character style from the object's style runs, and an object whose text
|
||||
/// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux
|
||||
/// bold still on. A layer whose style agrees with the runs still gets the block back byte for
|
||||
/// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the
|
||||
/// glyph pen positions laid out in the old style are dropped.
|
||||
#[test]
|
||||
fn style_only_edit_restyles_the_kept_txt2_object() {
|
||||
use crate::engine_data::Value as E;
|
||||
// Strings in the block are UTF-16 with a BOM; every character here is below U+0100.
|
||||
let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::<String>());
|
||||
let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name));
|
||||
// One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions.
|
||||
let prev = format!(
|
||||
"\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>",
|
||||
font("Arial-Black"),
|
||||
font("ArialMT"),
|
||||
utf16("AV\r"),
|
||||
);
|
||||
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
|
||||
let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() };
|
||||
let t = styled("AV", style.clone());
|
||||
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block");
|
||||
|
||||
let font_name = |out: &E, run: &E| {
|
||||
let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index");
|
||||
out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned)
|
||||
};
|
||||
for (what, edited, want_font) in [
|
||||
("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"),
|
||||
(
|
||||
"font and faux bold",
|
||||
CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() },
|
||||
"AppleSDGothicNeo-Heavy",
|
||||
),
|
||||
] {
|
||||
let t = styled("AV", edited);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
|
||||
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
|
||||
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged");
|
||||
let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
|
||||
assert!(!runs.is_empty(), "{what}: the object has style runs");
|
||||
for run in runs {
|
||||
assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2");
|
||||
assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font");
|
||||
}
|
||||
assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped");
|
||||
}
|
||||
}
|
||||
|
||||
/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an
|
||||
/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`,
|
||||
/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out.
|
||||
@@ -3158,3 +3208,285 @@ fn per_colour_passes_match_full_size_passes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the
|
||||
/// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs
|
||||
/// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`.
|
||||
fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec<u8> {
|
||||
use crate::engine_data::Value as E;
|
||||
let font_entry = |name: &str| {
|
||||
E::Dict(vec![(
|
||||
"Resource".into(),
|
||||
E::Dict(vec![
|
||||
("StreamTag".into(), E::Name("CoolTypeFont".into())),
|
||||
("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])),
|
||||
]),
|
||||
)])
|
||||
};
|
||||
let features = E::Dict(vec![
|
||||
("Font".into(), E::Int(0)),
|
||||
("FontSize".into(), E::Real(size)),
|
||||
("FauxBold".into(), E::Bool(false)),
|
||||
("FauxItalic".into(), E::Bool(false)),
|
||||
("AutoLeading".into(), E::Bool(true)),
|
||||
("Leading".into(), E::Real(0.01)),
|
||||
("HorizontalScale".into(), E::Real(1.0)),
|
||||
("VerticalScale".into(), E::Real(1.0)),
|
||||
("Tracking".into(), E::Int(0)),
|
||||
("AutoKern".into(), E::Int(auto_kern)),
|
||||
("BaselineShift".into(), E::Real(0.0)),
|
||||
("FontCaps".into(), E::Int(0)),
|
||||
("FontBaseline".into(), E::Int(0)),
|
||||
("UnderlinePosition".into(), E::Int(0)),
|
||||
("StrikethroughPosition".into(), E::Int(0)),
|
||||
("Ligatures".into(), E::Bool(true)),
|
||||
("DiscretionaryLigatures".into(), E::Bool(false)),
|
||||
("ContextualLigatures".into(), E::Bool(true)),
|
||||
("StylisticSets".into(), E::Int(0)),
|
||||
(
|
||||
"FillColor".into(),
|
||||
E::Dict(vec![
|
||||
("StreamTag".into(), E::Name("SimplePaint".into())),
|
||||
(
|
||||
"Color".into(),
|
||||
E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]);
|
||||
let total = format!("{text}\r").encode_utf16().count() as i64;
|
||||
let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]);
|
||||
let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]);
|
||||
let style_data = E::Dict(vec![(
|
||||
"StyleSheet".into(),
|
||||
E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]),
|
||||
)]);
|
||||
let entry = E::Dict(vec![
|
||||
(
|
||||
"Model".into(),
|
||||
E::Dict(vec![
|
||||
("Text".into(), E::String(format!("{text}\r"))),
|
||||
("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])),
|
||||
("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])),
|
||||
("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])),
|
||||
]),
|
||||
),
|
||||
("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])),
|
||||
]);
|
||||
crate::engine_data::write_bare(&[
|
||||
("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])),
|
||||
("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])),
|
||||
])
|
||||
}
|
||||
|
||||
/// The length and `Features` of each style run of the first legacy object.
|
||||
fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> {
|
||||
use crate::engine_data::Value as E;
|
||||
block
|
||||
.path(&["DocumentObjects", "TextObjects"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(<[E]>::first)
|
||||
.and_then(|e| e.path(&["Model", "StyleRun", "RunArray"]))
|
||||
.and_then(E::as_array)
|
||||
.map(|rs| {
|
||||
rs.iter()
|
||||
.map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0))))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older
|
||||
/// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the
|
||||
/// run keeps every key Photoshop wrote, and the lengths still cover the text.
|
||||
#[test]
|
||||
fn a_style_only_edit_restyles_the_photoshop_era_objects() {
|
||||
use crate::engine_data::Value as E;
|
||||
let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1);
|
||||
let style = CharStyle {
|
||||
postscript_name: Some("Courier".into()),
|
||||
size_pt: 20.0,
|
||||
faux_bold: true,
|
||||
color: Color::rgb(0.0, 0.5, 1.0),
|
||||
tracking: 25.0,
|
||||
..Default::default()
|
||||
};
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap();
|
||||
let runs = era_runs(&out);
|
||||
assert_eq!(runs.len(), 1);
|
||||
let (length, f) = &runs[0];
|
||||
assert_eq!(*length, 7, "the run covers the text and its trailing return");
|
||||
assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0));
|
||||
assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true));
|
||||
assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25));
|
||||
assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value");
|
||||
assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1));
|
||||
assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4));
|
||||
assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept");
|
||||
// The font went into the block's list and the run names it.
|
||||
let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list");
|
||||
let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index");
|
||||
let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str);
|
||||
assert_eq!(name(index), Some("Courier"));
|
||||
assert_eq!(name(0), Some("ArialMT"), "the file's font stays first");
|
||||
// The text did not change.
|
||||
let text =
|
||||
out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str);
|
||||
assert_eq!(text, Some("Line 1\r"));
|
||||
}
|
||||
|
||||
/// A layer whose style already agrees with the file's run leaves the legacy objects as they are.
|
||||
#[test]
|
||||
fn an_unedited_style_leaves_the_photoshop_era_objects_alone() {
|
||||
let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1);
|
||||
let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
|
||||
let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev));
|
||||
assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap()));
|
||||
}
|
||||
|
||||
/// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by
|
||||
/// the same style-only edit.
|
||||
#[test]
|
||||
fn a_style_only_edit_restyles_both_object_lists() {
|
||||
use crate::engine_data::Value as E;
|
||||
let mut prev = photoshop_txt2(&[(4, 1)]);
|
||||
prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1));
|
||||
let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() };
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap();
|
||||
let legacy = era_runs(&out);
|
||||
let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool)));
|
||||
assert_eq!(first, Some((4, Some(31.0), Some(true))));
|
||||
let numbered = out
|
||||
.path(&["1", "1"])
|
||||
.and_then(E::as_array)
|
||||
.and_then(<[E]>::first)
|
||||
.and_then(|o| o.path(&["0", "6", "0"]))
|
||||
.and_then(E::as_array)
|
||||
.and_then(<[E]>::first)
|
||||
.and_then(|r| r.path(&["0", "0", "6"]));
|
||||
assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too");
|
||||
}
|
||||
|
||||
/// Two character runs give two style runs in the legacy copy, with the lengths of the runs.
|
||||
#[test]
|
||||
fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() {
|
||||
use crate::engine_data::Value as E;
|
||||
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
|
||||
let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() };
|
||||
let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() };
|
||||
let t = runs_of("Hello", &[(2, small), (3, big)]);
|
||||
let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap());
|
||||
let seen: Vec<(i64, Option<f64>)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect();
|
||||
assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return");
|
||||
}
|
||||
|
||||
/// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit
|
||||
/// does not copy the first run's baseline shift, case, underline or strikethrough to the others.
|
||||
#[test]
|
||||
fn each_photoshop_era_style_run_gets_its_own_values() {
|
||||
use crate::engine_data::Value as E;
|
||||
use photocraft_doc::text::{Caps, FontBaseline};
|
||||
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
|
||||
let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
|
||||
let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() };
|
||||
let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() };
|
||||
let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]);
|
||||
let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap());
|
||||
let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned();
|
||||
assert_eq!(runs.iter().map(|r| r.0).collect::<Vec<_>>(), vec![2, 2, 2]);
|
||||
assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0));
|
||||
assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first");
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]);
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]);
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]);
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]);
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]);
|
||||
assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]);
|
||||
let _ = E::Int(0);
|
||||
}
|
||||
|
||||
/// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and
|
||||
/// a layer that agrees on all of them leaves the block's bytes alone.
|
||||
#[test]
|
||||
fn every_legacy_style_key_is_compared() {
|
||||
use photocraft_doc::text::{Caps, FontBaseline, Kerning};
|
||||
let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1);
|
||||
let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() };
|
||||
let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev));
|
||||
assert!(
|
||||
era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()),
|
||||
"an agreeing layer leaves the legacy copy as it is"
|
||||
);
|
||||
let edits = [
|
||||
CharStyle { baseline_shift_pt: 2.0, ..base.clone() },
|
||||
CharStyle { caps: Caps::AllCaps, ..base.clone() },
|
||||
CharStyle { baseline: FontBaseline::Subscript, ..base.clone() },
|
||||
CharStyle { underline: true, ..base.clone() },
|
||||
CharStyle { strikethrough: true, ..base.clone() },
|
||||
CharStyle { ligatures: false, ..base.clone() },
|
||||
CharStyle { discretionary_ligatures: true, ..base.clone() },
|
||||
CharStyle { kerning: Kerning::Optical, ..base.clone() },
|
||||
];
|
||||
for (i, edit) in edits.into_iter().enumerate() {
|
||||
let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev));
|
||||
assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered
|
||||
/// objects, so the layer a legacy file opens as agrees with its legacy copy.
|
||||
#[test]
|
||||
fn legacy_auto_kern_is_read_on_import() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let t = styled("AVA", CharStyle::default());
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
|
||||
for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] {
|
||||
let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap();
|
||||
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
|
||||
assert!(
|
||||
back.char_runs().iter().all(|r| r.style.kerning == want),
|
||||
"AutoKern {mode}: {:?}",
|
||||
back.char_runs().iter().map(|r| r.style.kerning).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
// Other text, or another object, changes nothing.
|
||||
let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap();
|
||||
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
|
||||
assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
|
||||
}
|
||||
|
||||
/// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw
|
||||
/// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come
|
||||
/// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file.
|
||||
#[test]
|
||||
fn a_style_only_save_keeps_the_raw_strings_of_the_file() {
|
||||
let mut prev = photoshop_txt2(&[(4, 1)]);
|
||||
let version = b"/98 << /0 14 >>";
|
||||
let at = prev.windows(version.len()).position(|w| w == version).expect("the version key");
|
||||
prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied());
|
||||
// A size the file's runs do not have: the block is rewritten.
|
||||
let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() };
|
||||
let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev));
|
||||
assert_ne!(out, prev, "the style edit rewrites the block");
|
||||
let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle);
|
||||
assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out));
|
||||
assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16");
|
||||
}
|
||||
|
||||
/// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string
|
||||
/// elsewhere in the block as the file wrote it.
|
||||
#[test]
|
||||
fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() {
|
||||
use crate::engine_data::Value as E;
|
||||
let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap();
|
||||
let E::Dict(mut items) = era else { panic!("a dictionary") };
|
||||
items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))])));
|
||||
let prev = crate::engine_data::write_bare(&items);
|
||||
let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() };
|
||||
let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev));
|
||||
assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out));
|
||||
let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap());
|
||||
assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
//! `--right-click-at X,Y` opens a screen-space context menu after the script, including panel
|
||||
//! and document-tab menus that are outside the document-coordinate control pointer.
|
||||
//! `--click-at X,Y` opens a screen-space menu (for example the top Select menu) after the script.
|
||||
//! `--hover-at X,Y` leaves the pointer there without a click, for what a tool shows under it (the
|
||||
//! brush outline, the Clone Source overlay).
|
||||
|
||||
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
@@ -170,6 +172,10 @@ fn main() {
|
||||
harness.event(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
|
||||
harness.run_steps(4);
|
||||
}
|
||||
if let Some((x, y)) = arg(&args, "--hover-at").and_then(|s| s.split_once(',').and_then(|(x, y)| Some((x.parse::<f32>().ok()?, y.parse::<f32>().ok()?)))) {
|
||||
harness.event(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||
harness.run_steps(4);
|
||||
}
|
||||
let t_settle = std::time::Instant::now();
|
||||
while t_settle.elapsed() < std::time::Duration::from_millis(settle_ms) {
|
||||
harness.step();
|
||||
|
||||
@@ -101,6 +101,7 @@ use strokes::{HOLD_REPEAT_MS, draw_clone_preview, feed_live_stroke, pointer_butt
|
||||
#[cfg(test)]
|
||||
pub(crate) use strokes::{SPOT_HEALING_TRAIL, live_retouch_points};
|
||||
pub(crate) use tabs::arranged_cells;
|
||||
pub(crate) use tabs::tab_title_at;
|
||||
#[cfg(test)]
|
||||
use tabs::{TEAR_OFF, pro_tab_title, tab_context_items, tab_drop_index, tab_move_target, tabs, torn_off};
|
||||
pub use tabs::{TabStrip, document_area, extra_windows};
|
||||
@@ -115,10 +116,7 @@ use textures::{ensure_gpu, redraw_focus, visible_doc_rect};
|
||||
pub use tool_input::{ToolEvent, selection_drag_kind, tool_event};
|
||||
pub(crate) use tool_input::{command_guide_at, command_moves_layer, event_tool};
|
||||
pub(crate) use transform_controls::{LAYER_EDGES, layer_edges_rect};
|
||||
use transform_controls::{
|
||||
begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_several,
|
||||
transform_controls_turn,
|
||||
};
|
||||
use transform_controls::{begin_transform_controls_at, draw_transform_controls, transform_controls_cursor, transform_controls_rect, transform_controls_turn};
|
||||
#[cfg(test)]
|
||||
use transform_controls::{transform_controls_hit, transform_controls_layers};
|
||||
pub use view_xform::{ViewXform, doc_point_at, fill_view, fit_view};
|
||||
@@ -1360,10 +1358,8 @@ fn draw_canvas_tool_cursor(app: &mut PhotocraftApp, ui: &mut egui::Ui, input: &C
|
||||
// corner badge, as Photoshop's corner radius cursor.
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
|
||||
crate::icons::cursor_badge(ctx, "scan", p);
|
||||
} else if let Some((r, p, c)) = response
|
||||
.hover_pos()
|
||||
.filter(|_| !transform_controls_several(app))
|
||||
.and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
|
||||
} else if let Some((r, p, c)) =
|
||||
response.hover_pos().and_then(|p| transform_controls_rect(app, xf).and_then(|r| transform_controls_cursor(r, p).map(|c| (r, p, c))))
|
||||
{
|
||||
// The Move tool draws handles without opening a Free Transform session.
|
||||
// Give them their resize/rotate cursors before the first pointer press (#2871).
|
||||
|
||||
@@ -57,7 +57,7 @@ impl Mode {
|
||||
Mode::ALL.into_iter().find(|m| m.id() == id)
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
pub(crate) fn label(self) -> &'static str {
|
||||
match self {
|
||||
Mode::OnionSkin => tl!("Onion Skin"),
|
||||
Mode::MarchingAnts => tl!("Marching Ants"),
|
||||
@@ -70,7 +70,7 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// The single-letter key that picks this view.
|
||||
fn key(self) -> egui::Key {
|
||||
pub(crate) fn key(self) -> egui::Key {
|
||||
match self {
|
||||
Mode::OnionSkin => egui::Key::O,
|
||||
Mode::MarchingAnts => egui::Key::M,
|
||||
@@ -83,7 +83,7 @@ impl Mode {
|
||||
}
|
||||
|
||||
/// The view's opacity (percent) until it is changed; `None` for views without one.
|
||||
fn default_opacity(self) -> Option<f32> {
|
||||
pub(crate) fn default_opacity(self) -> Option<f32> {
|
||||
match self {
|
||||
Mode::OnionSkin | Mode::Overlay => Some(50.0),
|
||||
Mode::OnBlack | Mode::OnWhite => Some(100.0),
|
||||
@@ -91,7 +91,7 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
fn next(self) -> Mode {
|
||||
pub(crate) fn next(self) -> Mode {
|
||||
let i = Mode::ALL.iter().position(|m| *m == self).unwrap_or(0);
|
||||
Mode::ALL.get(i + 1).copied().unwrap_or(Mode::OnionSkin)
|
||||
}
|
||||
@@ -438,6 +438,10 @@ struct View {
|
||||
opacity: f32,
|
||||
show_edge: bool,
|
||||
show_original: bool,
|
||||
/// Overlay's colour (Photoshop's default rubylith red) and whether it marks the selected
|
||||
/// areas rather than the masked ones.
|
||||
color: Color,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl View {
|
||||
@@ -449,6 +453,16 @@ impl View {
|
||||
opacity: v.and_then(|v| v.get("opacity")).and_then(Value::as_f64).filter(|o| o.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0) as f32,
|
||||
show_edge: b("showEdge"),
|
||||
show_original: b("showOriginal"),
|
||||
color: v
|
||||
.and_then(|v| v.get("color"))
|
||||
.and_then(Value::as_array)
|
||||
.filter(|c| c.len() == 3)
|
||||
.and_then(|c| {
|
||||
let ch = |i: usize| c.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32);
|
||||
Some(Color::rgb(ch(0)?, ch(1)?, ch(2)?))
|
||||
})
|
||||
.unwrap_or(Color::rgb(1.0, 0.0, 0.0)),
|
||||
selected: v.and_then(|v| v.get("indicates")).and_then(Value::as_str) == Some("selected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -461,16 +475,31 @@ fn length(params: &Value, key: &str, k: u32) -> f64 {
|
||||
/// The preview of the Select and Mask dialog for `doc`: the proxy reduced by `k`, shown in the
|
||||
/// requested view. `None` when the refinement fails or is cancelled.
|
||||
pub fn preview_document(doc: &Document, active: Option<LayerId>, params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>) -> Option<Document> {
|
||||
let k = k.max(1);
|
||||
preview_proxy(crate::proxy::proxy_document(doc, k), active, params, k, cancel)
|
||||
}
|
||||
|
||||
/// [`preview_document`] on `proxy`, the document already reduced by `k` (the Select and Mask
|
||||
/// workspace builds it itself, its selection already carrying the workspace's tool edits).
|
||||
pub fn preview_proxy(
|
||||
mut proxy: Document,
|
||||
active: Option<LayerId>,
|
||||
params: &Value,
|
||||
k: u32,
|
||||
cancel: Option<&photocraft_engine::jobs::JobCtx>,
|
||||
) -> Option<Document> {
|
||||
let view = View::of(params);
|
||||
let k = k.max(1);
|
||||
let mut proxy = crate::proxy::proxy_document(doc, k);
|
||||
let active = active.filter(|id| proxy.layer(*id).is_some());
|
||||
// What Select and Mask starts from: the selection, else the active layer's mask.
|
||||
let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone()));
|
||||
let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX));
|
||||
let coverage = if view.show_edge {
|
||||
let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize);
|
||||
band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas)
|
||||
// With nothing selected OK refines nothing (`matting::refine_area`), so the brush shows nothing either.
|
||||
let selects = original.as_ref().is_some_and(|s| !s.content_bounds().intersect(&canvas).is_empty());
|
||||
let brush = if selects { scaled_strokes(params, k).and_then(|s| photocraft_algo::matting::EdgeBrush::new(&s, canvas)) } else { None };
|
||||
band.with_brush(brush.as_ref(), canvas)
|
||||
} else if view.show_original {
|
||||
Coverage::read(original.as_ref(), canvas)
|
||||
} else {
|
||||
@@ -559,7 +588,10 @@ fn show(doc: &mut Document, active: Option<LayerId>, view: &View, coverage: &Cov
|
||||
doc.selection = Some(coverage.surface(|v| v));
|
||||
match view.mode {
|
||||
Mode::MarchingAnts => {}
|
||||
Mode::Overlay => doc.layers.push(veil("Overlay", Color::rgb(1.0, 0.0, 0.0), o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::Overlay => {
|
||||
let marked = if view.selected { coverage.surface(|v| v) } else { coverage.surface(|v| 1.0 - v) };
|
||||
doc.layers.push(veil("Overlay", view.color, o, Some(marked)));
|
||||
}
|
||||
Mode::OnBlack => doc.layers.push(veil("On Black", Color::BLACK, o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::OnWhite => doc.layers.push(veil("On White", Color::WHITE, o, Some(coverage.surface(|v| 1.0 - v)))),
|
||||
Mode::BlackWhite => {
|
||||
@@ -705,6 +737,24 @@ mod tests {
|
||||
assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area");
|
||||
}
|
||||
|
||||
/// Overlay takes Photoshop's colour swatch and Indicates: the masked (default) or the selected
|
||||
/// areas carry the tint.
|
||||
#[test]
|
||||
fn overlay_colour_and_what_it_indicates() {
|
||||
let doc = half_selected();
|
||||
let active = doc.layers.first().map(|l| l.id);
|
||||
let near = |a: &[f32], b: &[f32]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 0.02);
|
||||
let view = |indicates: &str| json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": [0.0, 0.0, 1.0], "indicates": indicates}});
|
||||
let masked = preview_document(&doc, active, &view("masked"), 1, None).expect("a preview");
|
||||
assert!(near(&pixel(&masked, 35, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&masked, 5, 10), &[1.0, 1.0, 1.0, 1.0]));
|
||||
let selected = preview_document(&doc, active, &view("selected"), 1, None).expect("a preview");
|
||||
assert!(near(&pixel(&selected, 5, 10), &[0.0, 0.0, 1.0, 1.0]) && near(&pixel(&selected, 35, 10), &[1.0, 1.0, 1.0, 1.0]));
|
||||
// A hostile colour falls back to red.
|
||||
let bad = json!({"__view": {"mode": "overlay", "opacity": 1.0, "color": ["x", 2, null]}});
|
||||
let red = preview_document(&doc, active, &bad, 1, None).expect("a preview");
|
||||
assert!(near(&pixel(&red, 35, 10), &[1.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// Show Edge shows the band around the selection's edge; Show Original the unrefined selection.
|
||||
#[test]
|
||||
fn show_edge_and_show_original() {
|
||||
|
||||
@@ -216,15 +216,17 @@ fn begin(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) {
|
||||
let crop_tol = crate::crop_ui::tolerance(app);
|
||||
let gesture = if let Some(t) = &app.ui.transform {
|
||||
let corner = match crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app)) {
|
||||
crate::transform_tool::Hit::Corner(index) => t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, vec![LayerId(t.layer)])),
|
||||
crate::transform_tool::Hit::Corner(index) => {
|
||||
t.quad.get(index).map(|&point| (Gesture::TransformCorner { index, point }, crate::transform_tool::moving_layers(t)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if t.mode == crate::state::TransformMode::Distort {
|
||||
corner
|
||||
} else if near_handle(&t.quad, p, crate::transform_tool::handle_tolerance(app)) {
|
||||
corner.or_else(|| Some((Gesture::Point, vec![LayerId(t.layer)])))
|
||||
corner.or_else(|| Some((Gesture::Point, crate::transform_tool::moving_layers(t))))
|
||||
} else if in_quad(&t.quad, p) {
|
||||
Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, vec![LayerId(t.layer)]))
|
||||
Some((Gesture::TransformMove { rect: quad_rect(&t.quad) }, crate::transform_tool::moving_layers(t)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -6,14 +6,73 @@ use super::{Color32, DRect, PhotocraftApp, Pos2, Stroke, Tool, json, pos2};
|
||||
|
||||
pub(crate) struct ClonePreviewCache {
|
||||
pub(super) key: String,
|
||||
texture: egui::TextureHandle,
|
||||
/// The brush a clipped overlay took its coverage from: a hardness or tip change redraws it.
|
||||
pub(super) brush: Option<photocraft_paint::BrushSettings>,
|
||||
/// The document area the texture covers.
|
||||
pub(super) rect: DRect,
|
||||
pub(super) texture: egui::TextureHandle,
|
||||
}
|
||||
|
||||
/// A transient source image beneath the brush outline; never part of the document render.
|
||||
/// The most document pixels one dab's coverage is rendered over for the overlay: a 1000 px brush,
|
||||
/// the largest the overlay draws, at hardness 0 reaches about 1.73 × its radius (`tip_reach`).
|
||||
const CLONE_PREVIEW_MAX_PIXELS: u64 = 1800 * 1800;
|
||||
|
||||
/// The largest side `retouch_cmds::clone_preview_periodic` samples, for the overlay's area and for
|
||||
/// the source area it reads (`Mapping::source_rect`: the overlay's, mapped, with a 2 px apron).
|
||||
const CLONE_PREVIEW_MAX_SIDE: u32 = 1024;
|
||||
|
||||
/// The coverage of one dab of `brush` at `at`, as the brush engine paints it with opacity and flow
|
||||
/// at full: its tip, angle, roundness and hardness (a soft tip reaches past its radius). The area
|
||||
/// it covers and its `width × height` coverage values in 0..1. A very large soft tip, or a source
|
||||
/// the Clone Source panel scales down (which reads a larger area than it covers), is cropped
|
||||
/// around `at` to the sampling budget, keeping the middle of the dab.
|
||||
fn clone_dab_coverage(
|
||||
brush: &photocraft_paint::BrushSettings,
|
||||
at: [f64; 2],
|
||||
map: &photocraft_engine::presets::clone_source::Mapping,
|
||||
) -> Option<(DRect, Vec<f32>)> {
|
||||
let brush = photocraft_paint::BrushSettings { opacity: 1.0, flow: 1.0, erase: false, ..brush.clone() };
|
||||
let stroke = photocraft_paint::Stroke { brush, points: vec![photocraft_paint::StrokePoint::new(at[0], at[1], 1.0)] };
|
||||
let (rect, cov) = photocraft_paint::retouch::stroke_coverage_within(&stroke, CLONE_PREVIEW_MAX_PIXELS)?;
|
||||
let fits = |r: DRect| r.width() <= CLONE_PREVIEW_MAX_SIDE && r.height() <= CLONE_PREVIEW_MAX_SIDE;
|
||||
let (cx, cy) = (at[0].floor() as i32, at[1].floor() as i32);
|
||||
let around = |half: i32| rect.intersect(&DRect::new(cx.saturating_sub(half), cy.saturating_sub(half), cx.saturating_add(half), cy.saturating_add(half)));
|
||||
// The source area scales with the overlay's: shrink by what overflows, a few times for the
|
||||
// apron's rounding.
|
||||
let mut half = i32::try_from((CLONE_PREVIEW_MAX_SIDE - 4) / 2).unwrap_or(510);
|
||||
let mut window = around(half);
|
||||
for _ in 0..8 {
|
||||
let src = map.source_rect(window);
|
||||
if fits(window) && fits(src) {
|
||||
break;
|
||||
}
|
||||
let over = f64::from(src.width().max(src.height())) / f64::from(CLONE_PREVIEW_MAX_SIDE - 4);
|
||||
half = ((f64::from(half) / over.max(1.0)).floor() as i32).saturating_sub(2).max(1);
|
||||
window = around(half);
|
||||
}
|
||||
if window.is_empty() || !fits(window) || !fits(map.source_rect(window)) {
|
||||
return None;
|
||||
}
|
||||
if window == rect {
|
||||
return Some((rect, cov));
|
||||
}
|
||||
let stride = rect.width() as usize;
|
||||
let mut cropped = Vec::with_capacity(window.width() as usize * window.height() as usize);
|
||||
for y in window.y0..window.y1 {
|
||||
let row = (y - rect.y0) as usize * stride;
|
||||
let from = row + (window.x0 - rect.x0) as usize;
|
||||
cropped.extend_from_slice(cov.get(from..from + window.width() as usize)?);
|
||||
}
|
||||
Some((window, cropped))
|
||||
}
|
||||
|
||||
/// A transient source image beneath the brush outline; never part of the document render. The
|
||||
/// Clone Source panel drives the Clone Stamp and the Healing Brush alike, so both show it.
|
||||
pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, at: [f64; 2], output: Option<u32>) {
|
||||
use photocraft_engine::presets::clone_source::{Mapping, overlay_blend, transform_matrix};
|
||||
let overlay = app.session.presets.clone.overlay.clone();
|
||||
if app.ui.tool != Tool::CloneStamp || !overlay.show || (overlay.auto_hide && app.drag.is_some()) {
|
||||
let tool = app.ui.tool;
|
||||
if !matches!(tool, Tool::CloneStamp | Tool::Healing) || !overlay.show || (overlay.auto_hide && app.drag.is_some()) {
|
||||
return;
|
||||
}
|
||||
let slot = app.session.presets.clone.active().clone();
|
||||
@@ -22,30 +81,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
|
||||
if !radius.is_finite() || radius <= 0.0 || radius > 500.0 || !at.iter().all(|v| v.is_finite() && v.abs() < 1_000_000.0) {
|
||||
return;
|
||||
}
|
||||
// View › Pattern Preview repeats the source for the Clone Stamp only (`paint.cloneStamp`'s
|
||||
// `wrap`); the Healing Brush samples it as it is.
|
||||
let wrap = tool == Tool::CloneStamp && app.ui.view.pattern_preview;
|
||||
let stroke_start = app.drag.as_ref().map(|d| d.start);
|
||||
let anchor = if app.ui.tool_options.clone_aligned { slot.anchor.or(stroke_start) } else { stroke_start }.unwrap_or(at);
|
||||
let map =
|
||||
Mapping { source: (source[0], source[1]), anchor: (anchor[0], anchor[1]), m: transform_matrix(slot.scale, slot.rotation, slot.flip_h, slot.flip_v) };
|
||||
let rect = DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32);
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let (display, display_key) = canvas_display(app, &st.doc, output);
|
||||
// A shaped tip (sampled bitmap, or a rotated / flattened round one) clips the overlay to its
|
||||
// outline instead of a circle, as Photoshop does with Clipped on.
|
||||
let tip_rings = if overlay.clipped { crate::brush_cursor::rings(painter.ctx(), &app.session.tools.brush, true) } else { None };
|
||||
let tip_key = tip_rings.as_ref().map(|r| {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
r.iter().flatten().for_each(|p| (p[0].to_bits(), p[1].to_bits()).hash(&mut h));
|
||||
(h.finish(), radius.to_bits(), at.map(f64::to_bits))
|
||||
});
|
||||
let key = format!(
|
||||
"{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}",
|
||||
st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview
|
||||
"{:?}:{}:{:?}:{:?}:{map:?}:{}:{}:{}:{}:{wrap}:{}:{}",
|
||||
st.doc.id,
|
||||
st.revision,
|
||||
st.active_layer,
|
||||
at.map(f64::to_bits),
|
||||
app.ui.tool_options.clone_sample,
|
||||
display_key,
|
||||
overlay.invert,
|
||||
overlay.blend,
|
||||
overlay.clipped,
|
||||
radius.to_bits()
|
||||
);
|
||||
if app.clone_preview.as_ref().is_none_or(|c| c.key != key) {
|
||||
let Ok(mut buf) =
|
||||
photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview)
|
||||
else {
|
||||
let brush = overlay.clipped.then_some(&app.session.tools.brush);
|
||||
if app.clone_preview.as_ref().is_none_or(|c| c.key != key || c.brush.as_ref() != brush) {
|
||||
// Clipped (Photoshop's default): the dab the tool paints here, so a soft brush previews
|
||||
// soft and a shaped tip keeps its outline. Unclipped: the brush's whole square.
|
||||
let (rect, coverage) = match brush {
|
||||
Some(b) => {
|
||||
let Some((rect, coverage)) = clone_dab_coverage(b, at, &map) else { return };
|
||||
(rect, Some(coverage))
|
||||
}
|
||||
None => (
|
||||
DRect::new((at[0] - radius).floor() as i32, (at[1] - radius).floor() as i32, (at[0] + radius).ceil() as i32, (at[1] + radius).ceil() as i32),
|
||||
None,
|
||||
),
|
||||
};
|
||||
let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, wrap) else {
|
||||
return;
|
||||
};
|
||||
if overlay.invert {
|
||||
@@ -59,55 +131,43 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
|
||||
// composite, whatever Sample reads), so Difference turns black where they line up.
|
||||
if overlay.blend != "normal" {
|
||||
let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] };
|
||||
let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else {
|
||||
let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", wrap) else {
|
||||
return;
|
||||
};
|
||||
for (px, d) in buf.px.iter_mut().zip(&under.px) {
|
||||
*px = overlay_blend(&overlay.blend, *px, *d);
|
||||
}
|
||||
}
|
||||
if let Some(rings) = &tip_rings {
|
||||
let mask = crate::brush_cursor::tip_mask(rings, at, radius, [rect.x0, rect.y0, rect.x1, rect.y1]);
|
||||
for (px, inside) in buf.px.iter_mut().zip(mask) {
|
||||
if !inside {
|
||||
*px = [0.0; 4];
|
||||
}
|
||||
if let Some(coverage) = &coverage {
|
||||
for (px, c) in buf.px.iter_mut().zip(coverage) {
|
||||
px[3] *= c.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let image = display_image(display.as_deref(), &buf);
|
||||
let brush = brush.cloned();
|
||||
match app.clone_preview.as_mut() {
|
||||
Some(cache) => {
|
||||
cache.texture.set(image, canvas_texture_options(1));
|
||||
cache.key = key;
|
||||
cache.brush = brush;
|
||||
cache.rect = rect;
|
||||
}
|
||||
None => {
|
||||
let texture = painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1));
|
||||
app.clone_preview = Some(ClonePreviewCache { key, brush, rect, texture });
|
||||
}
|
||||
None => app.clone_preview = Some(ClonePreviewCache { key, texture: painter.ctx().load_texture("clone-preview", image, canvas_texture_options(1)) }),
|
||||
}
|
||||
}
|
||||
let Some(cache) = &app.clone_preview else { return };
|
||||
let rect = cache.rect;
|
||||
let tint = Color32::from_white_alpha((overlay.opacity.clamp(0.0, 100.0) * 2.55).round() as u8);
|
||||
// A textured triangle fan provides a circular clip, including mirrored canvas views.
|
||||
// A textured quad over the covered area: its texels carry the clip, in mirrored and rotated
|
||||
// canvas views too.
|
||||
let mut mesh = egui::Mesh::with_texture(cache.texture.id());
|
||||
let vertex = |x: f64, y: f64| egui::epaint::Vertex {
|
||||
pos: xf.to_screen(x as f32, y as f32),
|
||||
uv: pos2((x - f64::from(rect.x0)) as f32 / rect.width() as f32, (y - f64::from(rect.y0)) as f32 / rect.height() as f32),
|
||||
color: tint,
|
||||
};
|
||||
if !overlay.clipped || tip_rings.is_some() {
|
||||
for (x, y) in [(rect.x0, rect.y0), (rect.x1, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1)] {
|
||||
mesh.vertices.push(vertex(f64::from(x), f64::from(y)));
|
||||
}
|
||||
mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
return;
|
||||
}
|
||||
mesh.vertices.push(vertex(at[0], at[1]));
|
||||
for i in 0..=64 {
|
||||
let angle = f64::from(i) * std::f64::consts::TAU / 64.0;
|
||||
mesh.vertices.push(vertex(at[0] + radius * angle.cos(), at[1] + radius * angle.sin()));
|
||||
}
|
||||
for i in 1..=64 {
|
||||
mesh.indices.extend_from_slice(&[0, i, i + 1]);
|
||||
for (x, y, u, v) in [(rect.x0, rect.y0, 0.0, 0.0), (rect.x1, rect.y0, 1.0, 0.0), (rect.x1, rect.y1, 1.0, 1.0), (rect.x0, rect.y1, 0.0, 1.0)] {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: tint });
|
||||
}
|
||||
mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
@@ -115,8 +175,8 @@ pub(super) fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painte
|
||||
/// Healing Brush and History Brush composite their source.
|
||||
pub(super) enum EngineStroke {
|
||||
Brush(Box<photocraft_engine::brush_cmds::LiveStroke>),
|
||||
Retouch(photocraft_engine::retouch_cmds::LiveRetouch),
|
||||
Dab(photocraft_engine::retouch_cmds::LiveDab),
|
||||
Retouch(Box<photocraft_engine::retouch_cmds::LiveRetouch>),
|
||||
Dab(Box<photocraft_engine::retouch_cmds::LiveDab>),
|
||||
Mixer(Box<photocraft_engine::brush_cmds::mixer_live::LiveMixer>),
|
||||
}
|
||||
|
||||
@@ -304,32 +364,32 @@ pub(super) fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = crate::retouch_ui::color_replacement_params(app);
|
||||
p["points"] = json!(d.points);
|
||||
EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?))
|
||||
} else if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) {
|
||||
// The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed.
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?))
|
||||
} else if d.tool == Tool::PatternStamp {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = crate::retouch_ui::pattern_stamp_params(app, &d.points);
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
if app.ui.tool_options.pattern_stamp_impressionist {
|
||||
EngineStroke::Dab(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::retouch_cmds::live_pattern_impressionist(&app.session, &p).ok()?))
|
||||
} else {
|
||||
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?)
|
||||
EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, "paint.patternStamp", &p).ok()?))
|
||||
}
|
||||
} else if d.tool == Tool::BackgroundEraser {
|
||||
// The params `eraser_ui::finish_stroke` commits (layer pixels only).
|
||||
let p = crate::eraser_ui::background_eraser_params(app, &d.points);
|
||||
EngineStroke::Dab(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?)
|
||||
EngineStroke::Dab(Box::new(photocraft_engine::eraser_cmds::live_background_eraser(&app.session, &p).ok()?))
|
||||
} else if let Some(cmd) = live_retouch_command(d.tool) {
|
||||
// The params `retouch_ui::finish_stroke` commits.
|
||||
let mut p = if d.tool == Tool::HistoryBrush { crate::retouch_ui::history_brush_params(app) } else { crate::retouch_ui::clone_params(app)? };
|
||||
p["points"] = json!(d.points);
|
||||
p["target"] = paint_target(app);
|
||||
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)
|
||||
EngineStroke::Retouch(Box::new(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?))
|
||||
} else {
|
||||
let p = stroke_params(app, d, &app.stylus.stroke_points(&d.points));
|
||||
EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(app, d.tool), &p).ok()?))
|
||||
@@ -433,12 +493,12 @@ pub(super) fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) {
|
||||
match stroke {
|
||||
EngineStroke::Retouch(mut live) if !dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_retouch(live);
|
||||
let _ = app.session.offer_live_retouch(*live);
|
||||
}
|
||||
}
|
||||
EngineStroke::Dab(mut live) if dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_dab(live);
|
||||
let _ = app.session.offer_live_dab(*live);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -436,9 +436,13 @@ fn fit_doc_tabs(natural: &[f32], selected: usize, avail: f32) -> crate::tab_stri
|
||||
/// Photoshop's tab title: "name @ 50% (Layer 1, RGB/8)", "(Layer 1, Layer Mask/8)" when the mask
|
||||
/// is targeted, "(Quick Mask/8)" in Quick Mask mode; the Background layer's name is omitted.
|
||||
pub(super) fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String {
|
||||
tab_title_at(app, i, app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0))
|
||||
}
|
||||
|
||||
/// [`pro_tab_title`] at `zoom` percent (a workspace with its own view, such as Select and Mask).
|
||||
pub(crate) fn tab_title_at(app: &PhotocraftApp, i: usize, zoom: f32) -> String {
|
||||
use photocraft_engine::channel_cmds::ChannelTarget;
|
||||
let Some(st) = app.session.documents().get(i) else { return String::new() };
|
||||
let zoom = app.ui.views.get(i).map_or(100.0, |v| v.zoom * 100.0);
|
||||
// Quick Mask mode targets the mask, not the layer (see `paint_target`): painting and Free
|
||||
// Transform change the red overlay, so the tab says so, as Photoshop's does.
|
||||
let quick_mask = st.doc.quick_mask.is_some() && st.channel_view.target == ChannelTarget::Composite;
|
||||
|
||||
@@ -1175,70 +1175,151 @@ fn dragging_a_tab_off_the_strip_floats_it_in_a_window() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An app with a 64 × 64 white document, a clone source and the Clone Stamp at `size` px.
|
||||
fn clone_overlay_app(size: f32) -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = size;
|
||||
app
|
||||
}
|
||||
|
||||
/// One frame of the overlay at (32, 32): its shapes and texture uploads.
|
||||
fn clone_overlay_frame(ctx: &egui::Context, app: &mut PhotocraftApp, xf: &ViewXform) -> egui::FullOutput {
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(app, &painter, xf, [32.0, 32.0], None);
|
||||
})
|
||||
}
|
||||
|
||||
/// The overlay texture a frame uploaded and the alpha at a document pixel, read from the
|
||||
/// document area the overlay covers (transparent outside it).
|
||||
fn clone_overlay_alpha(app: &PhotocraftApp, output: &mut egui::FullOutput) -> impl Fn(i32, i32) -> u8 + use<> {
|
||||
let cache = app.clone_preview.as_ref().expect("an overlay was drawn");
|
||||
let image = output
|
||||
.textures_delta
|
||||
.set
|
||||
.get(&cache.texture.id())
|
||||
.and_then(|deltas| deltas.last())
|
||||
.map(|d| match &d.image {
|
||||
egui::ImageData::Color(c) => c.clone(),
|
||||
})
|
||||
.expect("the overlay uploads a texture");
|
||||
output.textures_delta.clear();
|
||||
let rect = cache.rect;
|
||||
assert_eq!(image.size, [rect.width() as usize, rect.height() as usize], "the texture covers the overlay's area");
|
||||
move |x, y| {
|
||||
if x < rect.x0 || y < rect.y0 || x >= rect.x1 || y >= rect.y1 {
|
||||
return 0;
|
||||
}
|
||||
image.pixels[(y - rect.y0) as usize * image.width() + (x - rect.x0) as usize].a()
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_overlay_view(side: f32, zoom: f32) -> ViewXform {
|
||||
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(side, side)), zoom, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_overlay_is_circular_and_respects_visibility() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = 20.0;
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let ctx = ui.ctx();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mesh = output
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.shape {
|
||||
egui::Shape::Mesh(m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(mesh.indices.len(), 64 * 3);
|
||||
let center = xf.to_screen(32.0, 32.0);
|
||||
assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01));
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "the overlay is drawn");
|
||||
let alpha = clone_overlay_alpha(&app, &mut output);
|
||||
assert_eq!(alpha(32, 32), 255, "the centre shows the source");
|
||||
assert_eq!(alpha(32 + 8, 32), 255, "inside the hard 10 px radius");
|
||||
assert_eq!(alpha(32 + 8, 32 + 8), 0, "the brush square's corner, outside the circle, is clipped");
|
||||
assert_eq!(alpha(32 + 12, 32), 0, "past the hard tip's rim");
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert!(app.session.presets.clone.active().anchor.is_none());
|
||||
let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone();
|
||||
app.ui.view.pattern_preview = true;
|
||||
let mut periodic_output = ctx.run_ui(Default::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
let mut periodic_output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels");
|
||||
assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit");
|
||||
periodic_output.textures_delta.clear();
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
app.session.presets.clone.overlay.show = false;
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let ctx = ui.ctx();
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
output.textures_delta.clear();
|
||||
assert!(output.shapes.iter().all(|s| !matches!(s.shape, egui::Shape::Mesh(_))));
|
||||
}
|
||||
|
||||
/// A soft brush previews soft: the overlay fades towards the rim with the brush's hardness, as
|
||||
/// the stroke it previews does, and reaches past the radius as a soft tip paints. A brush
|
||||
/// change redraws an overlay that hasn't moved.
|
||||
#[test]
|
||||
fn clone_overlay_follows_the_brush_hardness() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
let hard = clone_overlay_alpha(&app, &mut output);
|
||||
app.session.tools.brush.hardness = 0.0;
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
let soft = clone_overlay_alpha(&app, &mut output);
|
||||
assert!(hard(32, 32) == 255 && soft(32, 32) > 240, "both show the source at the centre: {} {}", hard(32, 32), soft(32, 32));
|
||||
assert_eq!(hard(32 + 8, 32), 255);
|
||||
assert!(soft(32 + 8, 32) < 200, "a soft tip fades before its rim: {}", soft(32 + 8, 32));
|
||||
assert!(soft(32 + 4, 32) > soft(32 + 8, 32), "and fades outwards");
|
||||
assert!(hard(32 + 12, 32) == 0 && soft(32 + 13, 32) > 0, "a soft tip reaches past the radius, a hard one doesn't");
|
||||
// The largest brush the overlay draws still shows at its softest.
|
||||
let mut big = clone_overlay_app(1000.0);
|
||||
big.session.tools.brush.hardness = 0.0;
|
||||
let mut output = clone_overlay_frame(&ctx, &mut big, &xf);
|
||||
assert_eq!(clone_overlay_alpha(&big, &mut output)(32, 32), 255, "a 1000 px soft brush previews too");
|
||||
}
|
||||
|
||||
/// A scaled Clone Source reads a larger source area than the overlay covers: a large brush's
|
||||
/// overlay shrinks around the pointer until that area fits the sampling budget, rather than
|
||||
/// vanishing.
|
||||
#[test]
|
||||
fn clone_overlay_of_a_scaled_source_stays_within_the_sampling_budget() {
|
||||
let ctx = egui::Context::default();
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
for (size, scale) in [(600.0, 200), (1000.0, 300), (1000.0, 50)] {
|
||||
let mut app = clone_overlay_app(size);
|
||||
app.run("cloneSource.set", json!({"width": scale, "height": scale})).unwrap();
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
assert!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), "{size} px at {scale} %: the overlay shows");
|
||||
assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{size} px at {scale} %: the source under the pointer");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Healing Brush shares the Clone Stamp's source and overlay (Photoshop's Clone Source
|
||||
/// panel drives both); other tools show none.
|
||||
#[test]
|
||||
fn clone_overlay_shows_for_the_healing_brush_too() {
|
||||
let ctx = egui::Context::default();
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
for (tool, shown) in [(Tool::Healing, true), (Tool::CloneStamp, true), (Tool::Brush, false), (Tool::SpotHealing, false)] {
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
app.ui.tool = tool;
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
assert_eq!(output.shapes.iter().any(|s| matches!(s.shape, egui::Shape::Mesh(_))), shown, "{tool:?}");
|
||||
if shown {
|
||||
assert_eq!(clone_overlay_alpha(&app, &mut output)(32, 32), 255, "{tool:?}: the source shows under the brush");
|
||||
}
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16, 16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = 20.0;
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 4.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
let xf = clone_overlay_view(200.0, 4.0);
|
||||
for invert in [false, true] {
|
||||
app.session.presets.clone.overlay.invert = invert;
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
draw_clone_preview(&mut app, ui.painter(), &xf, [32.0, 32.0], None);
|
||||
});
|
||||
let upload = output.textures_delta.set.values().flatten().find(|d| d.image.size() == [20, 20]).expect("clone overlay upload");
|
||||
let id = app.clone_preview.as_ref().unwrap().texture.id();
|
||||
let upload = output.textures_delta.set.get(&id).and_then(|d| d.last()).expect("clone overlay upload");
|
||||
let options = upload.options;
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(options.magnification, egui::TextureFilter::Nearest, "invert={invert}");
|
||||
@@ -1249,27 +1330,20 @@ fn clone_overlay_keeps_magnified_pixels_crisp_on_upload_and_refresh() {
|
||||
#[test]
|
||||
fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = 20.0;
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
// The centre texel of the overlay texture uploaded for `mode`.
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
// The overlay texel at the brush centre for `mode`.
|
||||
let mut centre = |mode: &str| {
|
||||
app.run("cloneSource.overlay", json!({"blend": mode})).unwrap();
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
// The brush's 20 × 20 px square (the font atlas may upload too).
|
||||
let image = output.textures_delta.set.values().flatten().find_map(|d| match &d.image {
|
||||
egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()),
|
||||
_ => None,
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
let cache = app.clone_preview.as_ref().unwrap();
|
||||
let image = output.textures_delta.set.get(&cache.texture.id()).and_then(|d| d.last()).map(|d| match &d.image {
|
||||
egui::ImageData::Color(c) => c.clone(),
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let image = image.expect("the overlay uploads a texture");
|
||||
image.pixels[image.width() * (image.height() / 2) + image.width() / 2]
|
||||
let rect = cache.rect;
|
||||
image.pixels[(32 - rect.y0) as usize * image.width() + (32 - rect.x0) as usize]
|
||||
};
|
||||
// A white source over the white image: Normal shows white, Difference black.
|
||||
assert_eq!(centre("normal"), Color32::WHITE);
|
||||
@@ -1281,32 +1355,14 @@ fn clone_overlay_blend_mode_mixes_with_the_image_under_the_brush() {
|
||||
#[test]
|
||||
fn clone_overlay_is_clipped_to_a_shaped_tip() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 64, "height": 64})).unwrap();
|
||||
app.run("cloneSource.set", json!({"source": [16,16]})).unwrap();
|
||||
app.ui.tool = Tool::CloneStamp;
|
||||
app.session.tools.brush.size = 20.0;
|
||||
let mut app = clone_overlay_app(20.0);
|
||||
app.session.tools.brush.roundness = 0.25;
|
||||
let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(100.0, 100.0)), zoom: 1.0, center: [32.0, 32.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let mut output = ctx.run_ui(Default::default(), |ui| {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("clone-test")));
|
||||
draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None);
|
||||
});
|
||||
let image = output
|
||||
.textures_delta
|
||||
.set
|
||||
.values()
|
||||
.flatten()
|
||||
.find_map(|d| match &d.image {
|
||||
egui::ImageData::Color(c) if c.size == [20, 20] => Some(c.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the overlay uploads a texture");
|
||||
output.textures_delta.clear();
|
||||
let alpha = |x: usize, y: usize| image.pixels[y * image.width() + x].a();
|
||||
assert_eq!(alpha(10, 10), 255, "the tip's centre shows the source");
|
||||
assert_eq!(alpha(10, 2), 0, "inside the circle but outside the flat tip: clipped");
|
||||
assert_eq!(alpha(1, 10), 255, "the tip's long axis stays");
|
||||
let xf = clone_overlay_view(100.0, 1.0);
|
||||
let mut output = clone_overlay_frame(&ctx, &mut app, &xf);
|
||||
let alpha = clone_overlay_alpha(&app, &mut output);
|
||||
assert_eq!(alpha(32, 32), 255, "the tip's centre shows the source");
|
||||
assert_eq!(alpha(32, 24), 0, "inside the circle but outside the flat tip: clipped");
|
||||
assert!(alpha(25, 32) > 240, "the tip's long axis stays: {}", alpha(25, 32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -40,12 +40,6 @@ pub(super) fn transform_controls_layers(app: &PhotocraftApp) -> Vec<&photocraft_
|
||||
st.selected_layers().into_iter().filter_map(|id| st.doc.layer(id)).filter(|l| !crate::doc_props_ui::is_background(&st.doc, l)).collect()
|
||||
}
|
||||
|
||||
/// Several layers are selected: Show Transform Controls frames them all, as Photoshop does, but
|
||||
/// Free Transform works on one layer, so the frame has no handles to scale or rotate them.
|
||||
pub(super) fn transform_controls_several(app: &PhotocraftApp) -> bool {
|
||||
transform_controls_layers(app).len() > 1
|
||||
}
|
||||
|
||||
/// Move tool › Show Transform Controls: the boxes Free Transform would open on the selected
|
||||
/// layers, joined into one.
|
||||
pub(super) fn transform_controls_rect(app: &PhotocraftApp, xf: &ViewXform) -> Option<Rect> {
|
||||
@@ -110,11 +104,9 @@ pub(super) fn transform_controls_hit(r: Rect, p: Pos2) -> bool {
|
||||
transform_controls_cursor(r, p).is_some()
|
||||
}
|
||||
|
||||
/// Enter the existing Free Transform session when a Move-tool transform control is pressed.
|
||||
/// Enter the existing Free Transform session when a Move-tool transform control is pressed: on
|
||||
/// every selected layer, as the frame shows them (#6501).
|
||||
pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::Context, xf: &ViewXform, p: Pos2) -> bool {
|
||||
if transform_controls_several(app) {
|
||||
return false;
|
||||
}
|
||||
let Some(r) = transform_controls_rect(app, xf) else { return false };
|
||||
if !transform_controls_hit(r, p) {
|
||||
return false;
|
||||
@@ -128,8 +120,8 @@ pub(super) fn begin_transform_controls_at(app: &mut PhotocraftApp, ctx: &egui::C
|
||||
}
|
||||
}
|
||||
|
||||
/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles
|
||||
/// for one layer, and the outline of every selected shape layer, as in Photoshop.
|
||||
/// Move tool › Show Transform Controls: the selected layers' bounding box, with its eight handles,
|
||||
/// and the outline of every selected shape layer, as in Photoshop.
|
||||
pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
let Some(r) = transform_controls_rect(app, xf) else { return };
|
||||
let accent = crate::theme::Tokens::get(painter.ctx()).accent;
|
||||
@@ -140,9 +132,6 @@ pub(super) fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::P
|
||||
}
|
||||
}
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, accent), egui::StrokeKind::Middle);
|
||||
if transform_controls_several(app) {
|
||||
return;
|
||||
}
|
||||
for p in [r.left_top(), r.center_top(), r.right_top(), r.right_center(), r.right_bottom(), r.center_bottom(), r.left_bottom(), r.left_center()] {
|
||||
let h = Rect::from_center_size(p, vec2(7.0, 7.0));
|
||||
painter.rect_filled(h, 0.0, Color32::WHITE);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() {
|
||||
app.session.execute("select.rect", json!({"x": 20, "y": 20, "width": 100, "height": 90})).unwrap();
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.tool_options.move_show_transform = true;
|
||||
assert!(transform_controls_several(&app), "two layers framed");
|
||||
assert_eq!(transform_controls_layers(&app).len(), 2, "two layers framed");
|
||||
app.session.execute("select.float", json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert!(photocraft_engine::float_cmds::floating(app.session.active().unwrap()).is_some());
|
||||
let xf =
|
||||
@@ -126,34 +126,36 @@ fn a_floating_piece_among_several_selected_layers_keeps_the_shared_frame() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_selected_layers_share_one_frame_without_handles() {
|
||||
// As in Photoshop: Show Transform Controls frames every selected layer. Free Transform
|
||||
// works on one layer, so the shared frame doesn't start it.
|
||||
fn several_selected_layers_share_one_frame_that_transforms_them_all() {
|
||||
// As in Photoshop: Show Transform Controls frames every selected layer, and its handles
|
||||
// start one Free Transform of all of them (#6501).
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 40, 40], "fill": "#ff0000"})).unwrap();
|
||||
let a = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.execute("shape.create", json!({"kind": "rect", "rect": [100, 120, 50, 30], "fill": "#ff0000"})).unwrap();
|
||||
let b = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.sync_views();
|
||||
app.ui.tool = Tool::Move;
|
||||
app.ui.tool_options.move_show_transform = true;
|
||||
let xf =
|
||||
ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), zoom: 1.0, center: [100.0, 100.0], flip: false, rotation: 0.0, aspect: 1.0 };
|
||||
let one = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert!(!transform_controls_several(&app));
|
||||
app.session.execute("layer.select", json!({"layer": a.0, "mode": "add"})).unwrap();
|
||||
assert!(transform_controls_several(&app));
|
||||
let both = transform_controls_rect(&app, &xf).unwrap();
|
||||
assert!(both.contains_rect(one) && both.width() > one.width() && both.height() > one.height(), "{both:?} frames both");
|
||||
let (lt, rb) = (xf.to_doc(both.left_top()), xf.to_doc(both.right_bottom()));
|
||||
assert!((lt[0] - 20.0).abs() < 1e-3 && (lt[1] - 30.0).abs() < 1e-3 && (rb[0] - 150.0).abs() < 1e-3 && (rb[1] - 150.0).abs() < 1e-3, "{lt:?} {rb:?}");
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()));
|
||||
assert!(app.ui.transform.is_none());
|
||||
// The Background never joins the frame.
|
||||
let bg = app.session.active().unwrap().doc.layers[0].id;
|
||||
app.session.execute("layer.select", json!({"layer": bg.0, "mode": "add"})).unwrap();
|
||||
assert_eq!(transform_controls_layers(&app).len(), 2);
|
||||
assert_eq!(transform_controls_rect(&app, &xf), Some(both));
|
||||
let ctx = egui::Context::default();
|
||||
assert!(begin_transform_controls_at(&mut app, &ctx, &xf, both.right_bottom()));
|
||||
let t = app.ui.transform.as_ref().expect("the shared frame's handle starts Free Transform");
|
||||
assert_eq!(t.rect, [20.0, 30.0, 150.0, 150.0]);
|
||||
assert_eq!(t.layers, [a.0, b.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -167,7 +167,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() {
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
|
||||
app.run("select.rect", json!({"x":8,"y":8,"width":8,"height":8})).unwrap();
|
||||
for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle"] {
|
||||
for workspace in ["filter.liquify", "edit.contentAwareFill", "filter.filterGallery", "filter.adaptiveWideAngle", "select.selectAndMask"] {
|
||||
let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id":workspace}));
|
||||
assert_eq!(opened["ok"], true, "{workspace}: {opened}");
|
||||
assert_eq!(crate::distort_ui::modal_workspace(&app), Some(workspace));
|
||||
@@ -177,6 +177,7 @@ fn full_window_workspaces_are_modal_for_menu_invocations() {
|
||||
json!({"id":"select.all"}),
|
||||
json!({"id":"view.zoomIn"}),
|
||||
json!({"id":"filter.cameraRaw"}),
|
||||
json!({"id":"filter.liquify"}),
|
||||
json!({"id":workspace,"params":{"ui":null}}),
|
||||
] {
|
||||
let blocked = call(&mut app, &ctx, "ui.menu.invoke", request.clone());
|
||||
|
||||
@@ -1122,6 +1122,7 @@ pub mod rasterize_prompt;
|
||||
pub mod replace_prompt;
|
||||
pub(super) mod rgb_histogram;
|
||||
pub mod screen_picker;
|
||||
pub mod select_mask_ui;
|
||||
pub mod shape_dialog;
|
||||
pub(crate) mod shape_fill_ui;
|
||||
pub(super) mod sizing;
|
||||
|
||||
@@ -149,6 +149,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
}
|
||||
|
||||
pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::color_picker_ui::any_open(app) {
|
||||
return;
|
||||
}
|
||||
let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
|
||||
if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) {
|
||||
redo(app);
|
||||
|
||||
@@ -321,6 +321,13 @@ where
|
||||
(hex(to_rgb(v)) == hex(rgb)).then_some(v)
|
||||
}
|
||||
|
||||
/// The CMYK values typed into the picker, while they still match its colour: the inks a command
|
||||
/// should keep in a CMYK document (#4196, #8568).
|
||||
pub(crate) fn picked_cmyk(f: &Map<String, Value>) -> Option<[f32; 4]> {
|
||||
let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex)?;
|
||||
kept::<4>(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb)
|
||||
}
|
||||
|
||||
/// Current colour, keeping the stored HSB, Lab or CMYK values while they still match `color`.
|
||||
pub fn current(f: &Map<String, Value>) -> Components {
|
||||
let rgb = f.get("color").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]);
|
||||
@@ -751,6 +758,12 @@ pub fn take_add_swatch(app: &mut PhotocraftApp, f: &mut Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Color Picker dialog is open: a workspace under it (Content-Aware Fill, Select and
|
||||
/// Mask) leaves the keys to the picker, as it did for the swatch popups the picker replaced.
|
||||
pub fn any_open(app: &PhotocraftApp) -> bool {
|
||||
app.ui.dialogs.iter().any(|d| owns(&d.fields))
|
||||
}
|
||||
|
||||
/// A colour swatch widget asked for the Color Picker on its colour ([`request_for_widget`]).
|
||||
#[derive(Clone)]
|
||||
struct WidgetRequest {
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct Distort {
|
||||
pub liquify: Option<crate::liquify_ui::LiquifyDialog>,
|
||||
/// Edit › Content-Aware Fill… (caf_ui).
|
||||
pub caf: Option<crate::caf_ui::CafWorkspace>,
|
||||
/// Select › Select and Mask… (select_mask_ui).
|
||||
pub select_mask: Option<crate::select_mask_ui::SelectMaskWorkspace>,
|
||||
pub puppet: Option<crate::puppet_ui::PuppetSession>,
|
||||
pub perspective: Option<crate::perspective_ui::PerspSession>,
|
||||
pub vanishing: Option<crate::vp_ui::VpSession>,
|
||||
@@ -35,7 +37,12 @@ pub struct Distort {
|
||||
|
||||
impl Distort {
|
||||
pub fn active(&self) -> bool {
|
||||
self.liquify.is_some() || self.caf.is_some() || self.puppet.is_some() || self.perspective.is_some() || self.vanishing.is_some()
|
||||
self.liquify.is_some()
|
||||
|| self.caf.is_some()
|
||||
|| self.select_mask.is_some()
|
||||
|| self.puppet.is_some()
|
||||
|| self.perspective.is_some()
|
||||
|| self.vanishing.is_some()
|
||||
}
|
||||
|
||||
/// Short description for `ui.inspect`.
|
||||
@@ -43,6 +50,7 @@ impl Distort {
|
||||
json!({
|
||||
"liquify": self.liquify.as_ref().map(|d| d.describe()),
|
||||
"contentAwareFill": self.caf.as_ref().map(|d| d.describe()),
|
||||
"selectAndMask": self.select_mask.as_ref().map(|d| d.describe()),
|
||||
"puppet": self.puppet.as_ref().map(|p| p.describe()),
|
||||
"perspective": self.perspective.as_ref().map(|p| p.describe()),
|
||||
"vanishingPoint": self.vanishing.as_ref().map(|v| v.describe()),
|
||||
@@ -95,7 +103,9 @@ pub(crate) fn surface_image(surf: &Surface, r: Rect, max_side: usize) -> ColorIm
|
||||
/// Adaptive Wide Angle). These are modal: other menu commands wait for OK or Cancel
|
||||
/// (`menus::modal_allows`).
|
||||
pub(crate) fn modal_workspace(app: &PhotocraftApp) -> Option<&'static str> {
|
||||
if app.distort.liquify.is_some() {
|
||||
if app.distort.select_mask.is_some() {
|
||||
Some("select.selectAndMask")
|
||||
} else if app.distort.liquify.is_some() {
|
||||
Some("filter.liquify")
|
||||
} else if app.distort.caf.is_some() {
|
||||
Some("edit.contentAwareFill")
|
||||
@@ -116,7 +126,7 @@ fn on_doc(app: &PhotocraftApp, document: DocId) -> bool {
|
||||
active_doc(app) == Some(document)
|
||||
}
|
||||
|
||||
/// Drops Liquify / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the
|
||||
/// Drops Liquify / Select and Mask / Puppet / Perspective Warp / Vanishing Point / Filter Gallery when the
|
||||
/// document they were opened on is no longer active, so they cannot steal undo, block layer
|
||||
/// delete, or draw another document's pixels (#5115).
|
||||
pub fn cancel_stale(app: &mut PhotocraftApp) {
|
||||
@@ -136,6 +146,10 @@ pub fn cancel_stale(app: &mut PhotocraftApp) {
|
||||
if app.distort.gallery.as_ref().is_some_and(|g| Some(g.document) != active) {
|
||||
app.distort.gallery = None;
|
||||
}
|
||||
// Its document, or the layer it refines, is no longer the active one (#8896 review).
|
||||
if crate::select_mask_ui::stale(app).is_some() {
|
||||
crate::select_mask_ui::cancel(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document shown on the canvas while Puppet or Perspective Warp previews a layer, or
|
||||
@@ -171,6 +185,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
|
||||
"edit.contentAwareFill" if ui.is_some() && app.distort.caf.is_some() => Some(crate::caf_ui::control(app, ui.unwrap_or(&Value::Null))),
|
||||
// `ui` addresses the workspace: closed, that is an error, not a fill with the defaults.
|
||||
"edit.contentAwareFill" if ui.is_some() => Some(Err("Content-Aware Fill is not open".into())),
|
||||
"select.selectAndMask" if ui.is_some() && app.distort.select_mask.is_some() => Some(crate::select_mask_ui::control(app, ui.unwrap_or(&Value::Null))),
|
||||
"select.selectAndMask" if ui.is_some() => Some(Err("Select and Mask is not open".into())),
|
||||
"select.selectAndMask" => Some(crate::select_mask_ui::open(app).map(|_| json!({"selectAndMask": app.distort.describe()["selectAndMask"]}))),
|
||||
"edit.puppetWarp" | "layer.smartObjects.puppetWarp" if empty => {
|
||||
Some(crate::puppet_ui::begin(app, ctx, id).map(|_| json!({"puppet": app.distort.describe()["puppet"]})))
|
||||
}
|
||||
@@ -196,6 +213,10 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
|
||||
crate::caf_ui::pointer(app, ev, mods);
|
||||
return true;
|
||||
}
|
||||
if app.distort.select_mask.as_ref().is_some_and(|w| on_doc(app, w.document)) {
|
||||
crate::select_mask_ui::pointer(app, ev, mods);
|
||||
return true;
|
||||
}
|
||||
if app.distort.liquify.as_ref().is_some_and(|d| on_doc(app, d.document)) {
|
||||
crate::liquify_ui::pointer(app, ev, mods);
|
||||
return true;
|
||||
@@ -238,6 +259,15 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if app.distort.select_mask.is_some() {
|
||||
// Enter closes a Polygonal Lasso polygon, else OK; Esc drops one, else Cancel.
|
||||
if enter(ctx) {
|
||||
crate::select_mask_ui::enter(app);
|
||||
} else if esc(ctx) {
|
||||
crate::select_mask_ui::escape(app);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if app.distort.liquify.is_some() {
|
||||
if enter(ctx) {
|
||||
let _ = crate::liquify_ui::commit(app);
|
||||
@@ -348,6 +378,7 @@ pub fn drop_foreign(app: &mut PhotocraftApp) {
|
||||
app.distort.liquify = None;
|
||||
// Stops its background preview too; what Apply wrote stays, as with Cancel.
|
||||
crate::caf_ui::cancel(app);
|
||||
crate::select_mask_ui::cancel(app);
|
||||
app.distort.puppet = None;
|
||||
app.distort.perspective = None;
|
||||
app.distort.vanishing = None;
|
||||
@@ -363,6 +394,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if app.distort.caf.is_some() {
|
||||
crate::caf_ui::show(app, ctx);
|
||||
}
|
||||
if app.distort.select_mask.is_some() {
|
||||
crate::select_mask_ui::show(app, ctx);
|
||||
}
|
||||
if app.distort.gallery.is_some() {
|
||||
crate::gallery_ui::show(app, ctx);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,14 @@ fn remember(app: &mut PhotocraftApp, f: &Map<String, Value>) {
|
||||
}
|
||||
|
||||
pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let size = app.session.active().ok_or("no document")?.doc.size;
|
||||
open_sized(app, size.width, size.height)
|
||||
}
|
||||
|
||||
/// The dialog with its size fields fitted to a `src_w` × `src_h` source: the document, or the
|
||||
/// layer Layer › Export As exports. Fitting the document first would replace a remembered pixel
|
||||
/// size the document can't take with the percent, even when the layer can take it.
|
||||
fn open_sized(app: &mut PhotocraftApp, src_w: u32, src_h: u32) -> Result<u64, String> {
|
||||
let mut f = Map::new();
|
||||
f.insert("__export".into(), json!(true));
|
||||
f.insert("__label".into(), json!("Export As"));
|
||||
@@ -106,42 +113,54 @@ pub fn open(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
f.insert("lockAspect".into(), json!(true));
|
||||
f.insert("profile".into(), json!("document"));
|
||||
f.insert("embedProfile".into(), json!(true));
|
||||
f.insert("width".into(), json!(st.doc.size.width));
|
||||
f.insert("height".into(), json!(st.doc.size.height));
|
||||
f.insert("__w".into(), json!(st.doc.size.width));
|
||||
f.insert("__h".into(), json!(st.doc.size.height));
|
||||
f.insert("width".into(), json!(src_w));
|
||||
f.insert("height".into(), json!(src_h));
|
||||
f.insert("__w".into(), json!(src_w));
|
||||
f.insert("__h".into(), json!(src_h));
|
||||
seed_remembered(app, &mut f);
|
||||
let explicit = s(&f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height");
|
||||
if !explicit && let Ok(size) = resize_from_scale(f64::from(st.doc.size.width), f64::from(st.doc.size.height), n(&f, "scale", 100.0)) {
|
||||
fit_size(&mut f, f64::from(src_w), f64::from(src_h));
|
||||
Ok(app.ui.open_dialog(DialogKind::Command, f))
|
||||
}
|
||||
|
||||
/// The size fields for exporting a `src_w` × `src_h` source. A remembered percent scales it.
|
||||
/// Remembered exact pixels with Constrain proportions keep their width and take the height from
|
||||
/// this source, as ticking the checkbox does: the last export's proportions would stretch an
|
||||
/// image of another shape (#8738). Unlinked, the exact pixels are the size the user asked for.
|
||||
/// A linked width this source can't reach within the Scale field's 1..=1000 % falls back to the
|
||||
/// remembered percent.
|
||||
fn fit_size(f: &mut Map<String, Value>, src_w: f64, src_h: f64) {
|
||||
if s(f, "sizing") == "pixels" && f.contains_key("width") && f.contains_key("height") {
|
||||
if !f.get("lockAspect").and_then(Value::as_bool).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
if let Ok(size) = resize_from_side(src_w, src_h, n(f, "width", src_w.max(1.0)), n(f, "height", src_h.max(1.0)), true, true)
|
||||
&& (1.0..=1000.0).contains(&size.scale)
|
||||
{
|
||||
write_size(f, size, "pixels");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Ok(size) = resize_from_scale(src_w, src_h, n(f, "scale", 100.0)) {
|
||||
f.insert("width".into(), json!(size.width));
|
||||
f.insert("height".into(), json!(size.height));
|
||||
if (n(&f, "scale", 100.0) - size.scale).abs() > 1e-9 {
|
||||
if (n(f, "scale", 100.0) - size.scale).abs() > 1e-9 {
|
||||
f.insert("scale".into(), json!(size.scale));
|
||||
}
|
||||
f.insert("sizing".into(), json!("scale"));
|
||||
}
|
||||
Ok(app.ui.open_dialog(DialogKind::Command, f))
|
||||
}
|
||||
|
||||
/// Layer › Export As…: the same dialog for just the active layer (trimmed to its pixels).
|
||||
pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result<u64, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let ldoc = photocraft_engine::layer_menu_cmds::layer_document(&st.doc, layer).map_err(|e| e.to_string())?;
|
||||
let id = open(app)?;
|
||||
// The size fields follow the layer's trimmed size, so the dialog shows and edits the size
|
||||
// the layer exports at.
|
||||
let id = open_sized(app, ldoc.size.width, ldoc.size.height)?;
|
||||
if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) {
|
||||
let f = &mut d.fields;
|
||||
f.insert("__layer".into(), json!(layer.0));
|
||||
f.insert("__label".into(), json!(crate::i18n::fmt(crate::i18n::t("Export As: {name}"), &[("name", &ldoc.name)])));
|
||||
f.insert("__w".into(), json!(ldoc.size.width));
|
||||
f.insert("__h".into(), json!(ldoc.size.height));
|
||||
// `open` seeded width and height from the document; in scale mode they follow the layer's
|
||||
// trimmed size, so the dialog shows and edits the size the layer exports at. Exact pixels
|
||||
// (sizing "pixels") are a size the user asked for, so those stay.
|
||||
if s(f, "sizing") != "pixels"
|
||||
&& let Ok(size) = resize_from_scale(f64::from(ldoc.size.width), f64::from(ldoc.size.height), n(f, "scale", 100.0))
|
||||
{
|
||||
write_size(f, size, "scale");
|
||||
}
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
@@ -342,17 +361,18 @@ pub(crate) fn resize_from_scale(src_w: f64, src_h: f64, scale: f64) -> Result<Ex
|
||||
Ok(ExportSize { width, height, scale })
|
||||
}
|
||||
|
||||
/// An edited side. With `lock`, the other side keeps the source aspect; otherwise it stays.
|
||||
/// An edited side. With `lock`, the other side keeps the source aspect, never below 1 px (as
|
||||
/// [`scaled_size`]); otherwise it stays.
|
||||
pub(crate) fn resize_from_side(src_w: f64, src_h: f64, width: f64, height: f64, lock: bool, edited_width: bool) -> Result<ExportSize, String> {
|
||||
let src_w = src_w.max(1.0);
|
||||
let src_h = src_h.max(1.0);
|
||||
let (width, height) = if edited_width {
|
||||
let width = finite_px(width)?;
|
||||
let height = if lock { finite_px(src_h * width / src_w)? } else { finite_px(height)? };
|
||||
let height = if lock { finite_px((src_h * width / src_w).max(1.0))? } else { finite_px(height)? };
|
||||
(width, height)
|
||||
} else {
|
||||
let height = finite_px(height)?;
|
||||
let width = if lock { finite_px(src_w * height / src_h)? } else { finite_px(width)? };
|
||||
let width = if lock { finite_px((src_w * height / src_h).max(1.0))? } else { finite_px(width)? };
|
||||
(width, height)
|
||||
};
|
||||
let scale = if edited_width { width / src_w * 100.0 } else { height / src_h * 100.0 };
|
||||
|
||||
@@ -74,8 +74,9 @@ fn a_replayed_export_needs_a_path_and_a_known_format() {
|
||||
}
|
||||
|
||||
/// Layer › Export As opens with the layer's trimmed size, not the document's: `open` seeds the
|
||||
/// size fields from the document, and the layer replaces them in scale mode. A remembered
|
||||
/// percent scales the layer; remembered exact pixels stay the size the user asked for.
|
||||
/// size fields to the document, and the layer refits them. A remembered percent scales the
|
||||
/// layer; remembered exact pixels stay the size the user asked for, in the layer's proportions
|
||||
/// when they are linked.
|
||||
#[test]
|
||||
fn layer_export_as_opens_with_the_layer_size() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -94,10 +95,105 @@ fn layer_export_as_opens_with_the_layer_size() {
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(5.0), Some(3.0)));
|
||||
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80})));
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 120, "height": 80, "lockAspect": false})));
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(120.0), Some(80.0)));
|
||||
|
||||
// With Constrain proportions the remembered width stays and the height follows the layer.
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 50, "height": 80, "lockAspect": true})));
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(30.0)));
|
||||
assert_eq!(f["sizing"], "pixels");
|
||||
}
|
||||
|
||||
/// #8738: a pixel size remembered from a landscape export, with Constrain proportions on,
|
||||
/// keeps its width and takes the height from the portrait image being exported, as ticking
|
||||
/// the checkbox does; unlinked, both sides are the exact size asked for. A recorded action
|
||||
/// step is not the remembered size: it replays with exactly the pixels it was recorded with.
|
||||
#[test]
|
||||
fn a_remembered_pixel_size_keeps_this_images_proportions() {
|
||||
let sizes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let log = sizes.clone();
|
||||
let services = crate::Services {
|
||||
export: Some(Box::new(move |doc, _, _| {
|
||||
log.borrow_mut().push((doc.size.width, doc.size.height));
|
||||
Ok((Vec::new(), Vec::new()))
|
||||
})),
|
||||
write: Some(Box::new(|_, _| Ok(()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
app.run("file.new", json!({"width": 30, "height": 40})).unwrap();
|
||||
let landscape = json!({"sizing": "pixels", "width": 20, "height": 15, "scale": 50, "lockAspect": true});
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone()));
|
||||
let id = open(&mut app).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(27.0)));
|
||||
assert_eq!(f["sizing"], "pixels");
|
||||
assert!((f["scale"].as_f64().unwrap() - 200.0 / 3.0).abs() < 1e-9, "{}", f["scale"]);
|
||||
|
||||
let mut unlinked = landscape.clone();
|
||||
unlinked["lockAspect"] = json!(false);
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), unlinked));
|
||||
let id = open(&mut app).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(20.0), Some(15.0)));
|
||||
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), landscape.clone()));
|
||||
let mut step = landscape;
|
||||
step["path"] = json!("/x.png");
|
||||
step["format"] = json!("png");
|
||||
replay(&mut app, &step).unwrap();
|
||||
assert_eq!(*sizes.borrow(), [(20, 15)]);
|
||||
}
|
||||
|
||||
/// A remembered linked width this image can't take within the Scale field's 1..=1000 %
|
||||
/// (2000 px from a 4000 px photo, on a 100 px image) falls back to the remembered percent.
|
||||
#[test]
|
||||
fn a_remembered_pixel_size_out_of_scale_falls_back_to_the_percent() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 2000, "height": 1500, "scale": 50, "lockAspect": true})));
|
||||
let id = open(&mut app).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(50.0), Some(50.0)));
|
||||
assert_eq!((f["sizing"].as_str(), f["scale"].as_f64()), (Some("scale"), Some(50.0)));
|
||||
}
|
||||
|
||||
/// A remembered linked width whose height rounds below one pixel keeps its width: the height
|
||||
/// is one pixel, as the Scale field gives at the same 10 %, and not the remembered percent.
|
||||
#[test]
|
||||
fn a_remembered_pixel_width_keeps_a_one_pixel_height() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 1000, "height": 1})).unwrap();
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 100, "height": 75, "scale": 100, "lockAspect": true})));
|
||||
let id = open(&mut app).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(100.0), Some(1.0)));
|
||||
assert_eq!(f["sizing"], "pixels");
|
||||
// The same for an edited height on a 1 × 1000 image.
|
||||
let size = resize_from_side(1.0, 1000.0, 1.0, 100.0, true, false).unwrap();
|
||||
assert_eq!((size.width, size.height), (1.0, 100.0));
|
||||
}
|
||||
|
||||
/// Layer › Export As fits the remembered size to the layer, not to the document: a remembered
|
||||
/// linked width the tall document can't take (800 px wide is 320 000 px high) still fits the
|
||||
/// 100 × 10 layer at 800 × 80.
|
||||
#[test]
|
||||
fn a_layer_export_fits_the_remembered_size_to_the_layer() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 40000})).unwrap();
|
||||
app.run("layer.new.layer", json!({})).unwrap();
|
||||
app.run("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 10})).unwrap();
|
||||
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
let layer = app.session.active().unwrap().active_layer.unwrap();
|
||||
app.session.prefs.edit(|p| p.dialogs.insert(COMMAND.into(), json!({"sizing": "pixels", "width": 800, "height": 600, "scale": 50, "lockAspect": true})));
|
||||
let id = open_layer(&mut app, layer).unwrap();
|
||||
let f = &app.ui.dialog_mut(id).unwrap().fields;
|
||||
assert_eq!((f["width"].as_f64(), f["height"].as_f64()), (Some(800.0), Some(80.0)));
|
||||
assert_eq!(f["sizing"], "pixels");
|
||||
}
|
||||
|
||||
/// Only the dialog's own settings are remembered: not the destination, the document size or
|
||||
|
||||
@@ -24,7 +24,7 @@ use print::{drag_print_position, driver_paper_is_wide, print_params, print_previ
|
||||
use print::{print_body, print_confirm};
|
||||
pub use web::open_web;
|
||||
#[cfg(test)]
|
||||
use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, web_frame_document};
|
||||
use web::{WEB_COMMAND, WebCache, WebProxy, advance_preview, download_for_web, variants, web_frame_document};
|
||||
use web::{web_body, web_confirm};
|
||||
|
||||
fn no_params(p: &Value) -> bool {
|
||||
@@ -273,6 +273,28 @@ mod web_playback_tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// WebP (#6391): Quality until Lossless is ticked; 4-Up halves a lossy quality like JPEG's.
|
||||
#[test]
|
||||
fn webp_settings_show_quality_until_lossless() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 32, "height": 24})).unwrap();
|
||||
let id = open_web(&mut app);
|
||||
let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone();
|
||||
fields.insert("format".into(), json!("webp"));
|
||||
assert_eq!(fields["lossless"], false);
|
||||
let mut h = harness(app, fields);
|
||||
assert!(h.query_all_by_label("Quality").next().is_some());
|
||||
h.get_by_label("Lossless").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().1["lossless"], true);
|
||||
assert!(h.query_all_by_label("Quality").next().is_none(), "lossless has no quality");
|
||||
|
||||
let v = variants(&json!({"format": "webp", "quality": 80}));
|
||||
assert_eq!((v[1]["format"].as_str(), v[1]["quality"].as_f64(), v[2]["quality"].as_f64()), (Some("webp"), Some(40.0), Some(20.0)));
|
||||
let v = variants(&json!({"format": "webp", "lossless": true}));
|
||||
assert_eq!((v[1]["format"].as_str(), v[2]["format"].as_str()), (Some("png8"), Some("gif")));
|
||||
}
|
||||
|
||||
/// #9174: Save for Web reopens on the preview tab chosen last, as Photoshop's does; 2-Up at
|
||||
/// first, and for a remembered value it doesn't know.
|
||||
#[test]
|
||||
|
||||
@@ -256,6 +256,16 @@ fn save_for_web_dialog_runs_the_command() {
|
||||
let d = app.ui.dialog_mut(r["dialog"].as_u64().unwrap()).unwrap();
|
||||
assert_eq!(d.fields["format"], "gif");
|
||||
assert!(!d.fields.contains_key("path"));
|
||||
// WebP, with the dialog's own defaults (lossy at quality 60).
|
||||
let id = r["dialog"].as_u64().unwrap();
|
||||
let d = app.ui.dialog_mut(id).unwrap();
|
||||
let path = dir.join("web.webp").to_string_lossy().into_owned();
|
||||
d.fields.insert("format".into(), json!("webp"));
|
||||
d.fields.insert("path".into(), json!(path));
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(&bytes[8..12], b"WEBP");
|
||||
assert!(bytes.windows(4).any(|w| w == b"VP8 "), "lossy by default");
|
||||
}
|
||||
|
||||
/// A fresh session's dialog seeds from the preferences: the last export before a restart is
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{Arc, DialogKind, Map, PhotocraftApp, Tokens, Value, WebSettings, b, check, dropdown_str, json, n, number, params, s, web_cmds, web_preview};
|
||||
|
||||
const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")];
|
||||
const WEB_FORMATS: [(&str, &str); 6] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("webp", "WebP"), ("wbmp", "WBMP")];
|
||||
|
||||
pub(super) const WEB_COMMAND: &str = "file.export.saveForWebLegacy";
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 {
|
||||
for (k, v) in [
|
||||
("format", json!("jpeg")),
|
||||
("quality", json!(60)),
|
||||
("lossless", json!(false)),
|
||||
("palette", json!("selective")),
|
||||
("colors", json!(128)),
|
||||
("dither", json!("diffusion")),
|
||||
@@ -57,7 +58,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 {
|
||||
}
|
||||
|
||||
/// Settings variants shown in 4-Up (current, then two smaller alternatives).
|
||||
fn variants(p: &Value) -> Vec<Value> {
|
||||
pub(super) fn variants(p: &Value) -> Vec<Value> {
|
||||
let mut out = vec![p.clone()];
|
||||
let fmt = p["format"].as_str().unwrap_or("jpeg");
|
||||
for k in [2.0, 4.0] {
|
||||
@@ -66,7 +67,7 @@ fn variants(p: &Value) -> Vec<Value> {
|
||||
m.remove("colorTable");
|
||||
m.remove("colorTableEdits");
|
||||
}
|
||||
if fmt == "jpeg" {
|
||||
if fmt == "jpeg" || (fmt == "webp" && !p["lossless"].as_bool().unwrap_or(false)) {
|
||||
v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0));
|
||||
} else if fmt == "gif" || fmt == "png8" {
|
||||
v["colors"] = json!((p["colors"].as_f64().unwrap_or(128.0) / k).round().max(2.0));
|
||||
@@ -138,10 +139,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<
|
||||
None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::<Vec<u8>>(), None),
|
||||
Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{frame:?}|{size_sig}", doc.id.0), p, &px, w, h) {
|
||||
Ok((preview, bytes, colors, ext)) => (preview, Some(((bytes as f64 * ratio) as usize, colors, ext))),
|
||||
Err(error) => {
|
||||
ui.label(error);
|
||||
(vec![0; (w * h * 4) as usize], None)
|
||||
}
|
||||
// The error shows on the row above the panes; a label here sat beside the pane
|
||||
// and widened the dialog (#8830).
|
||||
Err(_) => (vec![0; (w * h * 4) as usize], None),
|
||||
},
|
||||
};
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba);
|
||||
@@ -350,6 +350,14 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<S
|
||||
check(ui, f, "optimized", tl!("Optimized"), true);
|
||||
check(ui, f, "embedIcc", tl!("Embed Color Profile"), false);
|
||||
}
|
||||
"webp" => {
|
||||
check(ui, f, "lossless", tl!("Lossless"), false);
|
||||
if !b(f, "lossless", false) {
|
||||
number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0);
|
||||
}
|
||||
check(ui, f, "transparency", tl!("Transparency"), true);
|
||||
check(ui, f, "embedIcc", tl!("Embed Color Profile"), false);
|
||||
}
|
||||
"wbmp" => {
|
||||
dropdown_str(
|
||||
ui,
|
||||
@@ -366,7 +374,7 @@ pub(super) fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<S
|
||||
check(ui, f, "interlaced", tl!("Interlaced"), false);
|
||||
}
|
||||
}
|
||||
if fmt != "png24" || !b(f, "transparency", true) {
|
||||
if !matches!(fmt.as_str(), "png24" | "webp") || !b(f, "transparency", true) {
|
||||
ui.horizontal(|ui| {
|
||||
let matte_label = ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim));
|
||||
let mut m = s(f, "matte", "#ffffff");
|
||||
|
||||
@@ -124,13 +124,3 @@ fn typing_replaces_the_selected_name_and_enter_confirms() {
|
||||
assert!(h.state().ui.dialogs.is_empty());
|
||||
assert_eq!(groups(h.state())[0].name, "Clouds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_group_dialog_labels_are_translated_in_every_catalog() {
|
||||
const LABELS: &[&str] = &["New Group…", "New Group from Layers…", "Name:", "Color:", "Mode:", "Opacity:", "Pass Through"];
|
||||
for lang in crate::i18n::Lang::all().filter(|lang| lang.complete_menus()) {
|
||||
for label in LABELS {
|
||||
assert!(crate::i18n::has(lang, label), "{} is missing New Group dialog label: {label}", lang.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user