From ccee4894bdab8f34930f1122aab80259bd98d18c Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:28:40 +0200 Subject: [PATCH 001/523] Use craft-print for PhotoCraft system printing --- Cargo.lock | 10 +++ Cargo.toml | 1 + crates/engine/Cargo.toml | 4 + crates/engine/src/jobs.rs | 5 ++ crates/engine/src/print_cmds.rs | 102 ++++++++++++++++---------- crates/engine/src/print_cmds/tests.rs | 68 ++++++++++++++++- crates/ui-egui/src/file_ui.rs | 9 ++- crates/ui-egui/src/jobs_ui.rs | 8 +- crates/ui-egui/src/jobs_ui_tests.rs | 41 +++++++++++ docs/development.md | 18 +++++ 10 files changed, 222 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fde4ac01b8..54187a84f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1121,6 +1121,14 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-print" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +dependencies = [ + "tempfile", +] + [[package]] name = "craft-ui" version = "0.1.0" @@ -4348,6 +4356,7 @@ name = "photocraft-engine" version = "0.7.0" dependencies = [ "blake3", + "craft-print", "exr", "flate2", "photocraft-affinity", @@ -4372,6 +4381,7 @@ dependencies = [ "roxmltree", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "wat", ] diff --git a/Cargo.toml b/Cargo.toml index 7502ff4fd5..1ae1c2f5ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ photocraft-testkit = { path = "crates/testkit" } photocraft-tablet = { path = "crates/tablet" } # external +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index fa15c30986..631befd39b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -34,6 +34,10 @@ photocraft-plugins = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } photocraft-codecs = { workspace = true } flate2 = { workspace = true } +craft-print = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3" # Photoshop `.kys` shortcut sets (`edit.keyboardShortcuts` `importKys`); already in the tree # through photocraft-ui-egui and usvg. roxmltree = "0.20" diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 15e7387b0e..d0349300c0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -527,6 +527,11 @@ impl Session { /// stays unchanged. The document is unlocked at once. False if no such job is running. pub fn cancel_job(&mut self, id: JobId) -> bool { let Some(i) = self.jobs.running.iter().position(|r| r.id == id) else { return false }; + // A print worker can already have handed the job to the OS. Discarding its result + // would falsely report cancellation; removal belongs to the system print queue. + if matches!(self.jobs.running[i].command.as_str(), "file.print" | "file.printOneCopy") { + return false; + } let job = self.jobs.running.remove(i); job.ctx.cancel(); self.end_job(&job, JobOutcome::Cancelled); diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 59d6b3e1e8..430fd3bde4 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -4,8 +4,8 @@ //! scale, colour handling with printer profile / intent / black-point compensation, corner and //! centre crop marks, registration marks, description and label) with a small PDF writer //! (ISO 32000: one page, a Flate-compressed image XObject, vector marks, Helvetica text), then -//! hands it to the system spooler with `lp` (CUPS, macOS and Linux). `"dryRun": true` stops -//! before spooling and reports the command line, which is how the tests exercise the path. +//! hands its bytes to `craft-print` (CUPS on macOS/Linux, Windows PDF printing on Windows). +//! `"dryRun": true` retains a diagnostic PDF and reports the backend command without spooling. //! //! Paths to Illustrator writes the document's paths as an Adobe Illustrator 3 compatible //! PostScript file (`m`/`L`/`C` path construction, `n`/`N` unpainted closed/open paths, `*u`/`*U` @@ -330,8 +330,37 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); - let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(&doc, p, cmd)?; - let (img, color) = print_image(&doc, p, cmd)?; + let params = p.clone(); + let worker_params = params.clone(); + let command = cmd.to_string(); + let send = p.get("send").and_then(Value::as_bool).unwrap_or(!p.get("output").and_then(Value::as_str).is_some_and(|v| !v.is_empty())); + if send && !p.get("dryRun").and_then(Value::as_bool).unwrap_or(false) { + // Session::execute remains synchronous; the desktop's Session::start snapshots here + // and runs PDF composition and the potentially slow driver on a worker. + return crate::jobs::run( + s, + "Print", + false, + move |ctx| { + ctx.progress(0.0, "Print"); + render_and_spool(&doc, &worker_params, &command) + }, + move |s, result| { + remember_print(s, ¶ms); + crate::automate_cmds::fire_event(s, "print"); + Ok(result) + }, + ); + } + let result = render_and_spool(&doc, p, cmd)?; + remember_print(s, p); + crate::automate_cmds::fire_event(s, "print"); + Ok(result) +} + +fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { + let PrintLayout { paper: (pw, ph), rect: (x, y, w, h), scale, marks, emulsion_down } = layout(doc, p, cmd)?; + let (img, color) = print_image(doc, p, cmd)?; let description = p .get("description") .and_then(Value::as_bool) @@ -341,38 +370,35 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let page = PrintPage { paper: (pw, ph), image: img, rect: (x, y, w, h), marks, description, label, emulsion_down }; let output = p.get("output").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string); let send = p.get("send").and_then(Value::as_bool).unwrap_or(output.is_none()); - let pdf_path = match &output { - Some(o) => o.clone(), - None => { - // Unique per call: concurrent prints of same-named documents must not share a spool file. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let tmp = std::env::temp_dir().to_string_lossy().into_owned(); - join(&tmp, &format!("{}-print-{}-{n}.pdf", crate::file_cmds::sanitize(&stem(&doc.name)), std::process::id())) - } - }; - let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999); + let copies = crate::commands::int(p, "copies").unwrap_or(1).clamp(1, 999) as u32; let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); let pdf = print_pdf(&page); - write_file(&pdf_path, &pdf)?; - let mut argv: Vec = vec!["lp".into()]; - if let Some(pr) = p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()) { - argv.extend(["-d".into(), pr.to_string()]); - } - if copies > 1 { - argv.extend(["-n".into(), copies.to_string()]); - } - argv.extend(["-t".into(), doc.name.clone(), pdf_path.clone()]); + let job = craft_print::Job { + printer: p.get("printer").and_then(Value::as_str).filter(|v| !v.is_empty()).map(str::to_string), + copies, + title: doc.name.clone(), + // PhotoCraft already placed and scaled the image on its physical sheet. Scaling again + // in the driver would change the preview, print resolution and crop-mark positions. + scaling: craft_print::Scaling::Exact, + ..Default::default() + }; + let command = if send { json!(craft_print::command_preview(&job).map_err(other)?) } else { Value::Null }; + let pdf_path = match output { + Some(path) => { + write_file(&path, &pdf)?; + Some(path) + } + None if dry || !send => Some(diagnostic_pdf(&pdf)?), + None => None, + }; let mut sent = false; let mut spool = Value::Null; if send && !dry { - spool = json!(spool_pdf(&argv)?); + spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } - remember_print(s, p); - crate::automate_cmds::fire_event(s, "print"); Ok( - json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": if send { json!(argv) } else { Value::Null }, "sent": sent, "spooler": spool, "color": color}), + json!({"pdf": pdf_path, "bytes": pdf.len(), "paper": [pw, ph], "imageRect": [x, y, w, h], "emulsionDown": emulsion_down, "scale": scale * 100.0, "copies": copies, "command": command, "sent": sent, "spooler": spool, "color": color}), ) } @@ -386,17 +412,19 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// A requested preview/export artifact belongs to the caller, unlike a spooler's temporary +/// transport file. Create it exclusively and keep it so the command's `pdf` path remains usable. #[cfg(not(target_arch = "wasm32"))] -fn spool_pdf(argv: &[String]) -> Result { - let out = std::process::Command::new(&argv[0]).args(&argv[1..]).output().map_err(|e| other(format!("could not run `lp` (CUPS): {e}")))?; - if !out.status.success() { - return Err(other(format!("lp failed: {}", String::from_utf8_lossy(&out.stderr).trim()))); - } - Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +fn diagnostic_pdf(pdf: &[u8]) -> Result { + let mut file = tempfile::Builder::new().prefix("photocraft-print-").suffix(".pdf").tempfile().map_err(other)?; + file.write_all(pdf).map_err(other)?; + file.flush().map_err(other)?; + let (_, path) = file.keep().map_err(other)?; + Ok(path.to_string_lossy().into_owned()) } #[cfg(target_arch = "wasm32")] -fn spool_pdf(_: &[String]) -> Result { - Err(other("printing needs the desktop app")) +fn diagnostic_pdf(_: &[u8]) -> Result { + Err(other("printing needs the desktop app; specify an output PDF path instead")) } fn print(s: &mut Session, p: &Value) -> Result { @@ -567,7 +595,7 @@ pub fn specs() -> Vec { CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run } }; } - const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render the PDF, report the lp command, don't spool)} → {pdf, imageRect, command, sent}"##; + const PRINT_PARAMS: &str = r##"{"printer":name? (default printer),"copies":1..999=1,"paper":"letter|legal|tabloid|a3|a4|a5|4x6|5x7"|[w,h] pt="letter","orientation":"portrait|landscape"="portrait","center":bool=true,"top":in?,"left":in?,"scale":%=100,"scaleToFit":bool=false,"colorHandling":"printerManages|photocraftManages|noColorManagement"="printerManages","printerProfile":profile? (photocraftManages),"intent":"perceptual|relative|saturation|absolute"="relative","bpc":bool=true,"cornerCropMarks":bool,"centerCropMarks":bool,"registrationMarks":bool,"description":bool,"labels":bool,"emulsionDown":bool=false (mirror the page left to right, for sublimation and other transfer printing),"output":pdf path? (print to PDF; then "send" defaults to false),"send":bool?,"dryRun":bool=false (render a diagnostic PDF, report the backend command, don't spool)} → {pdf:path|null (null for spool-only jobs), imageRect, command:diagnostic array|null, sent}"##; vec![ spec!("file.print", "Print…", &["File"], Some("Cmd+P"), PRINT_PARAMS, native_doc, print), spec!( diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 877fb945ff..e8e55c8961 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -33,7 +33,7 @@ fn pdf_image(pdf: &[u8]) -> (u32, u32, String, Vec) { } #[test] -fn print_dry_run_renders_a_pdf_and_reports_lp() { +fn print_dry_run_renders_a_pdf_and_reports_the_platform_backend() { let dir = tmp("print"); for depth in [8, 16, 32] { let mut s = session("rgb", depth); @@ -44,7 +44,14 @@ fn print_dry_run_renders_a_pdf_and_reports_lp() { ) .unwrap(); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); - assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); + #[cfg(unix)] + { + assert_eq!(&cmd[..5], &["lp", "-d", "Office", "-n", "2"]); + assert!(cmd.contains(&"fit-to-page=false".into())); + assert!(!cmd.contains(&r["pdf"].as_str().unwrap().to_string()), "PDF is streamed, not passed as a file"); + } + #[cfg(windows)] + assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); assert_eq!(r["sent"], false); let pdf = std::fs::read(r["pdf"].as_str().unwrap()).unwrap(); assert!(pdf.starts_with(b"%PDF-1.4")); @@ -152,13 +159,17 @@ fn printer_profile_can_be_an_installed_icc_file() { #[test] fn print_one_copy_repeats_the_last_settings() { let mut s = session("rgb", 8); - s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50})).unwrap(); + let first = s.execute("file.print", json!({"dryRun": true, "copies": 3, "paper": "a5", "scale": 50})).unwrap(); + std::fs::remove_file(first["pdf"].as_str().unwrap()).unwrap(); let r = s.execute("file.printOneCopy", json!({"dryRun": true})).unwrap(); assert_eq!(r["copies"], 1); assert_eq!(r["paper"], json!([419.53, 595.28])); assert_eq!(r["scale"], 50.0); let cmd: Vec = serde_json::from_value(r["command"].clone()).unwrap(); - assert!(!cmd.contains(&"-n".to_string())); + #[cfg(unix)] + assert_eq!(&cmd[..3], &["lp", "-n", "1"]); + #[cfg(windows)] + assert!(cmd.last().unwrap().contains("craft-print PDF spooler")); let _ = std::fs::remove_file(r["pdf"].as_str().unwrap()); } @@ -224,3 +235,52 @@ fn paths_export_as_illustrator_postscript() { assert_eq!(r["paths"], 1); assert!(s.execute("file.export.pathsToIllustrator", json!({"paths": "Nope"})).is_err()); } + +/// Launch the command in a subprocess with a fake CUPS binary: the parent process's PATH and +/// parallel tests are untouched, and the real spool boundary still consumes the PDF bytes. +#[cfg(unix)] +#[test] +fn spool_transport_streams_pdf_and_propagates_refusal() { + const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_TRANSPORT"; + if let Ok(dir) = std::env::var(CHILD) { + let mut s = session("rgb", 8); + let crate::jobs::Started::Job(job) = s.start("file.print", json!({"printer": "Office", "copies": 3, "paper": "a4", "scale": 50})).unwrap() else { + panic!("desktop printing must start on a worker"); + }; + assert!(!s.cancel_job(job), "queue side effects must not be reported as cancelled"); + let out = s.wait_job(job).unwrap(); + assert_eq!(out["sent"], true); + assert_eq!(out["spooler"], "request id is Office-42"); + assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); + let pdf = std::fs::read(format!("{dir}/received.pdf")).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.4")); + assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 595.28 841.89]")); + let args = std::fs::read_to_string(format!("{dir}/args")).unwrap(); + assert!(args.contains("fit-to-page=false")); + assert!(args.contains("-n\n3\n")); + assert!(!args.contains(".pdf")); + let remembered = s.file_menu.last_print.clone(); + let explicit = format!("{dir}/saved.pdf"); + let err = s.execute("file.print", json!({"printer": "Refuse", "output": explicit, "send": true})).unwrap_err(); + assert!(err.to_string().contains("queue refused"), "{err}"); + assert!(std::fs::read(&explicit).unwrap().starts_with(b"%PDF-1.4"), "explicit export survives a spool refusal"); + assert_eq!(s.file_menu.last_print, remembered, "failed printing does not replace repeat settings"); + return; + } + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("lp"); + std::fs::write( + &script, + 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(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) + .env(CHILD, dir.path()) + .env("PATH", dir.path()) + .output() + .unwrap(); + assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); + assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test"); +} diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 8b96a51a3d..0ac8f98028 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -840,6 +840,13 @@ fn print_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { let r = app.run("file.print", params(f))?; + if r["pending"] != json!(true) { + print_status(app, &r); + } + Ok(r) +} + +pub(crate) fn print_status(app: &mut PhotocraftApp, r: &Value) { app.ui.status = if r["sent"] == json!(true) { let template = "Sent to the printer ({spooler})"; crate::i18n::fmt(tl!(template), &[("spooler", r["spooler"].as_str().unwrap_or(""))]) @@ -847,7 +854,7 @@ fn print_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { + crate::file_ui::print_status(app, result); + } JobOutcome::Done(_) => { app.sync_views(); app.ui.status = e.label; @@ -576,12 +579,13 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; let more = app.session.jobs().len().saturating_sub(1); // Right to left: ×, percentage, bar, label. - let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + let print = matches!(j.command.as_str(), "file.print" | "file.printOneCopy"); + let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), if print { Sense::hover() } else { Sense::click() }); if xresp.hovered() { ui.painter().rect_filled(xr, t.radius_sm, t.hover); } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_dim }); - let xresp = xresp.on_hover_text(tl!("Cancel (Esc)")); + let xresp = xresp.on_hover_text(if print { tl!("Manage submitted jobs in the system print queue") } else { tl!("Cancel (Esc)") }); ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace()); let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover()); bar(ui, br, shown_fraction(&j), &t); diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index f0c629105a..975110941c 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -577,3 +577,44 @@ fn a_finished_job_beeps_when_the_preference_is_on() { frames_for(&mut h, 800); assert_eq!(BEEPS.load(Ordering::Relaxed), 1, "one beep for the finished job"); } + +#[test] +fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { + for success in [true, false] { + let mut h = app_harness(); + let gate = Arc::new(AtomicBool::new(false)); + let worker_gate = gate.clone(); + let Started::Job(id) = h.state_mut().session.start_job( + "file.print", + json!({}), + "Print", + false, + move |_| { + while !worker_gate.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + if success { Ok(json!({"sent": true, "spooler": "request id is Office-42", "pdf": null})) } + else { Err(photocraft_engine::EngineError::Other("queue refused".into())) } + }, + |_, result| Ok(result), + ).unwrap() else { panic!("must run on a worker") }; + let frame = h.state().frame; + h.run_steps(4); + assert!(h.state().frame > frame); + assert!(!h.state().ui.status.contains("Sent to the printer")); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().session.job(id).is_some(), "Esc cannot retract a submitted job"); + gate.store(true, Ordering::Relaxed); + let deadline = Instant::now() + Duration::from_secs(10); + while h.state().session.has_jobs() && Instant::now() < deadline { h.step(); } + assert!(!h.state().session.has_jobs()); + if success { + assert!(h.state().ui.status.contains("Office-42"), "{}", h.state().ui.status); + assert!(!h.state().ui.status_error); + } else { + assert!(h.state().ui.status.contains("queue refused"), "{}", h.state().ui.status); + assert!(h.state().ui.status_error); + } + } +} diff --git a/docs/development.md b/docs/development.md index 6eac94c6ce..567ba9648f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -526,3 +526,21 @@ stroke is drawn above its clipped layers; linked effect patterns tile from the l reference point; stroke distances follow a 5 × 5 chamfer metric (1, √2, √5) seeded at sub-pixel edge offsets; interior effects keep the layer's alpha; outside strokes blend onto the backdrop with their own modes, an upper stroke covering lower ones. + +## System printing + +`file.print` and `file.printOneCopy` keep PDF layout, scaling, marks and ICC conversion in +PhotoCraft and submit the rendered bytes through `craft-print` at the Git revision pinned in +`Cargo.toml`. CUPS streams the PDF on macOS/Linux; Windows uses the shared Windows PDF backend. +The driver receives exact physical sheet size so it does not scale PhotoCraft's layout twice. +Windows raster printing does not establish physical-printer colour fidelity; test critical +photo output with the actual printer and its profile. + +Desktop commands run composition and submission on a background worker. The UI reports success +only when the spooler accepts the job; remove submitted jobs in the system print queue. Headless +`Session::execute` stays synchronous. The returned `pdf` is `null` for a spool-only job, and +`command` is a diagnostic preview, not an executable command with a PDF path. Supplying `output` +keeps a PDF at that path and defaults `send` to false. `dryRun` renders a diagnostic PDF but never +submits; an implicit diagnostic file is exclusively created with a random name and belongs to +the caller, who may delete it after inspection. Shared spooler transport files are automatically +removed on Windows; CUPS needs none. From 698c0f554db2047b7e3a81a5c1dada94d050fb10 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:29:59 +0200 Subject: [PATCH 002/523] Format printing completion regression --- crates/ui-egui/src/jobs_ui_tests.rs | 42 ++++++++++++++++++----------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 975110941c..dcf24594b2 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -584,20 +584,30 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { let mut h = app_harness(); let gate = Arc::new(AtomicBool::new(false)); let worker_gate = gate.clone(); - let Started::Job(id) = h.state_mut().session.start_job( - "file.print", - json!({}), - "Print", - false, - move |_| { - while !worker_gate.load(Ordering::Relaxed) { - std::thread::sleep(Duration::from_millis(1)); - } - if success { Ok(json!({"sent": true, "spooler": "request id is Office-42", "pdf": null})) } - else { Err(photocraft_engine::EngineError::Other("queue refused".into())) } - }, - |_, result| Ok(result), - ).unwrap() else { panic!("must run on a worker") }; + let Started::Job(id) = h + .state_mut() + .session + .start_job( + "file.print", + json!({}), + "Print", + false, + move |_| { + while !worker_gate.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(1)); + } + if success { + Ok(json!({"sent": true, "spooler": "request id is Office-42", "pdf": null})) + } else { + Err(photocraft_engine::EngineError::Other("queue refused".into())) + } + }, + |_, result| Ok(result), + ) + .unwrap() + else { + panic!("must run on a worker") + }; let frame = h.state().frame; h.run_steps(4); assert!(h.state().frame > frame); @@ -607,7 +617,9 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { assert!(h.state().session.job(id).is_some(), "Esc cannot retract a submitted job"); gate.store(true, Ordering::Relaxed); let deadline = Instant::now() + Duration::from_secs(10); - while h.state().session.has_jobs() && Instant::now() < deadline { h.step(); } + while h.state().session.has_jobs() && Instant::now() < deadline { + h.step(); + } assert!(!h.state().session.has_jobs()); if success { assert!(h.state().ui.status.contains("Office-42"), "{}", h.state().ui.status); From bb46f846d9d6490e77c08a05ee662ec75930fe43 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:32:26 +0200 Subject: [PATCH 003/523] Keep print completion tied to its document and hold quit --- crates/engine/src/print_cmds.rs | 12 +++++++++++- crates/engine/src/print_cmds/tests.rs | 8 ++++++++ crates/ui-egui/src/discard_ui.rs | 6 +++--- crates/ui-egui/src/jobs_ui.rs | 9 ++++++--- crates/ui-egui/src/jobs_ui_tests.rs | 5 +++++ crates/ui-egui/src/lib.rs | 5 +++++ 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 430fd3bde4..37dee42adc 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -330,6 +330,7 @@ pub fn layout(doc: &Document, p: &Value, cmd: &str) -> Result { fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); + let doc_id = doc.id; let params = p.clone(); let worker_params = params.clone(); let command = cmd.to_string(); @@ -347,7 +348,16 @@ fn do_print(s: &mut Session, p: &Value, cmd: &str) -> Result { }, move |s, result| { remember_print(s, ¶ms); - crate::automate_cmds::fire_event(s, "print"); + // A user can switch tabs while printing. Hooks belong to the source document; + // a closed source has no document left to modify, as for background saves. + let viewed = s.active().map(|st| st.doc.id); + if let Some(index) = s.documents().iter().position(|st| st.doc.id == doc_id) { + s.set_active(index); + crate::automate_cmds::fire_event(s, "print"); + if let Some(index) = viewed.and_then(|id| s.documents().iter().position(|st| st.doc.id == id)) { + s.set_active(index); + } + } Ok(result) }, ); diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index e8e55c8961..23ca04a991 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -244,11 +244,19 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { const CHILD: &str = "PHOTOCRAFT_TEST_PRINT_TRANSPORT"; if let Ok(dir) = std::env::var(CHILD) { let mut s = session("rgb", 8); + let source = s.active().unwrap().doc.id; + s.execute("file.scripts.scriptEventsManager", json!({"add": {"event": "print", "steps": [["image.imageRotation.90cw", {}]]}})).unwrap(); let crate::jobs::Started::Job(job) = s.start("file.print", json!({"printer": "Office", "copies": 3, "paper": "a4", "scale": 50})).unwrap() else { panic!("desktop printing must start on a worker"); }; assert!(!s.cancel_job(job), "queue side effects must not be reported as cancelled"); + s.execute("file.new", json!({"width": 40, "height": 10})).unwrap(); + let viewed = s.active().unwrap().doc.id; let out = s.wait_job(job).unwrap(); + assert_eq!(s.active().unwrap().doc.id, viewed, "completion keeps the user's tab"); + assert_eq!(s.active().unwrap().doc.size.width, 40, "print hook must not act on the newly viewed document"); + let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; + assert_eq!((printed.size.width, printed.size.height), (150, 300), "print hook ran on the source document"); assert_eq!(out["sent"], true); assert_eq!(out["spooler"], "request id is Office-42"); assert!(out["pdf"].is_null(), "spool-only printing leaves no diagnostic PDF"); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index b0b455ec9c..b7ca6ebd60 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -120,9 +120,9 @@ pub fn guard_window_close(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); return; } - // A save still writing in the background finishes first (a saved copy, say, leaves nothing - // unsaved); `jobs_ui` repeats the close when it ends. - if app.saving() { + // A save or print worker must finish its external side effect before the process exits; + // `jobs_ui` repeats the close after completion and keeps errors visible. + if app.saving() || app.printing() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); app.jobs.close_after_saves = true; return; diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 4a52e26d48..7b3e0d8a62 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -348,7 +348,7 @@ fn finish_save(app: &mut PhotocraftApp, ctx: &egui::Context, save: SaveJob, labe /// no save worker is left. fn close_after_saves(app: &mut PhotocraftApp, ctx: &egui::Context) { app.jobs.saves.retain(|s| !(s.ended && s.state.finished())); - if !app.saving() && std::mem::take(&mut app.jobs.close_after_saves) { + if !app.saving() && !app.printing() && std::mem::take(&mut app.jobs.close_after_saves) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } @@ -363,8 +363,8 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.jobs.focus.is_some_and(|j| !app.jobs.opens.iter().any(|o| o.job == j)) { app.jobs.focus = app.jobs.opens.last().map(|o| o.job); } - if app.saving() { - close_after_saves(app, ctx); + close_after_saves(app, ctx); + if app.saving() || app.printing() { // Keep checking on a cancelled save's worker until it returns. ctx.request_repaint_after(std::time::Duration::from_millis(50)); } @@ -493,6 +493,9 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.status_error = false; } JobOutcome::Failed(err) => { + if matches!(e.command.as_str(), "file.print" | "file.printOneCopy") { + app.jobs.close_after_saves = false; + } app.sync_views(); notices::error(app, format!("{}: {err}", e.label)); } diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index dcf24594b2..2e61142938 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -615,12 +615,17 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { h.key_press(egui::Key::Escape); h.run_steps(2); assert!(h.state().session.job(id).is_some(), "Esc cannot retract a submitted job"); + let st = h.state_mut().session.active_mut().unwrap(); + st.saved_revision = st.revision; + assert!(press_window_close(h.state_mut()), "quit waits for print submission"); + assert!(h.state().jobs.close_after_saves); gate.store(true, Ordering::Relaxed); let deadline = Instant::now() + Duration::from_secs(10); while h.state().session.has_jobs() && Instant::now() < deadline { h.step(); } assert!(!h.state().session.has_jobs()); + assert!(!h.state().jobs.close_after_saves, "close repeats on success or is cleared for a visible error"); if success { assert!(h.state().ui.status.contains("Office-42"), "{}", h.state().ui.status); assert!(!h.state().ui.status_error); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3dc82a73a..0bac9f2a93 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1378,6 +1378,11 @@ impl PhotocraftApp { self.tiff_options.is_some() || self.targa_options.is_some() } + /// A print worker may still be handing a job to the system queue. + pub(crate) fn printing(&self) -> bool { + self.session.jobs().iter().any(|j| matches!(j.command.as_str(), "file.print" | "file.printOneCopy")) + } + /// A save is running in the background. pub(crate) fn saving(&self) -> bool { !self.jobs.saves.is_empty() From c6407cac07c1eb6fdc97f76805ecf2d3010ea0d9 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:34:03 +0200 Subject: [PATCH 004/523] Keep scorecard test reference current --- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index c6d67f8f9b..1a2aabd980 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -123,7 +123,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-216-2 | Large-file fixtures: 20 MP, 150-layer, generated 2 GB PSD and 4-5 GB PSB stress tests | partial | [#216](https://github.com/storytold/photocraft/issues/216) | 4-5 GB PSB fixtures: psd example big_psb writes them (raw and RLE, planes past 2 GiB), io example big_psb_check opens and checks them; opt-in io tests/large_psb.rs (2.5 GB merged image). No 2 GB PSD, 150-layer or 20 MP fixture, nothing in CI | | FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | -| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | +| FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | | FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks | ## Tools diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 51f40c3a1b..9f39d4b7d9 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -185,7 +185,7 @@ id = "FILE-216-5" target = "Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure" status = "partial" issue = 216 -note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist" +note = "Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_the_platform_backend decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist" [[item]] id = "FILE-216-6" From 223e9499c3ab5ec9f53537731362a5e4076a2351 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:46:08 +0200 Subject: [PATCH 005/523] Keep keyboard shortcut parser available in browser builds --- crates/engine/Cargo.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 631befd39b..741d927a3f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -36,12 +36,13 @@ photocraft-codecs = { workspace = true } flate2 = { workspace = true } craft-print = { workspace = true } -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tempfile = "3" # Photoshop `.kys` shortcut sets (`edit.keyboardShortcuts` `importKys`); already in the tree # through photocraft-ui-egui and usvg. roxmltree = "0.20" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3" + [dev-dependencies] photocraft-affinity = { workspace = true, features = ["synth"] } # Synthetic camera raw files for the open tests. From b8ea8206cc3879a280e446ea05accd79aa1fd65e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:51:42 +0200 Subject: [PATCH 006/523] Refresh document views after completed print hooks --- crates/ui-egui/src/jobs_ui.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 7b3e0d8a62..f0d10c3de8 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -485,6 +485,7 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { app.ui.brush_tab = 1; } JobOutcome::Done(ref result) if matches!(e.command.as_str(), "file.print" | "file.printOneCopy") => { + app.sync_views(); crate::file_ui::print_status(app, result); } JobOutcome::Done(_) => { From cc902c681b155d7478abb53105a9fe8867742743 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:56:10 +0200 Subject: [PATCH 007/523] Report refused print cancellation accurately to automation --- crates/engine/src/jobs.rs | 10 ++++++++-- crates/engine/src/print_cmds/tests.rs | 2 ++ crates/ui-egui/src/control.rs | 10 ++++------ crates/ui-egui/src/jobs_ui.rs | 8 +++++--- crates/ui-egui/src/jobs_ui_tests.rs | 4 ++++ 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index d0349300c0..5ea48f9431 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -819,11 +819,17 @@ pub fn specs() -> Vec { None | Some(Value::Null) => { let n = s.jobs.running.len(); s.cancel_all_jobs(); - Ok(json!({"cancelled": n})) + Ok(json!({"cancelled": n.saturating_sub(s.jobs.running.len())})) } Some(v) => { let id = v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: "jobs.cancel".into(), msg: "`job` must be a job id".into() })?; - if s.cancel_job(JobId(id)) { Ok(json!({"cancelled": 1})) } else { Err(EngineError::Other(format!("no running job {id}"))) } + if s.cancel_job(JobId(id)) { + Ok(json!({"cancelled": 1})) + } else if s.job(JobId(id)).is_some() { + Err(EngineError::Other("printing cannot be cancelled here; use the system print queue".into())) + } else { + Err(EngineError::Other(format!("no running job {id}"))) + } } }, journal: false, diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 23ca04a991..061e7538d4 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -250,6 +250,8 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { panic!("desktop printing must start on a worker"); }; assert!(!s.cancel_job(job), "queue side effects must not be reported as cancelled"); + assert_eq!(s.execute("jobs.cancel", json!({})).unwrap()["cancelled"], 0); + assert!(s.execute("jobs.cancel", json!({"job": job.0})).unwrap_err().to_string().contains("system print queue")); s.execute("file.new", json!({"width": 40, "height": 10})).unwrap(); let viewed = s.active().unwrap().doc.id; let out = s.wait_job(job).unwrap(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b2a48cf6d8..7bba3e0866 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -381,16 +381,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let all = p.get("job").is_none_or(Value::is_null); match p.get("job").and_then(Value::as_u64) { Some(j) if app.session.job(photocraft_engine::jobs::JobId(j)).is_some() => { - crate::jobs_ui::cancel(app, photocraft_engine::jobs::JobId(j)); - ok(json!({"cancelled": 1})) + let cancelled = crate::jobs_ui::cancel(app, photocraft_engine::jobs::JobId(j)); + ok(json!({"cancelled": usize::from(cancelled)})) } Some(j) => err(format!("no running job {j}")), None if all => { let ids: Vec<_> = app.session.jobs().into_iter().map(|j| j.id).collect(); - for j in &ids { - crate::jobs_ui::cancel(app, *j); - } - ok(json!({"cancelled": ids.len()})) + let cancelled = ids.into_iter().filter(|j| crate::jobs_ui::cancel(app, *j)).count(); + ok(json!({"cancelled": cancelled})) } None => err("`job` must be a job id"), } diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index f0d10c3de8..f15a464a88 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -391,7 +391,7 @@ pub fn job_in_view(app: &PhotocraftApp) -> Option { } /// Cancel job `id` (the status bar's ×, the dialog's Cancel, Esc, closing an opening tab). -pub fn cancel(app: &mut PhotocraftApp, id: JobId) { +pub fn cancel(app: &mut PhotocraftApp, id: JobId) -> bool { let label = app.session.job(id).map(|j| j.label); // A save that has started replacing the file runs to the end: cancelling it now would leave // the file written but the document marked unsaved. @@ -400,15 +400,17 @@ pub fn cancel(app: &mut PhotocraftApp, id: JobId) { { app.ui.status = crate::i18n::fmt(tl!("{label} is writing the file and can no longer be cancelled"), &[("label", label.as_deref().unwrap_or_default())]); app.ui.status_error = false; - return; + return false; } - if app.session.cancel_job(id) { + let cancelled = app.session.cancel_job(id); + if cancelled { // The event (and its status text) arrives with the next poll; say so now as well. if let Some(l) = label { app.ui.status = crate::i18n::fmt(tl!("Cancelled {label}"), &[("label", &l)]); app.ui.status_error = false; } } + cancelled } fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 2e61142938..2ab8f7e949 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -615,6 +615,10 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { h.key_press(egui::Key::Escape); h.run_steps(2); assert!(h.state().session.job(id).is_some(), "Esc cannot retract a submitted job"); + let (request, _) = crate::control::ControlRequest::new("jobs.cancel", json!({"job": id.0})); + let ctx = h.ctx.clone(); + let crate::control::Outcome::Done(reply) = crate::control::handle(h.state_mut(), &ctx, &request) else { panic!("cancel replies immediately") }; + assert_eq!(reply["result"]["cancelled"], 0, "control must not falsely report cancellation: {reply}"); let st = h.state_mut().session.active_mut().unwrap(); st.saved_revision = st.revision; assert!(press_window_close(h.state_mut()), "quit waits for print submission"); From 4bb6ffc46facf502b75864fa7e63b4ea376037a6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 02:58:32 +0200 Subject: [PATCH 008/523] Record background print hooks as one command --- crates/engine/src/jobs.rs | 9 ++++++++- crates/engine/src/print_cmds/tests.rs | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 5ea48f9431..6c63a1d9c5 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -751,18 +751,25 @@ impl Session { }; self.coalesce_request = coalesce.clone(); self.color_restrict = *color_restrict; + // Apply-time hooks can dispatch commands too. Keep the same single-step recording + // contract as synchronous dispatch without truncating work done while the worker ran. + let nested_from = self.journal.len(); + let recording = if *journal && crate::actions_cmds::replayable(command) { self.actions.recording.take() } else { None }; let r = match apply { Some(f) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self, payload))) .unwrap_or_else(|_| Err(EngineError::Other(format!("`{label}` failed with an internal error (logged); the document is unchanged")))), None => Err(EngineError::Other("internal error: job applied twice".into())), }; + if self.actions.recording.is_none() { + self.actions.recording = recording; + } self.coalesce_request = None; self.color_restrict = None; // Finish successful command bookkeeping on the document the job edited. // Restoring the viewed document first would attribute Fade and slice updates // to that document instead, when the user switched tabs mid-job. if r.is_ok() { - self.after_command(command, params.clone(), *journal, None); + self.after_command(command, params.clone(), *journal, Some(nested_from)); } // Keep the user's active document unless the job opened a new one. // This also restores the selection when the apply step returned an error. diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 061e7538d4..c58ae86e45 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -254,7 +254,10 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { assert!(s.execute("jobs.cancel", json!({"job": job.0})).unwrap_err().to_string().contains("system print queue")); s.execute("file.new", json!({"width": 40, "height": 10})).unwrap(); let viewed = s.active().unwrap().doc.id; + let journal_before_completion = s.journal.len(); let out = s.wait_job(job).unwrap(); + assert_eq!(s.journal.len(), journal_before_completion + 1, "print hooks must not become duplicate recorded action steps"); + assert_eq!(s.journal.last().unwrap().0, "file.print"); assert_eq!(s.active().unwrap().doc.id, viewed, "completion keeps the user's tab"); assert_eq!(s.active().unwrap().doc.size.width, 40, "print hook must not act on the newly viewed document"); let printed = &s.documents().iter().find(|st| st.doc.id == source).unwrap().doc; From f65f7cc9eb80995ed3859de8b1e5329b2605ac07 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 03:24:40 +0200 Subject: [PATCH 009/523] Correct stale HDR Levels test endpoints --- crates/engine/src/adjust_params.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 00fb67dd03..f23af8957f 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1240,9 +1240,11 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents, but in a 32-bit document the curve runs on past it (then 0..1). + // integer documents. Float32 keeps HDR values outside 0..1, as the compositor's real-file + // Levels oracle does. These independently calculated endpoints use the signed-power + // curve at black and white, not display-clipped values. let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for (depth, low, high) in [(8, 10.0, 245.0), (16, 10.0, 245.0), (32, 0.0, 255.0)] { + for (depth, low, high, tolerance) in [(8, 10.0, 245.0, 0.6), (16, 10.0, 245.0, 0.6), (32, -20.308_89, 265.750_3, 0.01)] { let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1250,8 +1252,11 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] * 255.0 - low).abs() < 0.6, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] * 255.0 - high).abs() < 0.6, "white @{depth}: {:?}", px[1]); + assert!((px[0][0] * 255.0 - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); + if depth == 32 { + assert!(px[0][0] < 0.0 && px[1][0] > 1.0, "HDR endpoints must remain unclipped"); + } } } From 9db0b309109a0ad340660a4f87ad441c582aa1f0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 03:53:52 +0200 Subject: [PATCH 010/523] Keep browser command orchestration within the existing size budget --- Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 1ae1c2f5ef..9c0d911604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,3 +113,7 @@ opt-level = 3 opt-level = 3 [profile.wasm-release.package.photocraft-paint] opt-level = 3 + +# Size-focused command orchestration in the browser; pixel kernels keep their overrides above. +[profile.wasm-release.package.photocraft-engine] +opt-level = "z" From cf98b91f7bbbaf7e2d40b92331fa0fafb75372ba Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:03:54 +0200 Subject: [PATCH 011/523] Remove duplicate Czech catalog rows while retaining active translations --- crates/ui-egui/src/i18n/cs.tsv | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5c9b846661..647af4a622 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2663,7 +2663,6 @@ cameraData Lens Objektiv Brush Type Typ štětce Bulge Vyboulení Burlap Pytlovina - Burn Ztmavení Calculations Výpočty Camera Calibration Kalibrace fotoaparátu Camera Raw Camera Raw @@ -2693,7 +2692,6 @@ cameraData Lens Objektiv Clear Selection Zrušit výběr Clipping Path Ořezová cesta Clone Klonovat - Clone Stamp Klonovací razítko Close Window Zavřít okno Clumped Shlukované Coarse Dots Hrubé tečky @@ -2790,7 +2788,6 @@ cameraData Lens Objektiv Distribution Rozložení Documentation Dokumentace Documents Dokumenty - Dodge Zesvětlení Dot Tečka Double Line Dvojitá čára Draft Koncept @@ -2931,7 +2928,6 @@ cameraData Lens Objektiv Highlight Radius Poloměr světel Highlight Strength Síla odlesku Highlight Tone Tón světel - History Brush Štětec historie Horizontal Only Pouze vodorovně Hotspot Jádro světla Hsb HSB @@ -3276,7 +3272,6 @@ cameraData Lens Objektiv Spherize Sférická deformace Spin Rotace Spin Blur Rotační rozostření - Sponge Houba Spot Reflektor Spot Color Přímá barva Spot Healing Bodové retušování @@ -3334,7 +3329,6 @@ cameraData Lens Objektiv Tiny Lens Drobné čočky To Center Ke středu Tone Curve Tónová křivka - Tool Nástroj Tool Options Možnosti nástroje Toolbars Panely nástrojů Torn Edges Roztřepené okraje @@ -3431,7 +3425,6 @@ layerLabel No Color Žádná barva Loading fonts… Načítání písem… Tool Nástroj Simulate Pressure Simulovat přítlak - Stroke Path… Vytáhnout cestu… Background Eraser Guma pozadí Clone Stamp Klonovací razítko Healing Brush Retušovací štětec From ce48721c6bf25d98e818d706b216f8096f2691e4 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:04:07 +0200 Subject: [PATCH 012/523] Translate the system print queue guidance in every UI language --- crates/ui-egui/src/i18n/cs.tsv | 3 +++ crates/ui-egui/src/i18n/de.tsv | 3 +++ crates/ui-egui/src/i18n/el.tsv | 3 +++ crates/ui-egui/src/i18n/es.tsv | 3 +++ crates/ui-egui/src/i18n/fr.tsv | 3 +++ crates/ui-egui/src/i18n/id.tsv | 3 +++ crates/ui-egui/src/i18n/it.tsv | 3 +++ crates/ui-egui/src/i18n/ja.tsv | 3 +++ crates/ui-egui/src/i18n/ko.tsv | 3 +++ crates/ui-egui/src/i18n/nl.tsv | 3 +++ crates/ui-egui/src/i18n/pl.tsv | 3 +++ crates/ui-egui/src/i18n/pt-br.tsv | 3 +++ crates/ui-egui/src/i18n/ru.tsv | 3 +++ crates/ui-egui/src/i18n/uk.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 3 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 3 +++ 16 files changed, 48 insertions(+) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 647af4a622..e3c3d1d043 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3642,3 +3642,6 @@ navigator Zoom Zvětšení (No neutral color exists for {mode} mode.) (Pro režim {mode} neexistuje neutrální barva.) Apply mask to layer before removing? Použít masku na vrstvu před odstraněním? The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Spravovat odeslané úlohy v systémové tiskové frontě diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a7bfaee5dc..b0d7990d1a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2652,3 +2652,6 @@ cameraData Lens Objektiv (No neutral color exists for {mode} mode.) (Für den Modus {mode} gibt es keine neutrale Farbe.) Apply mask to layer before removing? Maske vor dem Entfernen auf die Ebene anwenden? The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Übermittelte Aufträge in der Druckwarteschlange des Systems verwalten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a14b2b1de1..7dcbdb5c5f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3712,3 +3712,6 @@ cameraData Lens Φακός (No neutral color exists for {mode} mode.) (Δεν υπάρχει ουδέτερο χρώμα για τη λειτουργία {mode}.) Apply mask to layer before removing? Εφαρμογή της μάσκας στο επίπεδο πριν από την αφαίρεση; The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Διαχειριστείτε τις υποβληθείσες εργασίες στην ουρά εκτύπωσης του συστήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 999d5a5622..e025dd3f0f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2693,3 +2693,6 @@ cameraData Lens Objetivo (No neutral color exists for {mode} mode.) (No existe un color neutro para el modo {mode}.) Apply mask to layer before removing? ¿Aplicar la máscara a la capa antes de eliminarla? The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Gestionar los trabajos enviados en la cola de impresión del sistema diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5c49f95c12..71b0e5bb48 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3030,3 +3030,6 @@ cameraData Lens Objectif (No neutral color exists for {mode} mode.) (Il n’existe aucune couleur neutre pour le mode {mode}.) Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ? The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Gérer les tâches soumises dans la file d’impression du système diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c86521f576..db73e289d3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3017,3 +3017,6 @@ cameraData Lens Lensa (No neutral color exists for {mode} mode.) (Tidak ada warna netral untuk mode {mode}.) Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus? The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Kelola pekerjaan yang dikirim dalam antrean cetak sistem diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 351e4cc7a8..05dccec2a1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2684,3 +2684,6 @@ cameraData Lens Obiettivo (No neutral color exists for {mode} mode.) (Non esiste un colore neutro per il metodo {mode}.) Apply mask to layer before removing? Applicare la maschera al livello prima di rimuoverla? The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Gestisci i lavori inviati nella coda di stampa del sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 302f280369..050e35a441 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3624,3 +3624,6 @@ cameraData Lens レンズ (No neutral color exists for {mode} mode.) ({mode} モードには中間色がありません。) Apply mask to layer before removing? 削除する前にマスクをレイヤーに適用しますか? The layer or its mask changed. Drag the mask onto the Trash again. レイヤーまたはそのマスクが変更されました。もう一度マスクをゴミ箱にドラッグしてください。 + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue 送信済みのジョブはシステムの印刷キューで管理してください diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0cb8cc8b7e..b695ab9ea6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3197,3 +3197,6 @@ cameraData Lens 렌즈 (No neutral color exists for {mode} mode.) ({mode} 모드에는 중립 색상이 없습니다.) Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까? The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue 제출된 작업은 시스템 인쇄 대기열에서 관리하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 08d91dd692..291c896a8a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3774,3 +3774,6 @@ cameraData Lens Lens (No neutral color exists for {mode} mode.) (Voor de modus {mode} bestaat geen neutrale kleur.) Apply mask to layer before removing? Masker toepassen op laag voordat het wordt verwijderd? The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Beheer verzonden taken in de afdrukwachtrij van het systeem diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index eca048a067..9a42a56816 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3626,3 +3626,6 @@ cameraData Lens Obiektyw (No neutral color exists for {mode} mode.) (Dla trybu {mode} nie istnieje kolor neutralny.) Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem? The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Zarządzaj przesłanymi zadaniami w systemowej kolejce drukowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 51d250a35b..f2ee5e6124 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3804,3 +3804,6 @@ layerLabel No Color Sem cor (No neutral color exists for {mode} mode.) (Não existe cor neutra para o modo {mode}.) Apply mask to layer before removing? Aplicar a máscara à camada antes de removê-la? The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Gerencie os trabalhos enviados na fila de impressão do sistema diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ea4c90e626..9b2ba098bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2959,3 +2959,6 @@ cameraData Lens Объектив (No neutral color exists for {mode} mode.) (Для режима «{mode}» нет нейтрального цвета.) Apply mask to layer before removing? Применить маску к слою перед удалением? The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Управляйте отправленными заданиями в системной очереди печати diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cae6f36b17..4a8ced64c8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3712,3 +3712,6 @@ cameraData Lens Об'єктив (No neutral color exists for {mode} mode.) (Для режиму «{mode}» немає нейтрального кольору.) Apply mask to layer before removing? Застосувати маску до шару перед видаленням? The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз. + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue Керуйте надісланими завданнями в системній черзі друку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e9c12285d5..bc861c2768 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2632,3 +2632,6 @@ cameraData Lens 镜头 (No neutral color exists for {mode} mode.) ({mode}模式不存在中性色。) Apply mask to layer before removing? 移去之前将蒙版应用到图层吗? The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。 + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue 在系统打印队列中管理已提交的作业 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 765e998274..65cae1f79d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2602,3 +2602,6 @@ cameraData Lens 鏡頭 (No neutral color exists for {mode} mode.) ({mode}模式沒有中性色。) Apply mask to layer before removing? 移除之前將遮色片套用到圖層嗎? The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。 + +# Submitted print jobs are managed by the operating system. + Manage submitted jobs in the system print queue 在系統列印佇列中管理已提交的工作 From 3a06871d69ab13679a5683d7fe2042d767075550 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:11:22 +0200 Subject: [PATCH 013/523] Keep print queue translations alongside background job controls --- crates/ui-egui/src/i18n/cs.tsv | 4 +--- crates/ui-egui/src/i18n/de.tsv | 4 +--- crates/ui-egui/src/i18n/el.tsv | 4 +--- crates/ui-egui/src/i18n/es.tsv | 4 +--- crates/ui-egui/src/i18n/fr.tsv | 4 +--- crates/ui-egui/src/i18n/id.tsv | 4 +--- crates/ui-egui/src/i18n/it.tsv | 4 +--- crates/ui-egui/src/i18n/ja.tsv | 4 +--- crates/ui-egui/src/i18n/ko.tsv | 4 +--- crates/ui-egui/src/i18n/nl.tsv | 4 +--- crates/ui-egui/src/i18n/pl.tsv | 4 +--- crates/ui-egui/src/i18n/pt-br.tsv | 4 +--- crates/ui-egui/src/i18n/ru.tsv | 4 +--- crates/ui-egui/src/i18n/uk.tsv | 4 +--- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +--- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +--- 16 files changed, 16 insertions(+), 48 deletions(-) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3739a974fb..f66f69308b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -889,6 +889,7 @@ Camera Raw isn't open Camera Raw není otevřen Cancel Zrušit Cancel (Esc) Zrušit (Esc) + Manage submitted jobs in the system print queue Spravovat odeslané úlohy v systémové tiskové frontě Cancel any current edits (Esc) Zrušit aktuální úpravy (Esc) Cancel current crop operation (Esc) Zrušit aktuální oříznutí (Esc) Cancel transform (Esc) Zrušit transformaci (Esc) @@ -3656,6 +3657,3 @@ navigator Zoom Zvětšení Apply mask to layer before removing? Použít masku na vrstvu před odstraněním? The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu. Delete the vector mask? Odstranit vektorovou masku? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Spravovat odeslané úlohy v systémové tiskové frontě diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8b08428ce9..7f64b11a2f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -867,6 +867,7 @@ Cancel Abbrechen Reset Zurücksetzen Cancel (Esc) Abbrechen (Esc) + Manage submitted jobs in the system print queue Übermittelte Aufträge in der Druckwarteschlange des Systems verwalten Cancel any current edits (Esc) Aktuelle Bearbeitungen abbrechen (Esc) Cancel current crop operation (Esc) Aktuelle Freistellung abbrechen (Esc) Cancel transform (Esc) Transformation abbrechen (Esc) @@ -2666,6 +2667,3 @@ curves Smooth Glätten Apply mask to layer before removing? Maske vor dem Entfernen auf die Ebene anwenden? The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb. Delete the vector mask? Vektormaske löschen? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Übermittelte Aufträge in der Druckwarteschlange des Systems verwalten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 53cb0f2d9f..18b8fa110c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2083,6 +2083,7 @@ Camera Raw Filter… Φίλτρο επεξεργασίας ακατέργαστης εικόνας… Camera Raw… Επεξεργασία ακατέργαστης εικόνας… Cancel (Esc) Ακύρωση (Esc) + Manage submitted jobs in the system print queue Διαχειριστείτε τις υποβληθείσες εργασίες στην ουρά εκτύπωσης του συστήματος Cancel any current edits (Esc) Ακύρωση τρεχουσών αλλαγών (Esc) Cancel transform (Esc) Ακύρωση μετασχηματισμού (Esc) Cancel warp (Esc) Ακύρωση παραμόρφωσης (Esc) @@ -3724,6 +3725,3 @@ curves Smooth Εξομάλυνση Apply mask to layer before removing? Εφαρμογή της μάσκας στο επίπεδο πριν από την αφαίρεση; The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο. Delete the vector mask? Διαγραφή της διανυσματικής μάσκας; - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Διαχειριστείτε τις υποβληθείσες εργασίες στην ουρά εκτύπωσης του συστήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 597c31682e..dc3dcc662f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -875,6 +875,7 @@ Camera Raw isn't open Camera Raw no está abierto Cancel Cancelar Cancel (Esc) Cancelar (Esc) + Manage submitted jobs in the system print queue Gestionar los trabajos enviados en la cola de impresión del sistema Cancel any current edits (Esc) Cancelar las ediciones actuales (Esc) Cancel current crop operation (Esc) Cancelar la operación de recorte actual (Esc) Cancel transform (Esc) Cancelar transformación (Esc) @@ -2707,6 +2708,3 @@ curves Smooth Suavizar Apply mask to layer before removing? ¿Aplicar la máscara a la capa antes de eliminarla? The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera. Delete the vector mask? ¿Eliminar la máscara vectorial? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Gestionar los trabajos enviados en la cola de impresión del sistema diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 281a936a37..d4887522da 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -863,6 +863,7 @@ Camera Raw isn't open Camera Raw n'est pas ouvert Cancel Annuler Cancel (Esc) Annuler (Échap) + Manage submitted jobs in the system print queue Gérer les tâches soumises dans la file d’impression du système Cancel any current edits (Esc) Annuler les modifications en cours (Échap) Cancel current crop operation (Esc) Annuler le recadrage en cours (Échap) Cancel transform (Esc) Annuler la transformation (Échap) @@ -3042,6 +3043,3 @@ curves Smooth Lisser Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ? The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille. Delete the vector mask? Supprimer le masque vectoriel ? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Gérer les tâches soumises dans la file d’impression du système diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 33be2a37be..ed68afcda4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -857,6 +857,7 @@ Camera Raw isn't open Camera Raw tidak terbuka Cancel Batal Cancel (Esc) Batal (Esc) + Manage submitted jobs in the system print queue Kelola pekerjaan yang dikirim dalam antrean cetak sistem Cancel any current edits (Esc) Batalkan semua pengeditan saat ini (Esc) Cancel current crop operation (Esc) Batalkan operasi crop saat ini (Esc) Cancel transform (Esc) Batalkan transformasi (Esc) @@ -3029,6 +3030,3 @@ curves Smooth Haluskan Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus? The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi. Delete the vector mask? Hapus masker vektor? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Kelola pekerjaan yang dikirim dalam antrean cetak sistem diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 46a6003e06..b9db5914ed 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -874,6 +874,7 @@ Cancel Annulla Reset Ripristina Cancel (Esc) Annulla (Esc) + Manage submitted jobs in the system print queue Gestisci i lavori inviati nella coda di stampa del sistema Cancel any current edits (Esc) Annulla modifiche correnti (Esc) Cancel current crop operation (Esc) Annulla operazione ritaglio corrente (Esc) Cancel transform (Esc) Annulla trasformazione (Esc) @@ -2698,6 +2699,3 @@ curves Smooth Leviga Apply mask to layer before removing? Applicare la maschera al livello prima di rimuoverla? The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino. Delete the vector mask? Eliminare la maschera vettoriale? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Gestisci i lavori inviati nella coda di stampa del sistema diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f91836c757..9eb7eff242 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -876,6 +876,7 @@ Camera Raw isn't open Camera Raw は開いていません Cancel キャンセル Cancel (Esc) キャンセル(Esc) + Manage submitted jobs in the system print queue 送信済みのジョブはシステムの印刷キューで管理してください Cancel any current edits (Esc) 現在の編集をキャンセル(Esc) Cancel current crop operation (Esc) 現在の切り抜きをキャンセル(Esc) Cancel transform (Esc) 変形をキャンセル(Esc) @@ -3636,6 +3637,3 @@ curves Smooth スムーズ Apply mask to layer before removing? 削除する前にマスクをレイヤーに適用しますか? The layer or its mask changed. Drag the mask onto the Trash again. レイヤーまたはそのマスクが変更されました。もう一度マスクをゴミ箱にドラッグしてください。 Delete the vector mask? ベクトルマスクを削除しますか? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue 送信済みのジョブはシステムの印刷キューで管理してください diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bcd25b20d4..83529b6182 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -849,6 +849,7 @@ Cancel 취소 Reset 재설정 Cancel (Esc) 취소 (Esc) + Manage submitted jobs in the system print queue 제출된 작업은 시스템 인쇄 대기열에서 관리하세요 Cancel any current edits (Esc) 현재 편집 취소 (Esc) Cancel current crop operation (Esc) 현재 자르기 작업 취소 (Esc) Cancel transform (Esc) 변형 취소 (Esc) @@ -3211,6 +3212,3 @@ curves Smooth 매끄럽게 Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까? The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요. Delete the vector mask? 벡터 마스크를 삭제하시겠습니까? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue 제출된 작업은 시스템 인쇄 대기열에서 관리하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e7749159cf..598d6ae601 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -856,6 +856,7 @@ Cancel Annuleren Reset Herstellen Cancel (Esc) Annuleren (Esc) + Manage submitted jobs in the system print queue Beheer verzonden taken in de afdrukwachtrij van het systeem Cancel any current edits (Esc) Huidige bewerkingen annuleren (Esc) Cancel current crop operation (Esc) Huidige bijsnijbewerking annuleren (Esc) Cancel transform (Esc) Transformatie annuleren (Esc) @@ -3786,6 +3787,3 @@ curves Smooth Vloeiend maken Apply mask to layer before removing? Masker toepassen op laag voordat het wordt verwijderd? The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak. Delete the vector mask? Het vectormasker verwijderen? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Beheer verzonden taken in de afdrukwachtrij van het systeem diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5a23c0a74c..e63d87b108 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -874,6 +874,7 @@ Camera Raw isn't open Camera Raw nie jest otwarty Cancel Anuluj Cancel (Esc) Anuluj (Esc) + Manage submitted jobs in the system print queue Zarządzaj przesłanymi zadaniami w systemowej kolejce drukowania Cancel any current edits (Esc) Anuluj wszystkie bieżące zmiany (Esc) Cancel current crop operation (Esc) Anuluj bieżące przycinanie (Esc) Cancel transform (Esc) Anuluj przekształcanie (Esc) @@ -3638,6 +3639,3 @@ curves Smooth Wygładź Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem? The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza. Delete the vector mask? Usunąć maskę wektorową? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Zarządzaj przesłanymi zadaniami w systemowej kolejce drukowania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 016e7f6391..1a243cebc8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -858,6 +858,7 @@ Camera Raw isn't open Camera Raw não está aberto Cancel Cancelar Cancel (Esc) Cancelar (Esc) + Manage submitted jobs in the system print queue Gerencie os trabalhos enviados na fila de impressão do sistema Cancel any current edits (Esc) Cancelar as edições atuais (Esc) Cancel current crop operation (Esc) Cancelar a operação de corte atual (Esc) Cancel transform (Esc) Cancelar transformação (Esc) @@ -3816,6 +3817,3 @@ layerLabel No Color Sem cor Apply mask to layer before removing? Aplicar a máscara à camada antes de removê-la? The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente. Delete the vector mask? Excluir a máscara vetorial? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Gerencie os trabalhos enviados na fila de impressão do sistema diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6b7d9db398..4ad441bef7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -579,6 +579,7 @@ OK OK Cancel Отмена Cancel (Esc) Отмена (Esc) + Manage submitted jobs in the system print queue Управляйте отправленными заданиями в системной очереди печати Done Готово Apply Применить Reset Сброс @@ -2971,6 +2972,3 @@ curves Smooth Сгладить Apply mask to layer before removing? Применить маску к слою перед удалением? The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз. Delete the vector mask? Удалить векторную маску? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Управляйте отправленными заданиями в системной очереди печати diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dad9e6873c..8f9a7cb6f2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -557,6 +557,7 @@ OK Гаразд Cancel Скасувати Cancel (Esc) Скасувати (Esc) + Manage submitted jobs in the system print queue Керуйте надісланими завданнями в системній черзі друку Done Готово Apply Застосувати Reset Скинути @@ -3724,6 +3725,3 @@ curves Smooth Згладити Apply mask to layer before removing? Застосувати маску до шару перед видаленням? The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз. Delete the vector mask? Видалити векторну маску? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue Керуйте надісланими завданнями в системній черзі друку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d31a418148..414324d340 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -877,6 +877,7 @@ Camera Raw isn't open 尚未打开 Camera Raw Cancel 取消 Cancel (Esc) 取消(Esc) + Manage submitted jobs in the system print queue 在系统打印队列中管理已提交的作业 Cancel any current edits (Esc) 取消当前所有编辑(Esc) Cancel current crop operation (Esc) 取消当前裁剪操作 (Esc) Cancel transform (Esc) 取消变换(Esc) @@ -2646,6 +2647,3 @@ curves Smooth 平滑 Apply mask to layer before removing? 移去之前将蒙版应用到图层吗? The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。 Delete the vector mask? 要删除矢量蒙版吗? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue 在系统打印队列中管理已提交的作业 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7dc193999c..fda05b9245 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -880,6 +880,7 @@ Camera Raw isn't open Camera Raw 未開啟 Cancel 取消 Cancel (Esc) 取消 (Esc) + Manage submitted jobs in the system print queue 在系統列印佇列中管理已提交的工作 Cancel any current edits (Esc) 取消目前所有編輯 (Esc) Cancel current crop operation (Esc) 取消目前的裁切作業 (Esc) Cancel transform (Esc) 取消變形 (Esc) @@ -2616,6 +2617,3 @@ curves Smooth 平滑 Apply mask to layer before removing? 移除之前將遮色片套用到圖層嗎? The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。 Delete the vector mask? 要刪除向量遮色片嗎? - -# Submitted print jobs are managed by the operating system. - Manage submitted jobs in the system print queue 在系統列印佇列中管理已提交的工作 From 6651724db8eff2e6b9f1fb0ff62e8a2a3cc4c1b3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:30:41 +0200 Subject: [PATCH 014/523] Align CLI save test with native destination filename semantics --- apps/photocraft-cli/tests/cli.rs | 3 ++- apps/photocraft-cli/tests/orientation.rs | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index de04c3bf3a..9000cc35e7 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -189,9 +189,10 @@ fn run_commands_and_save() { let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[2]["command"], "document.inspect"); + assert_eq!(lines[2]["result"]["name"], "Run", "inspecting before --out retains the document title"); let (info, _) = ok(bin().arg("info").arg(&out_pc)); let v: Value = serde_json::from_str(&info).unwrap(); - assert_eq!(v["name"], "Run"); + assert_eq!(v["name"], "r.pcraft", "a native bundle stores its destination filename"); let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); assert_eq!(names, ["Ink", "Background"]); // Continue from the saved file and export PNG. diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/orientation.rs index 0d3ff9838f..e13319207c 100644 --- a/apps/photocraft-cli/tests/orientation.rs +++ b/apps/photocraft-cli/tests/orientation.rs @@ -121,18 +121,22 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12)); assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8)); - // .pcraft save, reopen and save again: identical document and pixels. + // .pcraft save, reopen and save again: destination names follow Save As; other metadata and pixels match. let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft")); ok(bin().arg("convert").arg(&jpg).arg(&a)); ok(bin().arg("convert").arg(&a).arg(&b)); - // Everything but the file's own path must match. + // Check destination names explicitly, then compare the remaining metadata. let info = |p: &Path| { let mut v = serde_json::from_str::(&ok(bin().arg("info").arg(p))).unwrap(); v.as_object_mut().unwrap().remove("file"); v }; - let (ia, ib) = (info(&a), info(&b)); + let (mut ia, mut ib) = (info(&a), info(&b)); assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32))); + assert_eq!(ia["name"], "a.pcraft"); + assert_eq!(ib["name"], "b.pcraft"); + ia.as_object_mut().unwrap().remove("name"); + ib.as_object_mut().unwrap().remove("name"); assert_eq!(ia, ib); let (pa, pb) = (d.join("a.png"), d.join("b.png")); ok(bin().arg("convert").arg(&a).arg(&pa)); From a538567a0debf8044da62bfa00ff2475d7936c09 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:49:59 +0200 Subject: [PATCH 015/523] Read plural keys from form arguments and report all catalog gaps --- xtask/src/i18n_coverage.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 817c364422..984d8c5ce5 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -185,9 +185,14 @@ fn plural_keys(sources: &[(PathBuf, String)]) -> BTreeSet { rest = rest.get(at + 4..).unwrap_or(""); let Some(end) = call_end(rest) else { break }; let args = rest.get(..end).unwrap_or(""); - let forms = quoted_strings(args); - if forms.len() >= 2 { - keys.insert(Key { context: "@plural".into(), source: format!("{}|{}", forms[0], forms[1]) }); + let args = split_arguments(args); + let literal = |arg: &&str| { + let arg = arg.trim(); + let strings = quoted_strings(arg); + if arg.starts_with('"') && arg.ends_with('"') && strings.len() == 1 { strings.into_iter().next() } else { None } + }; + if let Some((one, other)) = args.get(2).and_then(literal).zip(args.get(3).and_then(literal)) { + keys.insert(Key { context: "@plural".into(), source: format!("{one}|{other}") }); } rest = rest.get(end + 1..).unwrap_or(""); } @@ -545,6 +550,17 @@ mod tests { assert!(plural_keys(&sources).contains(&Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() })); } + #[test] + fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() { + let sources = vec![( + PathBuf::from("ui.rs"), + r#"trn(lang, r["files"].as_array().map_or(0, Vec::len) as u64, "Saved {n} file", "Saved {n} files"); + trn(lang, count, one, other);"# + .into(), + )]; + assert_eq!(plural_keys(&sources), BTreeSet::from([Key { context: "@plural".into(), source: "Saved {n} file|Saved {n} files".into() }])); + } + #[test] fn source_scanner_ignores_test_modules_with_lf_and_crlf() { for newline in ["\n", "\r\n"] { @@ -560,6 +576,7 @@ mod tests { fn source_key_set_is_covered_by_complete_catalogs() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")); let keys = source_keys(root).unwrap_or_default(); + let mut gaps = Vec::new(); for (code, path) in [ ("ja", "ja.tsv"), ("zh-hant", "zh-hant.tsv"), @@ -577,7 +594,10 @@ mod tests { let text = fs::read_to_string(&catalog_path).unwrap_or_default(); let translations = parse_catalog(&catalog_path, &text).unwrap_or_default(); let missing: Vec<_> = keys.difference(&translations).collect(); - assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}"); + if !missing.is_empty() { + gaps.push(format!("{code}: missing source keys: {missing:#?}")); + } } + assert!(gaps.is_empty(), "{}", gaps.join("\n")); } } From 82d2b7e0f14d5b86f8e089f4ccfe07e862e915e3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 04:54:47 +0200 Subject: [PATCH 016/523] Fill missing plural statuses in coverage-enforced catalogs --- crates/ui-egui/src/i18n/de.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/el.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/it.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/ja.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/nl.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/pt-br.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/ru.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 14 ++++++++++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 14 ++++++++++++++ 10 files changed, 140 insertions(+) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 7f64b11a2f..6dceac7cc6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -769,6 +769,20 @@ Statistics… Statistiken… Refine Strokes Verfeinerungsstriche @plural {n} item|{n} items {n} Element|{n} Elemente +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert +@plural Played {n} step|Played {n} steps {n} Schritt abgespielt|{n} Schritte abgespielt +@plural Saved for Web: {n} file|Saved for Web: {n} files Für Web gespeichert: {n} Datei|Für Web gespeichert: {n} Dateien +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} verknüpftes Smart-Objekt mit Änderungen auf dem Datenträger aktualisiert|{n} verknüpfte Smart-Objekte mit Änderungen auf dem Datenträger aktualisiert +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} in dieser Ebenenkomposition gespeicherte Ebene wurde gelöscht. Aktualisieren Sie die Komposition oder entfernen Sie die Warnung.|{n} in dieser Ebenenkomposition gespeicherte Ebenen wurden gelöscht. Aktualisieren Sie die Komposition oder entfernen Sie die Warnung. +@plural {n} plane|{n} planes {n} Ebene|{n} Ebenen +@plural {n} preset|{n} presets {n} Vorgabe|{n} Vorgaben +@plural {n} sample point removed|{n} sample points removed {n} Messpunkt entfernt|{n} Messpunkte entfernt +@plural {n} slice|{n} slices {n} Slice|{n} Slices +@plural {n} step|{n} steps {n} Schritt|{n} Schritte +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Warnung zu Pinselvorgaben)|{warning} (+{n} weitere Warnungen zu Pinselvorgaben) +@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen Auto Automatisch (print to the printer) (an den Drucker senden) + Add variable + Variable hinzufügen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 18b8fa110c..83dfb20a8a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3232,6 +3232,20 @@ @plural Group · {n} layer|Group · {n} layers Ομάδα · {n} επίπεδο|Ομάδα · {n} επίπεδα @plural {n} Layer|{n} Layers {n} επίπεδο|{n} επίπεδα @plural {n} item|{n} items {n} στοιχείο|{n} στοιχεία +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από το {group}|Εισήχθησαν {n} πινέλα από το {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file} +@plural Played {n} step|Played {n} steps Αναπαράχθηκε {n} βήμα|Αναπαράχθηκαν {n} βήματα +@plural Saved for Web: {n} file|Saved for Web: {n} files Αποθηκεύτηκε για Web: {n} αρχείο|Αποθηκεύτηκαν για Web: {n} αρχεία +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ενημερώθηκε {n} συνδεδεμένο έξυπνο αντικείμενο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα έξυπνα αντικείμενα που άλλαξαν στον δίσκο +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Διαγράφηκε {n} επίπεδο που καταγράφηκε σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή αφαιρέστε την προειδοποίηση.|Διαγράφηκαν {n} επίπεδα που καταγράφηκαν σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή αφαιρέστε την προειδοποίηση. +@plural {n} plane|{n} planes {n} επίπεδο προοπτικής|{n} επίπεδα προοπτικής +@plural {n} preset|{n} presets {n} προεπιλογή|{n} προεπιλογές +@plural {n} sample point removed|{n} sample points removed Αφαιρέθηκε {n} σημείο δείγματος|Αφαιρέθηκαν {n} σημεία δείγματος +@plural {n} slice|{n} slices {n} τμήμα εικόνας|{n} τμήματα εικόνας +@plural {n} step|{n} steps {n} βήμα|{n} βήματα +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογής πινέλου)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων) +@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις @plural {n} layer|{n} layers {n} επίπεδο|{n} επίπεδα @plural {n} sample|{n} samples {n} δείγμα|{n} δείγματα cameraRaw Amount Ποσότητα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index dc3dcc662f..127ef219fc 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -776,6 +776,20 @@ Statistics… Estadísticas… Refine Strokes Trazos de perfeccionamiento @plural {n} item|{n} items {n} elemento|{n} elementos +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se exportó {n} muestra a {file}|Se exportaron {n} muestras a {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se importó {n} pincel de {group}|Se importaron {n} pinceles de {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se importó {n} muestra de {file}|Se importaron {n} muestras de {file} +@plural Played {n} step|Played {n} steps Se reprodujo {n} paso|Se reprodujeron {n} pasos +@plural Saved for Web: {n} file|Saved for Web: {n} files Guardado para Web: {n} archivo|Guardado para Web: {n} archivos +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Se actualizó {n} objeto inteligente vinculado que cambió en disco|Se actualizaron {n} objetos inteligentes vinculados que cambiaron en disco +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Se eliminó {n} capa registrada en esta composición. Actualice la composición o borre la advertencia.|Se eliminaron {n} capas registradas en esta composición. Actualice la composición o borre la advertencia. +@plural {n} plane|{n} planes {n} plano|{n} planos +@plural {n} preset|{n} presets {n} ajuste preestablecido|{n} ajustes preestablecidos +@plural {n} sample point removed|{n} sample points removed Se eliminó {n} punto de muestra|Se eliminaron {n} puntos de muestra +@plural {n} slice|{n} slices {n} sector|{n} sectores +@plural {n} step|{n} steps {n} paso|{n} pasos +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel) +@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias Auto Automático # Dialogs and panels diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b9db5914ed..627cf3caec 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -774,6 +774,20 @@ Statistics… Statistiche… Refine Strokes Tratti di perfezionamento @plural {n} item|{n} items {n} elemento|{n} elementi +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Esportato {n} campione in {file}|Esportati {n} campioni in {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importato {n} pennello da {group}|Importati {n} pennelli da {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importato {n} campione da {file}|Importati {n} campioni da {file} +@plural Played {n} step|Played {n} steps Riprodotto {n} passaggio|Riprodotti {n} passaggi +@plural Saved for Web: {n} file|Saved for Web: {n} files Salvato per Web: {n} file|Salvati per Web: {n} file +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Aggiornato {n} oggetto avanzato collegato modificato su disco|Aggiornati {n} oggetti avanzati collegati modificati su disco +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. È stato eliminato {n} livello registrato in questa composizione. Aggiorna la composizione o rimuovi l’avviso.|Sono stati eliminati {n} livelli registrati in questa composizione. Aggiorna la composizione o rimuovi l’avviso. +@plural {n} plane|{n} planes {n} piano|{n} piani +@plural {n} preset|{n} presets {n} predefinito|{n} predefiniti +@plural {n} sample point removed|{n} sample points removed Rimosso {n} punto campione|Rimossi {n} punti campione +@plural {n} slice|{n} slices {n} sezione|{n} sezioni +@plural {n} step|{n} steps {n} passaggio|{n} passaggi +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso di predefinito pennello)|{warning} (+{n} altri avvisi di predefiniti pennello) +@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi Auto Automatico # Dialogs and panels diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9eb7eff242..ef902970aa 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -776,6 +776,20 @@ Statistics… 統計情報… Refine Strokes 調整ストローク @plural {n} item|{n} items {n} 件 +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {file} にスウォッチ {n} 個を書き出しました +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} からブラシ {n} 個を読み込みました +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} からスウォッチ {n} 個を読み込みました +@plural Played {n} step|Played {n} steps {n} ステップを再生しました +@plural Saved for Web: {n} file|Saved for Web: {n} files Web 用に保存: {n} ファイル +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk ディスク上で変更されたリンクされたスマートオブジェクト {n} 個を更新しました +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. このレイヤーカンプに記録された {n} 個のレイヤーが削除されました。カンプを更新するか、警告を消去してください。 +@plural {n} plane|{n} planes {n} 平面 +@plural {n} preset|{n} presets {n} プリセット +@plural {n} sample point removed|{n} sample points removed サンプルポイント {n} 個を削除しました +@plural {n} slice|{n} slices {n} スライス +@plural {n} step|{n} steps {n} ステップ +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ほかにブラシプリセットの警告 {n} 件) +@plural {what} with {n} warning|{what} with {n} warnings {what} (警告 {n} 件) Auto 自動 # Dialogs and panels diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 598d6ae601..0d1875f36f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -759,6 +759,20 @@ Statistics… Statistieken… Refine Strokes Verfijningsstreken @plural {n} item|{n} items {n} element|{n} elementen +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file} +@plural Played {n} step|Played {n} steps {n} stap afgespeeld|{n} stappen afgespeeld +@plural Saved for Web: {n} file|Saved for Web: {n} files Opgeslagen voor web: {n} bestand|Opgeslagen voor web: {n} bestanden +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} gekoppeld slim object met wijzigingen op schijf bijgewerkt|{n} gekoppelde slimme objecten met wijzigingen op schijf bijgewerkt +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} laag die in deze laagcompositie is vastgelegd is verwijderd. Werk de compositie bij of verwijder de waarschuwing.|{n} lagen die in deze laagcompositie zijn vastgelegd zijn verwijderd. Werk de compositie bij of verwijder de waarschuwing. +@plural {n} plane|{n} planes {n} vlak|{n} vlakken +@plural {n} preset|{n} presets {n} voorinstelling|{n} voorinstellingen +@plural {n} sample point removed|{n} sample points removed {n} monsterpunt verwijderd|{n} monsterpunten verwijderd +@plural {n} slice|{n} slices {n} segment|{n} segmenten +@plural {n} step|{n} steps {n} stap|{n} stappen +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} extra waarschuwing voor penseelvoorinstellingen)|{warning} (+{n} extra waarschuwingen voor penseelvoorinstellingen) +@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen Auto Automatisch (print to the printer) (naar de printer afdrukken) + Add variable + Variabele toevoegen diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a243cebc8..8c9348284e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -761,6 +761,20 @@ Statistics… Estatísticas… Refine Strokes Traços de refinamento @plural {n} item|{n} items {n} item|{n} itens +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file} +@plural Played {n} step|Played {n} steps {n} etapa reproduzida|{n} etapas reproduzidas +@plural Saved for Web: {n} file|Saved for Web: {n} files Salvo para Web: {n} arquivo|Salvo para Web: {n} arquivos +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objeto inteligente vinculado alterado no disco foi atualizado|{n} objetos inteligentes vinculados alterados no disco foram atualizados +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} camada registrada nesta composição foi excluída. Atualize a composição ou remova o aviso.|{n} camadas registradas nesta composição foram excluídas. Atualize a composição ou remova o aviso. +@plural {n} plane|{n} planes {n} plano|{n} planos +@plural {n} preset|{n} presets {n} predefinição|{n} predefinições +@plural {n} sample point removed|{n} sample points removed {n} ponto de amostra removido|{n} pontos de amostra removidos +@plural {n} slice|{n} slices {n} fatia|{n} fatias +@plural {n} step|{n} steps {n} etapa|{n} etapas +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} aviso adicional de predefinição de pincel)|{warning} (+{n} avisos adicionais de predefinições de pincel) +@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos Auto Automático (print to the printer) (imprimir na impressora) + Add variable + Adicionar variável diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4ad441bef7..5334137310 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1807,6 +1807,20 @@ # Plurals @plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортировано {n} образца в {file}|Экспортировано {n} образцов в {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортировано {n} кисти из {group}|Импортировано {n} кистей из {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортировано {n} образца из {file}|Импортировано {n} образцов из {file} +@plural Played {n} step|Played {n} steps Выполнен {n} шаг|Выполнено {n} шага|Выполнено {n} шагов +@plural Saved for Web: {n} file|Saved for Web: {n} files Сохранено для Web: {n} файл|Сохранено для Web: {n} файла|Сохранено для Web: {n} файлов +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Обновлён {n} связанный смарт-объект, изменённый на диске|Обновлено {n} связанных смарт-объекта, изменённых на диске|Обновлено {n} связанных смарт-объектов, изменённых на диске +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} слой, записанный в этом варианте слоя, был удалён. Обновите вариант или уберите предупреждение.|{n} слоя, записанных в этом варианте слоя, были удалены. Обновите вариант или уберите предупреждение.|{n} слоёв, записанных в этом варианте слоя, были удалены. Обновите вариант или уберите предупреждение. +@plural {n} plane|{n} planes {n} плоскость|{n} плоскости|{n} плоскостей +@plural {n} preset|{n} presets {n} набор|{n} набора|{n} наборов +@plural {n} sample point removed|{n} sample points removed Удалена {n} точка образца|Удалено {n} точки образца|Удалено {n} точек образца +@plural {n} slice|{n} slices {n} фрагмент|{n} фрагмента|{n} фрагментов +@plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} дополнительное предупреждение о наборе кистей)|{warning} (+{n} дополнительных предупреждения о наборе кистей)|{warning} (+{n} дополнительных предупреждений о наборе кистей) +@plural {what} with {n} warning|{what} with {n} warnings {what} с {n} предупреждением|{what} с {n} предупреждениями|{what} с {n} предупреждениями @plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв @plural {n} layer|{n} layers {n} слой|{n} слоя|{n} слоёв @plural Group · {n} layer|Group · {n} layers Группа · {n} слой|Группа · {n} слоя|Группа · {n} слоёв diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8f9a7cb6f2..6479030ebc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1786,6 +1786,20 @@ # Plurals @plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель із {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів із {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів із {file}|Імпортовано {n} зразків кольорів із {file} +@plural Played {n} step|Played {n} steps Відтворено {n} крок|Відтворено {n} кроки|Відтворено {n} кроків +@plural Saved for Web: {n} file|Saved for Web: {n} files Збережено для Web: {n} файл|Збережено для Web: {n} файли|Збережено для Web: {n} файлів +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Оновлено {n} зв’язаний смартоб’єкт, змінений на диску|Оновлено {n} зв’язані смартоб’єкти, змінені на диску|Оновлено {n} зв’язаних смартоб’єктів, змінених на диску +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Видалено {n} шар, записаний у цій композиції. Оновіть композицію або приберіть попередження.|Видалено {n} шари, записані в цій композиції. Оновіть композицію або приберіть попередження.|Видалено {n} шарів, записаних у цій композиції. Оновіть композицію або приберіть попередження. +@plural {n} plane|{n} planes {n} площина|{n} площини|{n} площин +@plural {n} preset|{n} presets {n} набір|{n} набори|{n} наборів +@plural {n} sample point removed|{n} sample points removed Видалено {n} точку зразка|Видалено {n} точки зразка|Видалено {n} точок зразка +@plural {n} slice|{n} slices {n} фрагмент|{n} фрагменти|{n} фрагментів +@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} додаткове попередження про набір пензлів)|{warning} (+{n} додаткові попередження про набори пензлів)|{warning} (+{n} додаткових попереджень про набори пензлів) +@plural {what} with {n} warning|{what} with {n} warnings {what} з {n} попередженням|{what} з {n} попередженнями|{what} з {n} попередженнями @plural {n} Layer|{n} Layers {n} Шар|{n} Шари|{n} Шарів @plural {n} layer|{n} layers {n} шар|{n} шари|{n} шарів @plural Group · {n} layer|Group · {n} layers Група · {n} шар|Група · {n} шари|Група · {n} шарів diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fda05b9245..d5ba8f0bc2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -783,6 +783,20 @@ Statistics… 統計資料… Refine Strokes 調整筆畫 @plural {n} item|{n} items {n} 個項目 +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷 +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票 +@plural Played {n} step|Played {n} steps 已播放 {n} 個步驟 +@plural Saved for Web: {n} file|Saved for Web: {n} files 儲存為網頁: {n} 個檔案 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 個在磁碟上變更的連結智慧型物件 +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此圖層構圖中記錄的 {n} 個圖層已刪除。請更新構圖或清除警告。 +@plural {n} plane|{n} planes {n} 個平面 +@plural {n} preset|{n} presets {n} 個預設集 +@plural {n} sample point removed|{n} sample points removed 已移除 {n} 個取樣點 +@plural {n} slice|{n} slices {n} 個切片 +@plural {n} step|{n} steps {n} 個步驟 +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告) +@plural {what} with {n} warning|{what} with {n} warnings {what},有 {n} 個警告 Auto 自動 (print to the printer) (列印至印表機) + Add variable + 新增變數 From 67ca74fe182c2796d6a658a59495dd9dd85f6c75 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 05:44:09 +0200 Subject: [PATCH 017/523] Align overlapping CI repairs with upstream while retaining regression coverage --- apps/photocraft-cli/tests/cli.rs | 5 +-- apps/photocraft-cli/tests/orientation.rs | 5 +-- crates/engine/src/adjust_params.rs | 38 ++++++++++++++++----- xtask/src/i18n_coverage.rs | 43 +++++++++++------------- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 9000cc35e7..5baa83fde3 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -189,10 +189,11 @@ fn run_commands_and_save() { let lines: Vec = out.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); assert_eq!(lines.len(), 3); assert_eq!(lines[2]["command"], "document.inspect"); - assert_eq!(lines[2]["result"]["name"], "Run", "inspecting before --out retains the document title"); + assert_eq!(lines[2]["result"]["name"], "Run"); let (info, _) = ok(bin().arg("info").arg(&out_pc)); let v: Value = serde_json::from_str(&info).unwrap(); - assert_eq!(v["name"], "r.pcraft", "a native bundle stores its destination filename"); + // Saving a native document gives the reopened bundle its output filename. + assert_eq!(v["name"], "r.pcraft"); let names: Vec<&str> = v["layers"].as_array().unwrap().iter().filter_map(|l| l["name"].as_str()).collect(); assert_eq!(names, ["Ink", "Background"]); // Continue from the saved file and export PNG. diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/orientation.rs index e13319207c..101195581f 100644 --- a/apps/photocraft-cli/tests/orientation.rs +++ b/apps/photocraft-cli/tests/orientation.rs @@ -121,14 +121,15 @@ fn orientation_6_jpeg_converts_upright_and_rotates_back() { assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12)); assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8)); - // .pcraft save, reopen and save again: destination names follow Save As; other metadata and pixels match. + // .pcraft save, reopen and save again: identical document and pixels. let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft")); ok(bin().arg("convert").arg(&jpg).arg(&a)); ok(bin().arg("convert").arg(&a).arg(&b)); - // Check destination names explicitly, then compare the remaining metadata. + // Everything but the file's own path and saved document name must match. let info = |p: &Path| { let mut v = serde_json::from_str::(&ok(bin().arg("info").arg(p))).unwrap(); v.as_object_mut().unwrap().remove("file"); + v.as_object_mut().unwrap().remove("path"); v }; let (mut ia, mut ib) = (info(&a), info(&b)); diff --git a/crates/engine/src/adjust_params.rs b/crates/engine/src/adjust_params.rs index 4b2f3c79c4..5885f0cdb7 100644 --- a/crates/engine/src/adjust_params.rs +++ b/crates/engine/src/adjust_params.rs @@ -1624,12 +1624,10 @@ mod tests { #[test] fn destructive_levels_dont_clip_in_32_bit() { - // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in - // integer documents. Float32 keeps HDR values outside 0..1, as the compositor's real-file - // Levels oracle does. These independently calculated endpoints use the signed-power - // curve at black and white, not display-clipped values. + // Input 15..230, gamma 1.3, output 10..245: integer documents stop at the output range, + // while float documents preserve the curve's signed and HDR values beyond it. let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); - for (depth, low, high, tolerance) in [(8, 10.0, 245.0, 0.6), (16, 10.0, 245.0, 0.6), (32, -20.308_89, 265.750_3, 0.01)] { + for depth in [8, 16, 32] { let mut s = crate::Session::new(); s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); @@ -1637,10 +1635,12 @@ mod tests { s.execute("select.deselect", json!({})).unwrap(); s.execute("image.adjustments.levels", params.clone()).unwrap(); let px = pixels(&s); - assert!((px[0][0] * 255.0 - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); - assert!((px[1][0] * 255.0 - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); if depth == 32 { - assert!(px[0][0] < 0.0 && px[1][0] > 1.0, "HDR endpoints must remain unclipped"); + assert!(px[0][0].is_finite() && px[0][0] < 0.0, "float black keeps its signed result: {:?}", px[0]); + assert!(px[1][0].is_finite() && px[1][0] > 1.0, "float white keeps its HDR result: {:?}", px[1]); + } else { + assert!((px[0][0] * 255.0 - 10.0).abs() < 0.6, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - 245.0).abs() < 0.6, "white @{depth}: {:?}", px[1]); } } } @@ -1706,4 +1706,26 @@ mod tests { } } } + #[test] + fn destructive_levels_match_independent_signed_hdr_endpoints() { + // Input 15..230, gamma 1.3, output 10..245: black and white land on the output range in + // integer documents. Float32 keeps HDR values outside 0..1, as the compositor's real-file + // Levels oracle does. These independently calculated endpoints use the signed-power + // curve at black and white, not display-clipped values. + let params = json!({"inBlack": 15, "inWhite": 230, "gamma": 1.3, "outBlack": 10, "outWhite": 245}); + for (depth, low, high, tolerance) in [(8, 10.0, 245.0, 0.6), (16, 10.0, 245.0, 0.6), (32, -20.308_89, 265.750_3, 0.01)] { + let mut s = crate::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "depth": depth, "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 16})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("image.adjustments.levels", params.clone()).unwrap(); + let px = pixels(&s); + assert!((px[0][0] * 255.0 - low).abs() < tolerance, "black @{depth}: {:?}", px[0]); + assert!((px[1][0] * 255.0 - high).abs() < tolerance, "white @{depth}: {:?}", px[1]); + if depth == 32 { + assert!(px[0][0] < 0.0 && px[1][0] > 1.0, "HDR endpoints must remain unclipped"); + } + } + } } diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 984d8c5ce5..ca2787b82d 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -185,13 +185,10 @@ fn plural_keys(sources: &[(PathBuf, String)]) -> BTreeSet { rest = rest.get(at + 4..).unwrap_or(""); let Some(end) = call_end(rest) else { break }; let args = rest.get(..end).unwrap_or(""); - let args = split_arguments(args); - let literal = |arg: &&str| { - let arg = arg.trim(); - let strings = quoted_strings(arg); - if arg.starts_with('"') && arg.ends_with('"') && strings.len() == 1 { strings.into_iter().next() } else { None } - }; - if let Some((one, other)) = args.get(2).and_then(literal).zip(args.get(3).and_then(literal)) { + // `trn(lang, n, one, other)`: the forms are the last two literals, so a string in the + // count expression (`r["files"].len()`) is not mistaken for one. + let forms = quoted_strings(args); + if let [.., one, other] = forms.as_slice() { keys.insert(Key { context: "@plural".into(), source: format!("{one}|{other}") }); } rest = rest.get(end + 1..).unwrap_or(""); @@ -548,17 +545,11 @@ mod tests { fn plural_keys_come_from_source_calls() { let sources = vec![(PathBuf::from("ui.rs"), r#"trn(lang, count, "{n} layer", "{n} layers");"#.into())]; assert!(plural_keys(&sources).contains(&Key { context: "@plural".into(), source: "{n} layer|{n} layers".into() })); - } - - #[test] - fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() { - let sources = vec![( - PathBuf::from("ui.rs"), - r#"trn(lang, r["files"].as_array().map_or(0, Vec::len) as u64, "Saved {n} file", "Saved {n} files"); - trn(lang, count, one, other);"# - .into(), - )]; - assert_eq!(plural_keys(&sources), BTreeSet::from([Key { context: "@plural".into(), source: "Saved {n} file|Saved {n} files".into() }])); + // A string literal in the count expression is not one of the forms. + let sources = vec![(PathBuf::from("ui.rs"), r#"trn(lang, r["files"].len() as u64, "{n} file", "{n} files");"#.into())]; + let keys = plural_keys(&sources); + assert!(keys.contains(&Key { context: "@plural".into(), source: "{n} file|{n} files".into() }), "{keys:?}"); + assert_eq!(keys.len(), 1, "{keys:?}"); } #[test] @@ -576,7 +567,6 @@ mod tests { fn source_key_set_is_covered_by_complete_catalogs() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")); let keys = source_keys(root).unwrap_or_default(); - let mut gaps = Vec::new(); for (code, path) in [ ("ja", "ja.tsv"), ("zh-hant", "zh-hant.tsv"), @@ -594,10 +584,17 @@ mod tests { let text = fs::read_to_string(&catalog_path).unwrap_or_default(); let translations = parse_catalog(&catalog_path, &text).unwrap_or_default(); let missing: Vec<_> = keys.difference(&translations).collect(); - if !missing.is_empty() { - gaps.push(format!("{code}: missing source keys: {missing:#?}")); - } + assert!(missing.is_empty(), "{code}: missing source keys: {missing:#?}"); } - assert!(gaps.is_empty(), "{}", gaps.join("\n")); + } + #[test] + fn plural_forms_follow_count_expressions_and_dynamic_forms_are_not_literal_keys() { + let sources = vec![( + PathBuf::from("ui.rs"), + r#"trn(lang, r["files"].as_array().map_or(0, Vec::len) as u64, "Saved {n} file", "Saved {n} files"); + trn(lang, count, one, other);"# + .into(), + )]; + assert_eq!(plural_keys(&sources), BTreeSet::from([Key { context: "@plural".into(), source: "Saved {n} file|Saved {n} files".into() }])); } } From 8c557fcb7d12a8f0d7d5ba07e9ee1bd1375cdfbd Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 05:46:49 +0200 Subject: [PATCH 018/523] Place identical browser engine profile beside upstream override --- Cargo.toml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c0d911604..65e6c12d23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,8 +82,8 @@ codegen-units = 1 # The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the # size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every -# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI shell, and "s" for -# other crates except pixel processing, which keeps opt-level 3 for filters and compositing. panic +# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O +# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic # stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for # the native app shell). packaging/web/package.sh fails the build above 24 MiB. [profile.wasm-release] @@ -101,6 +101,15 @@ opt-level = "s" [profile.wasm-release.package.photocraft-ui-egui] opt-level = "z" +# Keep browser command dispatch, import/export glue, and the entry point compact. Native +# builds and the speed-oriented pixel-processing overrides below retain their settings. +[profile.wasm-release.package.photocraft-engine] +opt-level = "z" +[profile.wasm-release.package.photocraft-io] +opt-level = "z" +[profile.wasm-release.package.photocraft-web] +opt-level = "z" + [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] @@ -114,6 +123,3 @@ opt-level = 3 [profile.wasm-release.package.photocraft-paint] opt-level = 3 -# Size-focused command orchestration in the browser; pixel kernels keep their overrides above. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" From 0467f715c60cdc30ae4af77639603935c7ecb9a0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 05:48:59 +0200 Subject: [PATCH 019/523] Reuse upstream counted catalogs to prevent duplicate keys in PR merge --- crates/ui-egui/src/i18n/de.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/el.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/es.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/it.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/ja.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/nl.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/pt-br.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/ru.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/uk.tsv | 29 +++++++++++++++-------------- crates/ui-egui/src/i18n/zh-hant.tsv | 29 +++++++++++++++-------------- 10 files changed, 150 insertions(+), 140 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6dceac7cc6..3deb6aefe8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -769,20 +769,6 @@ Statistics… Statistiken… Refine Strokes Verfeinerungsstriche @plural {n} item|{n} items {n} Element|{n} Elemente -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert -@plural Played {n} step|Played {n} steps {n} Schritt abgespielt|{n} Schritte abgespielt -@plural Saved for Web: {n} file|Saved for Web: {n} files Für Web gespeichert: {n} Datei|Für Web gespeichert: {n} Dateien -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} verknüpftes Smart-Objekt mit Änderungen auf dem Datenträger aktualisiert|{n} verknüpfte Smart-Objekte mit Änderungen auf dem Datenträger aktualisiert -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} in dieser Ebenenkomposition gespeicherte Ebene wurde gelöscht. Aktualisieren Sie die Komposition oder entfernen Sie die Warnung.|{n} in dieser Ebenenkomposition gespeicherte Ebenen wurden gelöscht. Aktualisieren Sie die Komposition oder entfernen Sie die Warnung. -@plural {n} plane|{n} planes {n} Ebene|{n} Ebenen -@plural {n} preset|{n} presets {n} Vorgabe|{n} Vorgaben -@plural {n} sample point removed|{n} sample points removed {n} Messpunkt entfernt|{n} Messpunkte entfernt -@plural {n} slice|{n} slices {n} Slice|{n} Slices -@plural {n} step|{n} steps {n} Schritt|{n} Schritte -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Warnung zu Pinselvorgaben)|{warning} (+{n} weitere Warnungen zu Pinselvorgaben) -@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen Auto Automatisch (print to the printer) (an den Drucker senden) + Add variable + Variable hinzufügen @@ -2681,3 +2667,18 @@ curves Smooth Glätten Apply mask to layer before removing? Maske vor dem Entfernen auf die Ebene anwenden? The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb. Delete the vector mask? Vektormaske löschen? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} Farbfeld nach {file} exportiert|{n} Farbfelder nach {file} exportiert +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} Pinsel aus {group} importiert|{n} Pinsel aus {group} importiert +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} Farbfeld aus {file} importiert|{n} Farbfelder aus {file} importiert +@plural Played {n} step|Played {n} steps {n} Schritt wiedergegeben|{n} Schritte wiedergegeben +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} verknüpftes Smart-Objekt aktualisiert, das auf dem Datenträger geändert wurde|{n} verknüpfte Smart-Objekte aktualisiert, die auf dem Datenträger geändert wurden +@plural Saved for Web: {n} file|Saved for Web: {n} files Für Web gespeichert: {n} Datei|Für Web gespeichert: {n} Dateien +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} von dieser Komposition erfasste Ebene wurde gelöscht. Aktualisiere die Komposition oder lösche die Warnung.|{n} von dieser Komposition erfasste Ebenen wurden gelöscht. Aktualisiere die Komposition oder lösche die Warnung. +@plural {n} plane|{n} planes {n} Fläche|{n} Flächen +@plural {n} preset|{n} presets {n} Vorgabe|{n} Vorgaben +@plural {n} sample point removed|{n} sample points removed {n} Messpunkt entfernt|{n} Messpunkte entfernt +@plural {n} slice|{n} slices {n} Slice|{n} Slices +@plural {n} step|{n} steps {n} Schritt|{n} Schritte +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} weitere Pinselvorgaben-Warnung)|{warning} (+{n} weitere Pinselvorgaben-Warnungen) +@plural {what} with {n} warning|{what} with {n} warnings {what} mit {n} Warnung|{what} mit {n} Warnungen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 83dfb20a8a..ae85c5293c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3232,20 +3232,6 @@ @plural Group · {n} layer|Group · {n} layers Ομάδα · {n} επίπεδο|Ομάδα · {n} επίπεδα @plural {n} Layer|{n} Layers {n} επίπεδο|{n} επίπεδα @plural {n} item|{n} items {n} στοιχείο|{n} στοιχεία -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από το {group}|Εισήχθησαν {n} πινέλα από το {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file} -@plural Played {n} step|Played {n} steps Αναπαράχθηκε {n} βήμα|Αναπαράχθηκαν {n} βήματα -@plural Saved for Web: {n} file|Saved for Web: {n} files Αποθηκεύτηκε για Web: {n} αρχείο|Αποθηκεύτηκαν για Web: {n} αρχεία -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ενημερώθηκε {n} συνδεδεμένο έξυπνο αντικείμενο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα έξυπνα αντικείμενα που άλλαξαν στον δίσκο -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Διαγράφηκε {n} επίπεδο που καταγράφηκε σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή αφαιρέστε την προειδοποίηση.|Διαγράφηκαν {n} επίπεδα που καταγράφηκαν σε αυτή τη σύνθεση. Ενημερώστε τη σύνθεση ή αφαιρέστε την προειδοποίηση. -@plural {n} plane|{n} planes {n} επίπεδο προοπτικής|{n} επίπεδα προοπτικής -@plural {n} preset|{n} presets {n} προεπιλογή|{n} προεπιλογές -@plural {n} sample point removed|{n} sample points removed Αφαιρέθηκε {n} σημείο δείγματος|Αφαιρέθηκαν {n} σημεία δείγματος -@plural {n} slice|{n} slices {n} τμήμα εικόνας|{n} τμήματα εικόνας -@plural {n} step|{n} steps {n} βήμα|{n} βήματα -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογής πινέλου)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων) -@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις @plural {n} layer|{n} layers {n} επίπεδο|{n} επίπεδα @plural {n} sample|{n} samples {n} δείγμα|{n} δείγματα cameraRaw Amount Ποσότητα @@ -3739,3 +3725,18 @@ curves Smooth Εξομάλυνση Apply mask to layer before removing? Εφαρμογή της μάσκας στο επίπεδο πριν από την αφαίρεση; The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο. Delete the vector mask? Διαγραφή της διανυσματικής μάσκας; +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Εξήχθη {n} δείγμα χρώματος στο {file}|Εξήχθησαν {n} δείγματα χρωμάτων στο {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Εισήχθη {n} πινέλο από την ομάδα {group}|Εισήχθησαν {n} πινέλα από την ομάδα {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Εισήχθη {n} δείγμα χρώματος από το {file}|Εισήχθησαν {n} δείγματα χρωμάτων από το {file} +@plural Played {n} step|Played {n} steps Αναπαράχθηκε {n} βήμα|Αναπαράχθηκαν {n} βήματα +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ενημερώθηκε {n} συνδεδεμένο έξυπνο αντικείμενο που άλλαξε στον δίσκο|Ενημερώθηκαν {n} συνδεδεμένα έξυπνα αντικείμενα που άλλαξαν στον δίσκο +@plural Saved for Web: {n} file|Saved for Web: {n} files Αποθήκευση για Web: {n} αρχείο|Αποθήκευση για Web: {n} αρχεία +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Διαγράφηκε {n} επίπεδο που καταγράφει αυτή η σύνθεση. Ενημερώστε τη σύνθεση ή καταργήστε την προειδοποίηση.|Διαγράφηκαν {n} επίπεδα που καταγράφει αυτή η σύνθεση. Ενημερώστε τη σύνθεση ή καταργήστε την προειδοποίηση. +@plural {n} plane|{n} planes {n} επίπεδο προοπτικής|{n} επίπεδα προοπτικής +@plural {n} preset|{n} presets {n} προεπιλογή|{n} προεπιλογές +@plural {n} sample point removed|{n} sample points removed Αφαιρέθηκε {n} σημείο δειγματοληψίας|Αφαιρέθηκαν {n} σημεία δειγματοληψίας +@plural {n} slice|{n} slices {n} τομή|{n} τομές +@plural {n} step|{n} steps {n} βήμα|{n} βήματα +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} ακόμη προειδοποίηση προεπιλογών πινέλων)|{warning} (+{n} ακόμη προειδοποιήσεις προεπιλογών πινέλων) +@plural {what} with {n} warning|{what} with {n} warnings {what} με {n} προειδοποίηση|{what} με {n} προειδοποιήσεις diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 127ef219fc..1496ca4861 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -776,20 +776,6 @@ Statistics… Estadísticas… Refine Strokes Trazos de perfeccionamiento @plural {n} item|{n} items {n} elemento|{n} elementos -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se exportó {n} muestra a {file}|Se exportaron {n} muestras a {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se importó {n} pincel de {group}|Se importaron {n} pinceles de {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se importó {n} muestra de {file}|Se importaron {n} muestras de {file} -@plural Played {n} step|Played {n} steps Se reprodujo {n} paso|Se reprodujeron {n} pasos -@plural Saved for Web: {n} file|Saved for Web: {n} files Guardado para Web: {n} archivo|Guardado para Web: {n} archivos -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Se actualizó {n} objeto inteligente vinculado que cambió en disco|Se actualizaron {n} objetos inteligentes vinculados que cambiaron en disco -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Se eliminó {n} capa registrada en esta composición. Actualice la composición o borre la advertencia.|Se eliminaron {n} capas registradas en esta composición. Actualice la composición o borre la advertencia. -@plural {n} plane|{n} planes {n} plano|{n} planos -@plural {n} preset|{n} presets {n} ajuste preestablecido|{n} ajustes preestablecidos -@plural {n} sample point removed|{n} sample points removed Se eliminó {n} punto de muestra|Se eliminaron {n} puntos de muestra -@plural {n} slice|{n} slices {n} sector|{n} sectores -@plural {n} step|{n} steps {n} paso|{n} pasos -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel) -@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias Auto Automático # Dialogs and panels @@ -2722,3 +2708,18 @@ curves Smooth Suavizar Apply mask to layer before removing? ¿Aplicar la máscara a la capa antes de eliminarla? The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera. Delete the vector mask? ¿Eliminar la máscara vectorial? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Se exportó {n} muestra a {file}|Se exportaron {n} muestras a {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Se importó {n} pincel de {group}|Se importaron {n} pinceles de {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Se importó {n} muestra de {file}|Se importaron {n} muestras de {file} +@plural Played {n} step|Played {n} steps Se reprodujo {n} paso|Se reprodujeron {n} pasos +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Se actualizó {n} objeto inteligente enlazado modificado en el disco|Se actualizaron {n} objetos inteligentes enlazados modificados en el disco +@plural Saved for Web: {n} file|Saved for Web: {n} files Guardado para Web: {n} archivo|Guardado para Web: {n} archivos +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Se eliminó {n} capa registrada por esta composición. Actualice la composición o borre la advertencia.|Se eliminaron {n} capas registradas por esta composición. Actualice la composición o borre la advertencia. +@plural {n} plane|{n} planes {n} plano|{n} planos +@plural {n} preset|{n} presets {n} ajuste preestablecido|{n} ajustes preestablecidos +@plural {n} sample point removed|{n} sample points removed Se quitó {n} punto de muestreo|Se quitaron {n} puntos de muestreo +@plural {n} slice|{n} slices {n} sector|{n} sectores +@plural {n} step|{n} steps {n} paso|{n} pasos +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} advertencia más de ajustes preestablecidos de pincel)|{warning} (+{n} advertencias más de ajustes preestablecidos de pincel) +@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} advertencia|{what} con {n} advertencias diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 627cf3caec..3dd931dccd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -774,20 +774,6 @@ Statistics… Statistiche… Refine Strokes Tratti di perfezionamento @plural {n} item|{n} items {n} elemento|{n} elementi -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Esportato {n} campione in {file}|Esportati {n} campioni in {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Importato {n} pennello da {group}|Importati {n} pennelli da {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Importato {n} campione da {file}|Importati {n} campioni da {file} -@plural Played {n} step|Played {n} steps Riprodotto {n} passaggio|Riprodotti {n} passaggi -@plural Saved for Web: {n} file|Saved for Web: {n} files Salvato per Web: {n} file|Salvati per Web: {n} file -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Aggiornato {n} oggetto avanzato collegato modificato su disco|Aggiornati {n} oggetti avanzati collegati modificati su disco -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. È stato eliminato {n} livello registrato in questa composizione. Aggiorna la composizione o rimuovi l’avviso.|Sono stati eliminati {n} livelli registrati in questa composizione. Aggiorna la composizione o rimuovi l’avviso. -@plural {n} plane|{n} planes {n} piano|{n} piani -@plural {n} preset|{n} presets {n} predefinito|{n} predefiniti -@plural {n} sample point removed|{n} sample points removed Rimosso {n} punto campione|Rimossi {n} punti campione -@plural {n} slice|{n} slices {n} sezione|{n} sezioni -@plural {n} step|{n} steps {n} passaggio|{n} passaggi -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso di predefinito pennello)|{warning} (+{n} altri avvisi di predefiniti pennello) -@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi Auto Automatico # Dialogs and panels @@ -2713,3 +2699,18 @@ curves Smooth Leviga Apply mask to layer before removing? Applicare la maschera al livello prima di rimuoverla? The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino. Delete the vector mask? Eliminare la maschera vettoriale? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} campione esportato in {file}|{n} campioni esportati in {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pennello importato da {group}|{n} pennelli importati da {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} campione importato da {file}|{n} campioni importati da {file} +@plural Played {n} step|Played {n} steps {n} passaggio riprodotto|{n} passaggi riprodotti +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} oggetto avanzato collegato modificato su disco aggiornato|{n} oggetti avanzati collegati modificati su disco aggiornati +@plural Saved for Web: {n} file|Saved for Web: {n} files Salvato per il Web: {n} file|Salvato per il Web: {n} file +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} livello registrato da questa composizione è stato eliminato. Aggiorna la composizione o cancella l'avviso.|{n} livelli registrati da questa composizione sono stati eliminati. Aggiorna la composizione o cancella l'avviso. +@plural {n} plane|{n} planes {n} piano|{n} piani +@plural {n} preset|{n} presets {n} predefinito|{n} predefiniti +@plural {n} sample point removed|{n} sample points removed {n} punto di campionamento rimosso|{n} punti di campionamento rimossi +@plural {n} slice|{n} slices {n} sezione|{n} sezioni +@plural {n} step|{n} steps {n} passaggio|{n} passaggi +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} altro avviso sui predefiniti pennello)|{warning} (+{n} altri avvisi sui predefiniti pennello) +@plural {what} with {n} warning|{what} with {n} warnings {what} con {n} avviso|{what} con {n} avvisi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ef902970aa..f43ce5b8f3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -776,20 +776,6 @@ Statistics… 統計情報… Refine Strokes 調整ストローク @plural {n} item|{n} items {n} 件 -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {file} にスウォッチ {n} 個を書き出しました -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} からブラシ {n} 個を読み込みました -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} からスウォッチ {n} 個を読み込みました -@plural Played {n} step|Played {n} steps {n} ステップを再生しました -@plural Saved for Web: {n} file|Saved for Web: {n} files Web 用に保存: {n} ファイル -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk ディスク上で変更されたリンクされたスマートオブジェクト {n} 個を更新しました -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. このレイヤーカンプに記録された {n} 個のレイヤーが削除されました。カンプを更新するか、警告を消去してください。 -@plural {n} plane|{n} planes {n} 平面 -@plural {n} preset|{n} presets {n} プリセット -@plural {n} sample point removed|{n} sample points removed サンプルポイント {n} 個を削除しました -@plural {n} slice|{n} slices {n} スライス -@plural {n} step|{n} steps {n} ステップ -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (ほかにブラシプリセットの警告 {n} 件) -@plural {what} with {n} warning|{what} with {n} warnings {what} (警告 {n} 件) Auto 自動 # Dialogs and panels @@ -3651,3 +3637,18 @@ curves Smooth スムーズ Apply mask to layer before removing? 削除する前にマスクをレイヤーに適用しますか? The layer or its mask changed. Drag the mask onto the Trash again. レイヤーまたはそのマスクが変更されました。もう一度マスクをゴミ箱にドラッグしてください。 Delete the vector mask? ベクトルマスクを削除しますか? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} スウォッチ {n} 個を {file} に書き出しました +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} からブラシ {n} 個を読み込みました +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} からスウォッチ {n} 個を読み込みました +@plural Played {n} step|Played {n} steps {n} ステップを再生しました +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk ディスク上で変更されたリンクされたスマートオブジェクト {n} 個を更新しました +@plural Saved for Web: {n} file|Saved for Web: {n} files Web 用に保存: {n} ファイル +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. このカンプに記録されたレイヤー {n} 個が削除されました。カンプを更新するか、警告を消去してください。 +@plural {n} plane|{n} planes {n} 個の平面 +@plural {n} preset|{n} presets {n} 個のプリセット +@plural {n} sample point removed|{n} sample points removed サンプルポイント {n} 個を削除しました +@plural {n} slice|{n} slices {n} 個のスライス +@plural {n} step|{n} steps {n} ステップ +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning}(ほかにブラシプリセットの警告 {n} 件) +@plural {what} with {n} warning|{what} with {n} warnings {what}(警告 {n} 件) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0d1875f36f..c273c8f26f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -759,20 +759,6 @@ Statistics… Statistieken… Refine Strokes Verfijningsstreken @plural {n} item|{n} items {n} element|{n} elementen -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file} -@plural Played {n} step|Played {n} steps {n} stap afgespeeld|{n} stappen afgespeeld -@plural Saved for Web: {n} file|Saved for Web: {n} files Opgeslagen voor web: {n} bestand|Opgeslagen voor web: {n} bestanden -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} gekoppeld slim object met wijzigingen op schijf bijgewerkt|{n} gekoppelde slimme objecten met wijzigingen op schijf bijgewerkt -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} laag die in deze laagcompositie is vastgelegd is verwijderd. Werk de compositie bij of verwijder de waarschuwing.|{n} lagen die in deze laagcompositie zijn vastgelegd zijn verwijderd. Werk de compositie bij of verwijder de waarschuwing. -@plural {n} plane|{n} planes {n} vlak|{n} vlakken -@plural {n} preset|{n} presets {n} voorinstelling|{n} voorinstellingen -@plural {n} sample point removed|{n} sample points removed {n} monsterpunt verwijderd|{n} monsterpunten verwijderd -@plural {n} slice|{n} slices {n} segment|{n} segmenten -@plural {n} step|{n} steps {n} stap|{n} stappen -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} extra waarschuwing voor penseelvoorinstellingen)|{warning} (+{n} extra waarschuwingen voor penseelvoorinstellingen) -@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen Auto Automatisch (print to the printer) (naar de printer afdrukken) + Add variable + Variabele toevoegen @@ -3801,3 +3787,18 @@ curves Smooth Vloeiend maken Apply mask to layer before removing? Masker toepassen op laag voordat het wordt verwijderd? The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak. Delete the vector mask? Het vectormasker verwijderen? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} staal geëxporteerd naar {file}|{n} stalen geëxporteerd naar {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} penseel geïmporteerd uit {group}|{n} penselen geïmporteerd uit {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} staal geïmporteerd uit {file}|{n} stalen geïmporteerd uit {file} +@plural Played {n} step|Played {n} steps {n} stap afgespeeld|{n} stappen afgespeeld +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} gekoppeld slim object bijgewerkt dat op schijf is gewijzigd|{n} gekoppelde slimme objecten bijgewerkt die op schijf zijn gewijzigd +@plural Saved for Web: {n} file|Saved for Web: {n} files Opgeslagen voor web: {n} bestand|Opgeslagen voor web: {n} bestanden +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} laag die deze compositie vastlegt, is verwijderd. Werk de compositie bij of wis de waarschuwing.|{n} lagen die deze compositie vastlegt, zijn verwijderd. Werk de compositie bij of wis de waarschuwing. +@plural {n} plane|{n} planes {n} vlak|{n} vlakken +@plural {n} preset|{n} presets {n} voorinstelling|{n} voorinstellingen +@plural {n} sample point removed|{n} sample points removed {n} steekproefpunt verwijderd|{n} steekproefpunten verwijderd +@plural {n} slice|{n} slices {n} segment|{n} segmenten +@plural {n} step|{n} steps {n} stap|{n} stappen +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} andere waarschuwing over penseelvoorinstellingen)|{warning} (+{n} andere waarschuwingen over penseelvoorinstellingen) +@plural {what} with {n} warning|{what} with {n} warnings {what} met {n} waarschuwing|{what} met {n} waarschuwingen diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8c9348284e..8c3fedb7d6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -761,20 +761,6 @@ Statistics… Estatísticas… Refine Strokes Traços de refinamento @plural {n} item|{n} items {n} item|{n} itens -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file} -@plural Played {n} step|Played {n} steps {n} etapa reproduzida|{n} etapas reproduzidas -@plural Saved for Web: {n} file|Saved for Web: {n} files Salvo para Web: {n} arquivo|Salvo para Web: {n} arquivos -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objeto inteligente vinculado alterado no disco foi atualizado|{n} objetos inteligentes vinculados alterados no disco foram atualizados -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} camada registrada nesta composição foi excluída. Atualize a composição ou remova o aviso.|{n} camadas registradas nesta composição foram excluídas. Atualize a composição ou remova o aviso. -@plural {n} plane|{n} planes {n} plano|{n} planos -@plural {n} preset|{n} presets {n} predefinição|{n} predefinições -@plural {n} sample point removed|{n} sample points removed {n} ponto de amostra removido|{n} pontos de amostra removidos -@plural {n} slice|{n} slices {n} fatia|{n} fatias -@plural {n} step|{n} steps {n} etapa|{n} etapas -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} aviso adicional de predefinição de pincel)|{warning} (+{n} avisos adicionais de predefinições de pincel) -@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos Auto Automático (print to the printer) (imprimir na impressora) + Add variable + Adicionar variável @@ -3831,3 +3817,18 @@ layerLabel No Color Sem cor Apply mask to layer before removing? Aplicar a máscara à camada antes de removê-la? The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente. Delete the vector mask? Excluir a máscara vetorial? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} amostra exportada para {file}|{n} amostras exportadas para {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} pincel importado de {group}|{n} pincéis importados de {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} amostra importada de {file}|{n} amostras importadas de {file} +@plural Played {n} step|Played {n} steps {n} etapa reproduzida|{n} etapas reproduzidas +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} objeto inteligente vinculado alterado no disco foi atualizado|{n} objetos inteligentes vinculados alterados no disco foram atualizados +@plural Saved for Web: {n} file|Saved for Web: {n} files Salvo para Web: {n} arquivo|Salvo para Web: {n} arquivos +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} camada registrada por esta composição foi excluída. Atualize a composição ou limpe o aviso.|{n} camadas registradas por esta composição foram excluídas. Atualize a composição ou limpe o aviso. +@plural {n} plane|{n} planes {n} plano|{n} planos +@plural {n} preset|{n} presets {n} predefinição|{n} predefinições +@plural {n} sample point removed|{n} sample points removed {n} ponto de amostragem removido|{n} pontos de amostragem removidos +@plural {n} slice|{n} slices {n} fatia|{n} fatias +@plural {n} step|{n} steps {n} etapa|{n} etapas +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} outro aviso de predefinição de pincel)|{warning} (+{n} outros avisos de predefinição de pincel) +@plural {what} with {n} warning|{what} with {n} warnings {what} com {n} aviso|{what} com {n} avisos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5334137310..1cc7640067 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1807,20 +1807,6 @@ # Plurals @plural {n} item|{n} items {n} элемент|{n} элемента|{n} элементов -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортировано {n} образца в {file}|Экспортировано {n} образцов в {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортировано {n} кисти из {group}|Импортировано {n} кистей из {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортировано {n} образца из {file}|Импортировано {n} образцов из {file} -@plural Played {n} step|Played {n} steps Выполнен {n} шаг|Выполнено {n} шага|Выполнено {n} шагов -@plural Saved for Web: {n} file|Saved for Web: {n} files Сохранено для Web: {n} файл|Сохранено для Web: {n} файла|Сохранено для Web: {n} файлов -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Обновлён {n} связанный смарт-объект, изменённый на диске|Обновлено {n} связанных смарт-объекта, изменённых на диске|Обновлено {n} связанных смарт-объектов, изменённых на диске -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} слой, записанный в этом варианте слоя, был удалён. Обновите вариант или уберите предупреждение.|{n} слоя, записанных в этом варианте слоя, были удалены. Обновите вариант или уберите предупреждение.|{n} слоёв, записанных в этом варианте слоя, были удалены. Обновите вариант или уберите предупреждение. -@plural {n} plane|{n} planes {n} плоскость|{n} плоскости|{n} плоскостей -@plural {n} preset|{n} presets {n} набор|{n} набора|{n} наборов -@plural {n} sample point removed|{n} sample points removed Удалена {n} точка образца|Удалено {n} точки образца|Удалено {n} точек образца -@plural {n} slice|{n} slices {n} фрагмент|{n} фрагмента|{n} фрагментов -@plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} дополнительное предупреждение о наборе кистей)|{warning} (+{n} дополнительных предупреждения о наборе кистей)|{warning} (+{n} дополнительных предупреждений о наборе кистей) -@plural {what} with {n} warning|{what} with {n} warnings {what} с {n} предупреждением|{what} с {n} предупреждениями|{what} с {n} предупреждениями @plural {n} Layer|{n} Layers {n} слой|{n} слоя|{n} слоёв @plural {n} layer|{n} layers {n} слой|{n} слоя|{n} слоёв @plural Group · {n} layer|Group · {n} layers Группа · {n} слой|Группа · {n} слоя|Группа · {n} слоёв @@ -2986,3 +2972,18 @@ curves Smooth Сгладить Apply mask to layer before removing? Применить маску к слою перед удалением? The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз. Delete the vector mask? Удалить векторную маску? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Экспортирован {n} образец в {file}|Экспортировано {n} образца в {file}|Экспортировано {n} образцов в {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Импортирована {n} кисть из {group}|Импортировано {n} кисти из {group}|Импортировано {n} кистей из {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Импортирован {n} образец из {file}|Импортировано {n} образца из {file}|Импортировано {n} образцов из {file} +@plural Played {n} step|Played {n} steps Воспроизведён {n} шаг|Воспроизведено {n} шага|Воспроизведено {n} шагов +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Обновлён {n} связанный смарт-объект, изменённый на диске|Обновлено {n} связанных смарт-объекта, изменённых на диске|Обновлено {n} связанных смарт-объектов, изменённых на диске +@plural Saved for Web: {n} file|Saved for Web: {n} files Сохранено для Web: {n} файл|Сохранено для Web: {n} файла|Сохранено для Web: {n} файлов +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Удалён {n} слой, записанный в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалено {n} слоя, записанных в этом варианте. Обновите вариант или сбросьте предупреждение.|Удалено {n} слоёв, записанных в этом варианте. Обновите вариант или сбросьте предупреждение. +@plural {n} plane|{n} planes {n} плоскость|{n} плоскости|{n} плоскостей +@plural {n} preset|{n} presets {n} набор|{n} набора|{n} наборов +@plural {n} sample point removed|{n} sample points removed Удалена {n} точка замера|Удалено {n} точки замера|Удалено {n} точек замера +@plural {n} slice|{n} slices {n} фрагмент|{n} фрагмента|{n} фрагментов +@plural {n} step|{n} steps {n} шаг|{n} шага|{n} шагов +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (и ещё {n} предупреждение о наборах кистей)|{warning} (и ещё {n} предупреждения о наборах кистей)|{warning} (и ещё {n} предупреждений о наборах кистей) +@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} предупреждение|{what}: {n} предупреждения|{what}: {n} предупреждений diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6479030ebc..b0110fb2cc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1786,20 +1786,6 @@ # Plurals @plural {n} item|{n} items {n} елемент|{n} елементи|{n} елементів -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель із {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів із {group} -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів із {file}|Імпортовано {n} зразків кольорів із {file} -@plural Played {n} step|Played {n} steps Відтворено {n} крок|Відтворено {n} кроки|Відтворено {n} кроків -@plural Saved for Web: {n} file|Saved for Web: {n} files Збережено для Web: {n} файл|Збережено для Web: {n} файли|Збережено для Web: {n} файлів -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Оновлено {n} зв’язаний смартоб’єкт, змінений на диску|Оновлено {n} зв’язані смартоб’єкти, змінені на диску|Оновлено {n} зв’язаних смартоб’єктів, змінених на диску -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Видалено {n} шар, записаний у цій композиції. Оновіть композицію або приберіть попередження.|Видалено {n} шари, записані в цій композиції. Оновіть композицію або приберіть попередження.|Видалено {n} шарів, записаних у цій композиції. Оновіть композицію або приберіть попередження. -@plural {n} plane|{n} planes {n} площина|{n} площини|{n} площин -@plural {n} preset|{n} presets {n} набір|{n} набори|{n} наборів -@plural {n} sample point removed|{n} sample points removed Видалено {n} точку зразка|Видалено {n} точки зразка|Видалено {n} точок зразка -@plural {n} slice|{n} slices {n} фрагмент|{n} фрагменти|{n} фрагментів -@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} додаткове попередження про набір пензлів)|{warning} (+{n} додаткові попередження про набори пензлів)|{warning} (+{n} додаткових попереджень про набори пензлів) -@plural {what} with {n} warning|{what} with {n} warnings {what} з {n} попередженням|{what} з {n} попередженнями|{what} з {n} попередженнями @plural {n} Layer|{n} Layers {n} Шар|{n} Шари|{n} Шарів @plural {n} layer|{n} layers {n} шар|{n} шари|{n} шарів @plural Group · {n} layer|Group · {n} layers Група · {n} шар|Група · {n} шари|Група · {n} шарів @@ -3739,3 +3725,18 @@ curves Smooth Згладити Apply mask to layer before removing? Застосувати маску до шару перед видаленням? The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз. Delete the vector mask? Видалити векторну маску? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Експортовано {n} зразок кольору до {file}|Експортовано {n} зразки кольорів до {file}|Експортовано {n} зразків кольорів до {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Імпортовано {n} пензель з {group}|Імпортовано {n} пензлі з {group}|Імпортовано {n} пензлів з {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Імпортовано {n} зразок кольору з {file}|Імпортовано {n} зразки кольорів з {file}|Імпортовано {n} зразків кольорів з {file} +@plural Played {n} step|Played {n} steps Відтворено {n} крок|Відтворено {n} кроки|Відтворено {n} кроків +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Оновлено {n} пов'язаний смартоб'єкт, змінений на диску|Оновлено {n} пов'язані смартоб'єкти, змінені на диску|Оновлено {n} пов'язаних смартоб'єктів, змінених на диску +@plural Saved for Web: {n} file|Saved for Web: {n} files Збережено для вебу: {n} файл|Збережено для вебу: {n} файли|Збережено для вебу: {n} файлів +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Видалено {n} шар, записаний у цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шари, записані в цій композиції. Оновіть композицію або скиньте попередження.|Видалено {n} шарів, записаних у цій композиції. Оновіть композицію або скиньте попередження. +@plural {n} plane|{n} planes {n} площина|{n} площини|{n} площин +@plural {n} preset|{n} presets {n} набір|{n} набори|{n} наборів +@plural {n} sample point removed|{n} sample points removed Вилучено {n} точку вимірювання|Вилучено {n} точки вимірювання|Вилучено {n} точок вимірювання +@plural {n} slice|{n} slices {n} фрагмент|{n} фрагменти|{n} фрагментів +@plural {n} step|{n} steps {n} крок|{n} кроки|{n} кроків +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попередження щодо наборів пензлів)|{warning} (і ще {n} попереджень щодо наборів пензлів) +@plural {what} with {n} warning|{what} with {n} warnings {what}: {n} попередження|{what}: {n} попередження|{what}: {n} попереджень diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d5ba8f0bc2..11aeb09f2f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -783,20 +783,6 @@ Statistics… 統計資料… Refine Strokes 調整筆畫 @plural {n} item|{n} items {n} 個項目 -@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file} -@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷 -@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票 -@plural Played {n} step|Played {n} steps 已播放 {n} 個步驟 -@plural Saved for Web: {n} file|Saved for Web: {n} files 儲存為網頁: {n} 個檔案 -@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 個在磁碟上變更的連結智慧型物件 -@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此圖層構圖中記錄的 {n} 個圖層已刪除。請更新構圖或清除警告。 -@plural {n} plane|{n} planes {n} 個平面 -@plural {n} preset|{n} presets {n} 個預設集 -@plural {n} sample point removed|{n} sample points removed 已移除 {n} 個取樣點 -@plural {n} slice|{n} slices {n} 個切片 -@plural {n} step|{n} steps {n} 個步驟 -@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告) -@plural {what} with {n} warning|{what} with {n} warnings {what},有 {n} 個警告 Auto 自動 (print to the printer) (列印至印表機) + Add variable + 新增變數 @@ -2631,3 +2617,18 @@ curves Smooth 平滑 Apply mask to layer before removing? 移除之前將遮色片套用到圖層嗎? The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。 Delete the vector mask? 要刪除向量遮色片嗎? +# Counted status messages and labels (#3454; adapted from PR #3821) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已將 {n} 個色票匯出至 {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} 已從 {group} 匯入 {n} 個筆刷 +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已從 {file} 匯入 {n} 個色票 +@plural Played {n} step|Played {n} steps 已播放 {n} 個步驟 +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk 已更新 {n} 個在磁碟上變更的連結智慧型物件 +@plural Saved for Web: {n} file|Saved for Web: {n} files 儲存為網頁用:{n} 個檔案 +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. 此構圖記錄的 {n} 個圖層已刪除。請更新構圖,或清除警告。 +@plural {n} plane|{n} planes {n} 個平面 +@plural {n} preset|{n} presets {n} 個預設集 +@plural {n} sample point removed|{n} sample points removed 已移除 {n} 個取樣點 +@plural {n} slice|{n} slices {n} 個切片 +@plural {n} step|{n} steps {n} 個步驟 +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (另有 {n} 個筆刷預設集警告) +@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} 個警告) From e3d118e0f3ec0a2c796d9174c9f11cf128cadc18 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 05:53:56 +0200 Subject: [PATCH 020/523] Separate print queue status from generic cancellable progress controls --- crates/ui-egui/src/jobs_ui.rs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index f15a464a88..6e03a11ca8 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -568,6 +568,24 @@ pub fn bar(ui: &egui::Ui, rect: Rect, frac: Option, t: &Tokens) { } } +/// A print job has already reached the spool worker; cancellation belongs to the system queue. +fn print_status_progress(app: &PhotocraftApp, ui: &mut egui::Ui, j: &JobInfo) { + let t = Tokens::get(ui.ctx()); + let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; + let more = app.session.jobs().len().saturating_sub(1); + let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + if xresp.hovered() { + ui.painter().rect_filled(xr, t.radius_sm, t.hover); + } + crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_dim }); + xresp.on_hover_text(tl!("Manage submitted jobs in the system print queue")); + ui.label(RichText::new(percent(j)).color(t.text_dim).size(11.5).monospace()); + let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover()); + bar(ui, br, shown_fraction(j), &t); + let label = if more > 0 { format!("{} (+{more})", tl!(&j.label)) } else { tl!(&j.label).to_owned() }; + ui.label(RichText::new(label).color(t.text_dim).size(12.0)); +} + /// Progress that has been reported, or `None` (indeterminate) while it's still 0. fn shown_fraction(j: &JobInfo) -> Option { (j.progress > 0.0).then_some(j.progress) @@ -584,14 +602,17 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (bar_w, bar_h) = if t.pro { (120.0, 6.0) } else { (140.0, 6.0) }; let more = app.session.jobs().len().saturating_sub(1); + if matches!(j.command.as_str(), "file.print" | "file.printOneCopy") { + print_status_progress(app, ui, &j); + return; + } // Right to left: ×, percentage, bar, label. - let print = matches!(j.command.as_str(), "file.print" | "file.printOneCopy"); - let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), if print { Sense::hover() } else { Sense::click() }); + let (xr, xresp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); if xresp.hovered() { ui.painter().rect_filled(xr, t.radius_sm, t.hover); } crate::icons::paint(ui, xr, "x", 11.0, if xresp.hovered() { t.text } else { t.text_dim }); - let xresp = xresp.on_hover_text(if print { tl!("Manage submitted jobs in the system print queue") } else { tl!("Cancel (Esc)") }); + let xresp = xresp.on_hover_text(tl!("Cancel (Esc)")); ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace()); let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover()); bar(ui, br, shown_fraction(&j), &t); From da3da0942c14c1159f40c652afcf33584332c733 Mon Sep 17 00:00:00 2001 From: Atos Lins <132598646+atoslins@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:28:09 -0300 Subject: [PATCH 021/523] Linux: StartupWMClass is the window's app id (#3481) * Linux: StartupWMClass is the window's app id The desktop entry said StartupWMClass=photocraft, but the window carries the app id ai.storyteller.photocraft (APP_ID) on Wayland and under X11 alike, so nothing that matches a window to its launcher by that key ever matched it. VectorCraft, GridCraft and WordCraft already use the app id. A test reads the entry and compares it with APP_ID. * Contributors: add atoslins (Atos Lins) --------- Co-authored-by: Brandon Thomas --- apps/photocraft/src/main.rs | 9 +++++++++ contributors/people.toml | 5 +++++ packaging/linux/ai.storyteller.photocraft.desktop | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index a267673aa5..aee9eabca1 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -644,4 +644,13 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, + /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. + #[test] + fn the_desktop_entry_names_the_window_class() { + let entry = include_str!("../../../packaging/linux/ai.storyteller.photocraft.desktop"); + let class = entry.lines().find_map(|line| line.strip_prefix("StartupWMClass=")); + assert_eq!(class, Some(super::APP_ID)); + } } diff --git a/contributors/people.toml b/contributors/people.toml index 914c9ce303..4765d92ac2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,6 +104,11 @@ sync_github_name = true [people.timothydaly1] display_name = "SyntaxError" +[people.atoslins] +real_name = "Atos Lins" +display_name = "Atos Lins" +sync_github_name = false + [people.axlecoffee] sync_github_name = true diff --git a/packaging/linux/ai.storyteller.photocraft.desktop b/packaging/linux/ai.storyteller.photocraft.desktop index d1fb15ce84..70ce82148e 100644 --- a/packaging/linux/ai.storyteller.photocraft.desktop +++ b/packaging/linux/ai.storyteller.photocraft.desktop @@ -9,7 +9,7 @@ Icon=ai.storyteller.photocraft Terminal=false # The native runner does not complete desktop startup-notification handshakes. StartupNotify=false -StartupWMClass=photocraft +StartupWMClass=ai.storyteller.photocraft Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;image;editor;psd;layers;retouch;paint; MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; From 857a4f7d8a1a71c54d8fd264b6de1d26a4bcff15 Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 16:00:30 +0800 Subject: [PATCH 022/523] M11: ten more MCP acceptance tasks (11-20) for FILE-216-6 (#3562) * test(automation): ten more MCP acceptance tasks (11-20) * docs(scorecard): FILE-216-6 note counts 20 tasks --- crates/automation/tests/agent_tasks.rs | 99 ++++++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 8eb6ba725a..b185e310a7 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -343,3 +343,102 @@ async fn agent_play_refuses_a_recorded_file_open() { shutdown(c, server).await; std::fs::remove_dir_all(dir).unwrap(); } + +/// The next ten acceptance tasks (11–20): strokes, inverse fills, duplication, merges, +/// flattening, adjustments, feathering, expand, renaming/deleting and an export/reopen round +/// trip — all through MCP tools and checked from outside the engine. +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_ten_more_scripted_tasks() { + let dir = tmp("tasks2"); + let (c, server) = connect(&dir).await; + + // 11. Stroke a selection: a red square with a black outside stroke. + tool(&c, "doc_new", json!({"width": 80, "height": 80, "background": "white"})).await; + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await; + run(&c, "edit.fill", json!({"color": "#ff0000"})).await; + run(&c, "edit.stroke", json!({"width": 4, "color": "#000000", "location": "outside"})).await; + let inside = pixel(&c, 40, 40).await; + let edge = pixel(&c, 18, 40).await; + let away = pixel(&c, 5, 5).await; + assert!(inside[0] > 0.99 && inside[1] < 0.01, "task 11: the centre is red {inside:?}"); + assert!(edge[0] < 0.1 && edge[1] < 0.1, "task 11: the stroke is black {edge:?}"); + assert!(away[0] > 0.99 && away[1] > 0.99, "task 11: outside stays white {away:?}"); + run(&c, "select.deselect", json!({})).await; + + // 12. Inverse the selection: a blue frame around the red square. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await; + run(&c, "select.inverse", json!({})).await; + run(&c, "edit.fill", json!({"color": "#0000ff"})).await; + let centre = pixel(&c, 40, 40).await; + let frame = pixel(&c, 4, 4).await; + assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 12: the centre keeps red {centre:?}"); + assert!(frame[2] > 0.99 && frame[0] < 0.01, "task 12: the frame is blue {frame:?}"); + run(&c, "select.deselect", json!({})).await; + + // 13. Paint a green patch on a new layer and duplicate it (offset, as Photoshop's default). + run(&c, "layer.new.layer", json!({"name": "Patch"})).await; + run(&c, "select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).await; + run(&c, "edit.fill", json!({"color": "#00ff00"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "layer.duplicate", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(doc["layers"].as_array().unwrap().len(), 3, "task 13: {doc}"); + let patches = doc["layers"].as_array().unwrap().iter().filter(|l| l["name"].as_str().is_some_and(|n| n.starts_with("Patch"))).count(); + assert_eq!(patches, 2, "task 13: {doc}"); + + // 14. Merge the copy down; the two patches become one layer. + run(&c, "layer.mergeDown", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 2, "task 14"); + + // 15. Flatten the image; the patch survives. + run(&c, "layer.flattenImage", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 15: {doc}"); + let green = pixel(&c, 20, 20).await; + assert!(green[1] > 0.99 && green[0] < 0.01, "task 15: the patch survives {green:?}"); + + // 16. A Hue/Saturation adjustment layer turns the red square green without touching pixels. + run(&c, "layer.newAdjustmentLayer.hueSaturation", json!({"hue": 120})).await; + assert_eq!(top_layer(&inspect(&c).await)["kind"], "Adjustment", "task 16"); + let shifted = pixel(&c, 40, 40).await; + assert!(shifted[1] > 0.5 && shifted[0] < 0.5, "task 16: red turns green {shifted:?}"); + run(&c, "layer.delete", json!({})).await; + + // 17. Feather the selection and reveal a mask through it: the edge blends. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 40, "height": 40})).await; + run(&c, "select.modify.feather", json!({"radius": 4})).await; + run(&c, "layer.layerMask.revealSelection", json!({})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["hasMask"], true, "task 17"); + let boundary = pixel(&c, 20, 40).await; + assert!(boundary[3] > 0.05 && boundary[3] < 0.95, "task 17: the feathered edge keeps partial alpha {boundary:?}"); + run(&c, "select.deselect", json!({})).await; + + // 18. Expand a selection and read its bounds back. + run(&c, "select.rect", json!({"x": 20, "y": 20, "width": 20, "height": 20})).await; + run(&c, "select.modify.expand", json!({"radius": 5})).await; + let bounds = inspect(&c).await["selectionBounds"].clone(); + let b = bounds.as_array().unwrap_or_else(|| panic!("task 18: {bounds}")); + let (x, w) = (b[0].as_i64().unwrap(), b[2].as_i64().unwrap()); + assert!((13..=15).contains(&x) && (30..=32).contains(&w), "task 18: expanded outward {bounds}"); + run(&c, "select.deselect", json!({})).await; + + // 19. Rename a layer and delete it again. + run(&c, "layer.new.layer", json!({"name": "Scratch"})).await; + run(&c, "layer.renameLayer", json!({"name": "Delivery"})).await; + assert_eq!(top_layer(&inspect(&c).await)["name"], "Delivery", "task 19"); + run(&c, "layer.delete", json!({})).await; + assert_eq!(inspect(&c).await["layers"].as_array().unwrap().len(), 1, "task 19: deleted"); + + // 20. Export the final image and reopen the PNG as a flat document. + tool(&c, "doc_export", json!({"path": "final.png"})).await; + tool(&c, "doc_close", json!({})).await; + tool(&c, "doc_open", json!({"path": "final.png"})).await; + let doc = inspect(&c).await; + assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(80), Some(80)), "task 20"); + assert_eq!(doc["layers"].as_array().unwrap().len(), 1, "task 20: a flat reopen"); + tool(&c, "doc_close", json!({})).await; + + shutdown(c, server).await; + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/docs/scorecard.md b/docs/scorecard.md index 0e8a58c75c..5b6275f0ea 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -124,7 +124,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-216-3 | TIFF, RAW and EXR corpora read by tests; per-camera RAW coverage reported | partial | [#216](https://github.com/storytold/photocraft/issues/216) | EXR: corpus/exr (the OpenEXR deep test images, pinned) is read by codecs tests/deep_exr.rs in the CI corpus job, against the ID manifests of the upstream sidecars. corpus/tiff and corpus/raw are still copied in by hand and no test reads them; no per-camera RAW report | | FILE-216-4 | Exports re-open in a third-party oracle editor | missing | [#216](https://github.com/storytold/photocraft/issues/216) | The round trip is our export -> our import | | FILE-216-5 | Save-path tests: layered TIFF, PDF, SVG, ACO/ASE, .shc, Smart Filters, atomic-save failure | partial | [#216](https://github.com/storytold/photocraft/issues/216) | Every save path that exists is tested by saving and reading back: layered TIFF io tests/tiff_layers.rs (every_compression_keeps_the_layers, advanced_blending_survives_layered_tiff_in_both_byte_orders), print PDF engine print_cmds/tests.rs (print_dry_run_renders_a_pdf_and_reports_lp decodes the image back), ACO/ASE engine swatch_cmds/tests.rs (aco_round_trip_keeps_16_bit_values_and_books, ase_round_trip_keeps_groups_floats_and_kinds), .shc psd shc.rs shc_round_trips_and_rejects_hostile_input and engine layer_style.rs contour_presets_and_custom_curves_apply_in_one_undo_step, Smart Filters engine tests/smart_psd.rs (smart_filters_survive_psd_in_every_colour_model), atomic-save failure format/src/atomic/tests.rs (BUG-203-6). There is no SVG writer (SVG opens only) and no Photoshop PDF save (FILE-215-10), so those have nothing to test yet; the row stays partial until they exist | -| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 10 MCP tasks | +| FILE-216-6 | 30-50 end-to-end workflow acceptance tasks driven through MCP or commands | partial | [#216](https://github.com/storytold/photocraft/issues/216) | automation/tests/agent_tasks.rs: 20 MCP tasks | ## Tools diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 51f40c3a1b..e6943b578c 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -192,4 +192,4 @@ id = "FILE-216-6" target = "30-50 end-to-end workflow acceptance tasks driven through MCP or commands" status = "partial" issue = 216 -note = "automation/tests/agent_tasks.rs: 10 MCP tasks" +note = "automation/tests/agent_tasks.rs: 20 MCP tasks" From c5a9e7f9b2de0a5af8591b714a7ef9c3f4c69152 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:03:07 +0700 Subject: [PATCH 023/523] fix(ui): don't reopen floating Brush Settings on restart (#3570) --- crates/ui-egui/src/dock.rs | 4 ++++ crates/ui-egui/src/dock_tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 7d70a72e77..089fb6cd2b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -765,6 +765,10 @@ pub fn restore(app: &mut PhotocraftApp) { } let saved = app.session.prefs().panel_layout.clone(); apply(app, &saved); + // The floating Brush Settings window is transient, not a dock panel: remembering the + // workspace must not leave it open on a fresh launch, especially with no document. + // Explicit workspace switches still use `apply` and keep their stored panel visibility. + app.ui.panels.brush_settings = false; if let Some(ws) = saved.get("workspace").and_then(Value::as_str) { app.ui.workspace = ws.to_string(); } diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index dddcce6559..b5ade1d580 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -1082,3 +1082,31 @@ fn a_tab_dragged_off_the_strip_moves_the_group_and_locking_stops_reordering() { drag(&mut h, paths.center(), paths.center() + vec2(0.0, 500.0)); assert_eq!(h.state().ui.dock.order(), order); } + +#[test] +fn brush_settings_window_is_closed_on_restart_but_can_be_reopened() { + let mut first = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + first.ui.panels.brush_settings = true; + first.ui.panels.history = true; + let saved = snapshot(&first); + first.session.prefs.edit(|p| { + p.workspace.remember_workspace_changes = true; + p.panel_layout = saved.clone(); + }); + let persisted = first.session.prefs_to_json(); + + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(&persisted).unwrap(); + let mut next = PhotocraftApp::new(session, crate::Services::default()); + restore(&mut next); + assert!(!next.ui.panels.brush_settings, "a floating Brush Settings window should not reopen on startup"); + assert!(next.ui.panels.history, "remembered dock panel visibility is preserved"); + + let ctx = egui::Context::default(); + crate::menus::invoke(&mut next, &ctx, "window.toggle.brushSettings", json!({})).unwrap(); + assert!(next.ui.panels.brush_settings, "Window > Brush Settings can still open it"); + + let mut workspace = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + apply(&mut workspace, &saved); + assert!(workspace.ui.panels.brush_settings, "switching workspaces still restores their saved layout"); +} From d2b5b144aaab90041b4f0fd482b6cff9a560a531 Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 16:03:30 +0800 Subject: [PATCH 024/523] test(automation): six rounding-out acceptance tasks (31-36) (#3568) --- crates/automation/tests/agent_tasks_more.rs | 184 ++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 crates/automation/tests/agent_tasks_more.rs diff --git a/crates/automation/tests/agent_tasks_more.rs b/crates/automation/tests/agent_tasks_more.rs new file mode 100644 index 0000000000..3bde075e05 --- /dev/null +++ b/crates/automation/tests/agent_tasks_more.rs @@ -0,0 +1,184 @@ +//! M11 acceptance, part three: six rounding-out tasks (31–36) over MCP (roadmap M11 DoD). +//! +//! Same contract as the other acceptance files: only MCP tools (`doc_*`, `command_run`), checked +//! through `doc_inspect`, `document.pixel` and exported/saved files. + +use photocraft_automation::{AuthorizedWorkspace, PhotocraftMcp}; +use rmcp::model::{CallToolRequestParams, CallToolResult, ClientConfig}; +use rmcp::service::RunningService; +use rmcp::{ClientHandler, RoleClient, ServiceExt}; +use serde_json::{Value, json}; + +#[derive(Clone, Default)] +struct Client; +impl ClientHandler for Client { + fn get_info(&self) -> ClientConfig { + ClientConfig::default() + } +} + +type Conn = RunningService; +type Server = tokio::task::JoinHandle<()>; + +async fn connect(root: &std::path::Path) -> (Conn, Server) { + let (s, c) = tokio::io::duplex(1 << 20); + let workspace = AuthorizedWorkspace::new(Some(root), Some(root)).expect("test workspace"); + let server = tokio::spawn(async move { + if let Ok(running) = PhotocraftMcp::headless_with_workspace(workspace).serve(s).await { + let _ = running.waiting().await; + } + }); + (Client.serve(c).await.expect("client init"), server) +} + +async fn shutdown(c: Conn, server: Server) { + c.cancel().await.unwrap(); + server.await.unwrap(); +} + +fn text(r: &CallToolResult) -> String { + r.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.clone()).collect::>().join("\n") +} + +async fn tool(c: &Conn, name: &str, args: Value) -> Value { + let mut p = CallToolRequestParams::new(name.to_owned()); + if let Value::Object(m) = args { + p = p.with_arguments(m); + } + let r = c.call_tool(p).await.expect("call_tool transport"); + assert_ne!(r.is_error, Some(true), "{name} failed: {}", text(&r)); + serde_json::from_str(&text(&r)).unwrap_or(Value::String(text(&r))) +} + +async fn run(c: &Conn, id: &str, params: Value) -> Value { + tool(c, "command_run", json!({"id": id, "params": params})).await +} + +async fn inspect(c: &Conn) -> Value { + tool(c, "doc_inspect", json!({})).await +} + +async fn pixel(c: &Conn, x: i32, y: i32) -> Vec { + let v = run(c, "document.pixel", json!({"x": x, "y": y})).await; + let arr = if v.is_array() { v } else { v.get("result").cloned().unwrap_or(v) }; + arr.as_array().unwrap_or_else(|| panic!("pixel: {arr}")).iter().map(|x| x.as_f64().unwrap()).collect() +} + +fn top_layer(doc: &Value) -> &Value { + &doc["layers"][0] +} + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pc-agent-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// Six more acceptance tasks (31–36): an undo/redo sweep, a painted barcode, a grayscale +/// conversion with an export check, a native save round trip that preserves type, a layer-style +/// stroke and inverse fills. +#[tokio::test(flavor = "multi_thread")] +async fn agent_completes_six_rounding_out_tasks() { + let dir = tmp("tasks4"); + let (c, server) = connect(&dir).await; + + // 31. Undo/redo sweep: four fills go away and come back. + tool(&c, "doc_new", json!({"width": 40, "height": 40, "background": "white"})).await; + let h0 = inspect(&c).await["history"].as_array().unwrap().len(); + let colors = ["#ff0000", "#00ff00", "#0000ff", "#000000"]; + for (i, color) in colors.iter().enumerate() { + let x = 10 * i as i32; + run(&c, "select.rect", json!({"x": x, "y": 0, "width": 10, "height": 10})).await; + run(&c, "edit.fill", json!({"color": color})).await; + } + run(&c, "select.deselect", json!({})).await; + let steps = inspect(&c).await["history"].as_array().unwrap().len() - h0; + assert!(steps >= 4, "at least the four fills are history steps: {steps}"); + for _ in 0..steps { + run(&c, "edit.undo", json!({})).await; + } + for i in 0..4 { + let x = 10 * i + 5; + let p = pixel(&c, x, 5).await; + assert!(p[0] > 0.99 && p[1] > 0.99 && p[2] > 0.99, "task 31: fill {i} undone {p:?}"); + } + for _ in 0..steps { + run(&c, "edit.redo", json!({})).await; + } + let red = pixel(&c, 5, 5).await; + let black = pixel(&c, 35, 5).await; + assert!(red[0] > 0.99 && red[1] < 0.01, "task 31: the first fill redid {red:?}"); + assert!(black[0] < 0.01 && black[1] < 0.01, "task 31: the last fill redid {black:?}"); + + // 32. Paint a barcode: five black bars on white. + tool(&c, "doc_new", json!({"width": 48, "height": 24, "background": "white"})).await; + for i in 0..5 { + let x = 4 + 8 * i; + run(&c, "select.rect", json!({"x": x, "y": 4, "width": 3, "height": 16})).await; + run(&c, "edit.fill", json!({"color": "#000000"})).await; + } + run(&c, "select.deselect", json!({})).await; + for i in 0..5 { + let bar = pixel(&c, 5 + 8 * i, 12).await; + let gap = pixel(&c, 9 + 8 * i, 12).await; + assert!(bar[0] < 0.01, "task 32: bar {i} is black {bar:?}"); + assert!(gap[0] > 0.99, "task 32: gap {i} is white {gap:?}"); + } + + // 33. Convert to grayscale: the channels flatten and the export keeps them equal. + run(&c, "image.mode.grayscale", json!({})).await; + assert_eq!(inspect(&c).await["mode"], "Grayscale", "task 33"); + let g = pixel(&c, 5, 12).await; + assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4, "task 33: the channels agree {g:?}"); + tool(&c, "doc_export", json!({"path": "bars.png"})).await; + let img = photocraft_codecs::decode(&std::fs::read(dir.join("bars.png")).unwrap()).unwrap(); + let px = img.to_rgba8(); + let at = |x: usize, y: usize| px[(y * img.width() as usize + x) * 4]; + assert!(at(5, 12) < 8 && at(9, 12) > 247, "task 33: the grayscale export matches the canvas"); + tool(&c, "doc_close", json!({})).await; + + // 34. A native save round trip preserves a styled type layer. + tool(&c, "doc_new", json!({"width": 160, "height": 80, "background": "white"})).await; + run(&c, "type.create", json!({"x": 16, "y": 48, "text": "Round", "size": 24, "color": "#3366cc"})).await; + tool(&c, "doc_save", json!({"path": "type.pcraft"})).await; + tool(&c, "doc_close", json!({})).await; + tool(&c, "doc_open", json!({"path": "type.pcraft"})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["kind"], "Type", "task 34: {doc}"); + assert_eq!(top_layer(&doc)["text"]["text"], "Round", "task 34: {doc}"); + tool(&c, "doc_close", json!({})).await; + + // 35. A layer-style stroke frames a swatch. + tool(&c, "doc_new", json!({"width": 48, "height": 48, "background": "white"})).await; + run(&c, "layer.new.layer", json!({"name": "Swatch"})).await; + run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await; + run(&c, "edit.fill", json!({"color": "#ff0000"})).await; + run(&c, "select.deselect", json!({})).await; + run(&c, "layer.layerStyle.stroke", json!({"size": 3, "position": "outside", "color": "#0000ff"})).await; + let doc = inspect(&c).await; + assert_eq!(top_layer(&doc)["effects"]["items"][0]["kind"], "Stroke", "task 35: {doc}"); + let border = pixel(&c, 11, 24).await; + let centre = pixel(&c, 24, 24).await; + assert!(border[2] > 0.5 && border[0] < 0.5, "task 35: the stroke is blue {border:?}"); + assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 35: the centre stays red {centre:?}"); + + // 36. Inverse fill: gray around the swatch, then Select All paints everything white. + run(&c, "select.rect", json!({"x": 12, "y": 12, "width": 24, "height": 24})).await; + run(&c, "select.inverse", json!({})).await; + run(&c, "edit.fill", json!({"color": "#808080"})).await; + run(&c, "select.deselect", json!({})).await; + let corner = pixel(&c, 2, 2).await; + let centre = pixel(&c, 24, 24).await; + assert!(corner[0] > 0.4 && corner[0] < 0.6 && (corner[0] - corner[1]).abs() < 0.01, "task 36: the inverse fill is gray {corner:?}"); + assert!(centre[0] > 0.99 && centre[2] < 0.01, "task 36: the swatch stays red {centre:?}"); + run(&c, "select.all", json!({})).await; + run(&c, "edit.fill", json!({"color": "#ffffff"})).await; + run(&c, "select.deselect", json!({})).await; + let white = pixel(&c, 2, 2).await; + assert!(white[0] > 0.99 && white[1] > 0.99 && white[2] > 0.99, "task 36: everything is white {white:?}"); + tool(&c, "doc_close", json!({})).await; + + shutdown(c, server).await; + std::fs::remove_dir_all(dir).unwrap(); +} From c7c60312544b1dd926fdcfccabf4a4aab03c42f4 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 10:10:35 +0200 Subject: [PATCH 025/523] Options bar: the Gradient tool's controls show in every theme (#3601) (#3607) Studio, Studio Light and Classic showed only the hint 'Drag to draw a gradient'. The Gradient arm was gated by t.pro; it now runs in every theme. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/move_auto_select_tests.rs | 28 ++++++++++++++++++++ crates/ui-egui/src/panels.rs | 3 +-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 10dc5532f9..03ad1c21e4 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -182,3 +182,31 @@ fn every_theme_shows_the_type_options() { assert!(combos.iter().any(|c| c.contains(&font)), "{theme:?}: the font picker shows {font:?}; combo boxes: {combos:?}"); } } + +#[test] +fn every_theme_shows_the_gradient_options() { + // #3601: the Studio themes' options bar was only the hint "Drag to draw a gradient", with no + // mode, style, blend mode or opacity control. + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + for theme in crate::theme::ThemeKind::ALL { + let (mut app, _, _) = app(); + app.ui.tool = Tool::Gradient; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 60.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let combos: Vec = h + .query_all_by_role(egui::accesskit::Role::ComboBox) + .map(|n| format!("{:?} {:?}", n.accesskit_node().label(), n.accesskit_node().value())) + .collect(); + assert!(combos.iter().any(|c| c.contains("Normal")), "{theme:?}: the blend mode picker shows; combo boxes: {combos:?}"); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 22c5aac26e..ad543786eb 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1157,7 +1157,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::checkbox(ui, &mut app.ui.tool_options.contiguous, tl!("Contiguous")); widgets::checkbox(ui, &mut app.ui.tool_options.sample_all_layers, tl!("All Layers")); } - Tool::Gradient if t.pro => { + Tool::Gradient => { // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels). let mut classic = app.ui.tool_options.gradient_classic; if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) { @@ -1412,7 +1412,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { &[("commit", &crate::shortcuts::pretty("Enter"))], ), ), - Tool::Gradient => hint(ui, tl!("Drag to draw a gradient")), Tool::PaintBucket => hint(ui, tl!("Click to fill similar colours")), // Retouching and smart-selection tools draw their bar in `retouch_ui::options_bar`. _ => {} From 6ca04e5afab52e37918decce648f9f49b5527f4f Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:10:40 -0500 Subject: [PATCH 026/523] fix(ui): one eliding label for fixed-width chrome, so translations can't overrun (#3580) (#3616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A translated label beside a fixed-width control could run over its neighbours: the Layers Lock/Fill row guarded only the redundant "Lock:" caption, so "Fill:" ("Заливка:" in Russian, longer still as pt-br "Preenchimento:" / fr "Remplissage :") overlapped the lock icons. Rather than a per-row fit check, add the one shared primitive for fixed chrome: `widgets::elided_label(ui, text, max_w)` lays the text out no wider than its room, cuts it with '…' and puts the full string on hover (built on the existing `tab_strip::elided`). The Opacity/Fill scrub labels now elide to the room beside their field, so any translation shrinks instead of overlapping, in every locale. Documented the rule in docs/ui-design.md: wrap by default; beside a fixed control, elide; a redundant caption may drop on a narrow dock (the lock icons carry the meaning). Test: widgets::tests::elided_label_never_exceeds_its_room. Full photocraft-ui-egui suite: 2077 pass; fmt and clippy clean. --- crates/ui-egui/src/panels.rs | 16 ++++++++++------ crates/ui-egui/src/widgets.rs | 35 +++++++++++++++++++++++++++++++++++ docs/ui-design.md | 3 ++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ad543786eb..4bda4fcdef 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1449,10 +1449,10 @@ fn label(ui: &mut egui::Ui, s: &str) { /// Photoshop's scrubby Opacity/Fill labels: drag horizontally to change the percentage without /// hitting the small number field. The unique drag ID coalesces every frame into one undo step. -fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32) -> widgets::PopupFieldResponse { - let t = Tokens::get(ui.ctx()); - let resp = - ui.add(egui::Label::new(RichText::new(text).color(t.text_dim)).sense(Sense::click_and_drag())).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); +fn scrub_pct_label(ui: &mut egui::Ui, text: &str, percent: &mut f32, max_w: f32) -> widgets::PopupFieldResponse { + // Elide to the room left in the row (full text on hover): a longer translation shrinks here + // instead of running over the fixed controls beside it (#3580). + let resp = widgets::elided_label_sensed(ui, text, max_w, Sense::click_and_drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); let key = resp.id.with("percent-label-drag"); if resp.drag_started() { let stamp = ui.ctx().cumulative_pass_nr(); @@ -2114,7 +2114,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut o = l.opacity * 100.0; let field = widgets::popup_value_field(ui, opacity_label, &mut o, 0.0..=100.0, "%", LAYER_PCT_W); // On a narrow dock the (scrubby) label yields its room to the controls (#2309). - let scrub = if show_opacity_label { scrub_pct_label(ui, opacity_label, &mut o) } else { widgets::PopupFieldResponse::default() }; + let scrub = if show_opacity_label { + scrub_pct_label(ui, opacity_label, &mut o, ui.available_width()) + } else { + widgets::PopupFieldResponse::default() + }; if field.changed || scrub.changed { actions.push(pct_action(l, "opacity", o, scrub.drag.or(field.drag))); } @@ -2165,7 +2169,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut f = l.fill_opacity * 100.0; let field = widgets::popup_value_field(ui, fill_label, &mut f, 0.0..=100.0, "%", LAYER_PCT_W); - let scrub = scrub_pct_label(ui, fill_label, &mut f); + let scrub = scrub_pct_label(ui, fill_label, &mut f, ui.available_width()); if field.changed || scrub.changed { actions.push(pct_action(l, "fill", f, scrub.drag.or(field.drag))); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 378678459b..876ed82e7f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -246,6 +246,27 @@ pub fn value_field(ui: &mut Ui, value: &mut f32, range: std::ops::RangeInclusive value_field_in(ui, value, range, suffix, width, 0.0, FieldOpts::default()).0 } +/// A single-line label for a row that also holds fixed-width controls: laid out no wider than +/// `max_w`, cut with '…' when the text (often a longer translation) does not fit, with the full +/// string on hover. Fixed chrome must never let a label overrun its row (#3580), so an *essential* +/// label beside a fixed control should use this rather than a show/hide fit check; a *redundant* +/// label (one whose control already carries the meaning, e.g. an icon row's caption) may be dropped. +pub fn elided_label_sensed(ui: &mut Ui, text: &str, max_w: f32, sense: Sense) -> Response { + let t = Tokens::get(ui.ctx()); + let font = egui::TextStyle::Body.resolve(ui.style()); + let galley = crate::tab_strip::elided(ui, text, font, t.text_dim, max_w); + let elided = galley.elided; + let (rect, resp) = ui.allocate_exact_size(galley.size(), sense); + ui.painter().galley(rect.min, galley, t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), text)); + if elided { resp.on_hover_text(text) } else { resp } +} + +/// [`elided_label_sensed`] with hover only (a plain, non-interactive label). +pub fn elided_label(ui: &mut Ui, text: &str, max_w: f32) -> Response { + elided_label_sensed(ui, text, max_w, Sense::hover()) +} + /// Height of a [`value_field`]'s box. pub const VALUE_FIELD_H: f32 = 24.0; @@ -1594,6 +1615,20 @@ mod tests { ); } + /// #3580: the shared eliding label never lays out wider than the room it is given, so a longer + /// translation shrinks with '…' instead of overrunning its row; a short label is untouched. + #[test] + fn elided_label_never_exceeds_its_room() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui(|ui| { + let long = super::elided_label(ui, "Preenchimento:", 24.0); // pt-br Fill:, the longest catalog + assert!(long.rect.width() <= 25.0, "elided to the room, got {}", long.rect.width()); + let short = super::elided_label(ui, "Fill", 200.0); + assert!(short.rect.width() < 60.0, "a short label is not padded: {}", short.rect.width()); + }); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(2); + } + #[test] fn spectrum_popup_style_matches_main_menu_and_preserves_studio() { for kind in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::ProMedium, crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::StudioLight] { diff --git a/docs/ui-design.md b/docs/ui-design.md index f3193b2687..91fac93374 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -26,7 +26,8 @@ The toolbar's foreground and background colour chips follow the `Tokens::round_c ## Rules - **Colours and radii come from tokens.** Read them with `Tokens::get(ctx)`; never hard-code a colour in a widget. -- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`. +- **Shared widgets live in `widgets.rs`:** `card` (panel group; Pro renders a Photoshop tab strip), `value_field`, `slider`/`slider_row`, `toggle`/`checkbox`, `primary_button`/`secondary_button`, `dropdown` (with a chevron icon), `hairline`/`vline`, `elided_label`. +- **A label never overflows its row.** Wrap by default. Beside a fixed-width control, or anywhere the row cannot reflow, use `widgets::elided_label(ui, text, max_w)`: a longer translation shrinks with `…` and shows the full string on hover instead of running over its neighbours (#3580). A *redundant* caption may be dropped on a narrow dock where its control already carries the meaning (e.g. the Layers lock icons); an *essential* one elides. This mirrors Photoshop: a fixed layout with elision and tooltips, not per-locale reflow. - **Icons:** Lucide SVGs in `assets/icons` (ISC licence), embedded via `icon_data.rs`. Regenerate that file when you add icons. - **Fonts:** Inter (UI) and JetBrains Mono (numbers), both OFL. Named families `medium` and `semibold` are available via `theme::medium()` and `theme::semibold()`. - **Photoshop layout grammar (Pro):** From 55b33013846485ae7cc603c1dd6606309af6f347 Mon Sep 17 00:00:00 2001 From: agent-346022 Date: Mon, 12 Oct 2026 11:10:45 +0300 Subject: [PATCH 027/523] test(ui): marquee shift+alt before the drag intersects the selection (#208) (#3624) The marquee already picks the selection mode from the modifier held at the press (shift adds, alt subtracts, shift+alt intersects) rather than constraining the shape. marquee_tests.rs covered add and subtract but not intersect; this adds that case, the one gap BUG-208-4 still names. The test fixes an existing selection, holds shift+alt at the press (then releases so the drag stays a plain rectangle, isolating the mode from the square/centre shape constraints) and drags a second rectangle. It samples three points -- one in the overlap, one only in the first rectangle, one only in the dragged rectangle -- so it passes for intersect and fails for replace, add or subtract. Test only; no production change. Co-authored-by: photocraft-agent UNCONFIGURED --- crates/ui-egui/src/marquee_tests.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index a660a92ddb..241f5aa048 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -383,6 +383,32 @@ fn modifiers_held_before_the_drag_pick_the_mode_not_the_shape() { assert!(sel.sample_channel(120, 110, 0) < 0.01 && sel.sample_channel(90, 110, 0) > 0.99, "subtracted, not centred"); } +/// #208: `⇧⌥` held at the press intersects, the third selection mode after add (`⇧`) and subtract +/// (`⌥`). Like those, the modifier at the press picks the mode; here the keys are released once the +/// press has fixed the mode, so the drag stays a plain rectangle and only the overlap of the +/// existing selection and the dragged rectangle survives. The three sample points below are each in +/// exactly one of {overlap, first-only, drag-only}, so they pass only for intersect, not for +/// replace, add or subtract. +#[test] +fn shift_alt_before_the_drag_intersects_the_selection() { + let mut h = harness(Tool::RectMarquee); + // A: the existing selection, 50..200 on both axes. + h.state_mut().run("select.rect", json!({"x": 50, "y": 50, "width": 150, "height": 150})).unwrap(); + let both = Modifiers::SHIFT | Modifiers::ALT; + mods(&mut h, both); + press_at(&mut h, 120.0, 120.0, both); + // Mode is fixed at the press; releasing the keys keeps intersect mode but leaves the drag a + // plain rectangle (no square/centre constraint), so B is roughly 120..300 on both axes. + mods(&mut h, Modifiers::NONE); + move_to(&mut h, 300.0, 300.0); + release_at(&mut h, 300.0, 300.0, Modifiers::NONE); + let st = h.state().session.active().unwrap(); + let sel = st.doc.selection.as_ref().expect("a selection survives the intersect"); + assert!(sel.sample_channel(160, 160, 0) > 0.99, "the overlap of the two rectangles stays selected"); + assert!(sel.sample_channel(60, 60, 0) < 0.01, "a point only in the existing selection is dropped (not add)"); + assert!(sel.sample_channel(260, 260, 0) < 0.01, "a point only in the dragged rectangle is dropped (not replace)"); +} + /// `cargo test --release -p photocraft-ui-egui marquee_drag_bench -- --ignored --nocapture` #[test] #[ignore] From b623db1ed7d8a82a71ef06a8b96d4fa4933d60b8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 10:10:50 +0200 Subject: [PATCH 028/523] Crop tool: a resized frame snaps its handle, not the pointer (#3638) (#3640) With Auto Center Preview off, or in Classic Mode, dragging a crop edge or corner snapped the pointer, which grabs the handle up to the 8 px grab radius away from it. The snap line showed on the target while the frame edge stopped as far from it as the grab offset, so recropping toward the pixels a Delete Cropped Pixels-off crop kept left a transparent strip along the top and sides (or cut a few pixels off). The crop handle snap (snap_ui::crop_handle) now covers every resize: it snaps the handle of the frame crop_ui would make and moves the pointer by the difference, 1:1 without Auto Center Preview and holding the ratio on a corner as before. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/crop_mode.rs | 85 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/snap_ui.rs | 44 ++++++++++------- 2 files changed, 113 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/crop_mode.rs b/crates/ui-egui/src/crop_mode.rs index e19f01334a..676c97dbec 100644 --- a/crates/ui-egui/src/crop_mode.rs +++ b/crates/ui-egui/src/crop_mode.rs @@ -820,4 +820,89 @@ mod tests { } } } + + /// #1919: with Delete Cropped Pixels off, a second crop's handle dragged out toward the hidden + /// pixels snaps exactly onto their edge (a layer edge), however far from the handle's centre + /// the press grabbed it, in both modes, with and without Auto Center Preview: the committed + /// crop has no transparent strip. + #[test] + fn a_recrop_handle_snaps_exactly_to_the_hidden_pixels_edge() { + // The image 200 × 100 cropped to [40, 20, 140, 70]: its pixels stay at [-40, -20, 160, 80]. + let hidden = [-40.0, -20.0, 160.0, 80.0]; + for classic in [false, true] { + for auto in [true, false] { + for (hx, hy) in [(1i8, 0i8), (0, -1), (-1, -1), (1, 1)] { + // A corner also with the image's 2:1 ratio held, which both hidden corners keep. + for (grab, ratio) in [(-4.0f32, false), (0.0, false), (4.0, false), (-4.0, true), (4.0, true)] { + if ratio && (hx == 0 || hy == 0) { + continue; + } + let what = format!("Classic Mode {classic}, Auto Center {auto}, handle ({hx}, {hy}), grabbed {grab} px off, ratio {ratio}"); + let mut app = app(); + app.ui.tool_options.crop_delete = false; + app.ui.tool_options.crop_shield.classic_mode = classic; + app.ui.tool_options.crop_shield.auto_center_preview = auto; + crate::crop_ui::ensure_frame(&mut app); + app.ui.crop_rect = Some([40.0, 20.0, 140.0, 70.0]); + app.crop.default_frame = false; + crate::canvas::commit_crop(&mut app); + let v = &mut app.ui.views[0]; + (v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [50.0, 25.0], false, 0.0); + app.ui.extras.snap = true; + crate::crop_ui::ensure_frame(&mut app); + assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 100.0, 50.0])); + if ratio { + app.ui.tool_options.crop_ratio = "original".into(); + } + let grab = vec2(f32::from(hx), f32::from(hy)) * grab; + let from = xf(&app).to_screen(50.0 + f32::from(hx) * 50.0, 25.0 + f32::from(hy) * 25.0) + grab; + // Toward the hidden corner (or edge), a screen pixel at a time on the longer axis. + let gap = vec2(f32::from(hx) * if hx < 0 { 40.0 } else { 60.0 }, f32::from(hy) * if hy < 0 { 20.0 } else { 30.0 }); + let dir = gap / gap.x.abs().max(gap.y.abs()); + let d = xf(&app).to_doc(from); + tool_event(&mut app, ToolEvent::Down { x: d[0], y: d[1], pressure: 1.0 }, NONE); + // Drag outward a screen pixel at a time; let go once the moving edges show + // the snap to the hidden pixels' edges (as the user would). + let snapped = |app: &PhotocraftApp| { + let on = |vertical: bool, pos: f64| { + app.prefs_rt + .snap_lines + .iter() + .any(|l| l.vertical == vertical && l.pos == pos && l.kind == photocraft_engine::snap::SnapKind::LayerEdge) + }; + (hx == 0 || on(true, if hx < 0 { hidden[0] } else { hidden[2] })) + && (hy == 0 || on(false, if hy < 0 { hidden[1] } else { hidden[3] })) + }; + let mut end = None; + for i in 1..=80 { + let d = xf(&app).to_doc(from + dir * i as f32); + tool_event(&mut app, ToolEvent::Move { x: d[0], y: d[1], pressure: 1.0 }, NONE); + if snapped(&app) { + end = Some(from + dir * i as f32); + break; + } + } + let end = end.unwrap_or_else(|| panic!("{what}: never snapped to the hidden edge")); + // Released where it stopped, read through the view as it is now. + let end = xf(&app).to_doc(end); + tool_event(&mut app, ToolEvent::Up { x: end[0], y: end[1] }, NONE); + let pick = |h: i8, lo: f64, hi: f64, a: f64, b: f64| match h { + -1 => [a, hi], + 1 => [lo, b], + _ => [lo, hi], + }; + let [x0, x1] = pick(hx, 0.0, 100.0, hidden[0], hidden[2]); + let [y0, y1] = pick(hy, 0.0, 50.0, hidden[1], hidden[3]); + assert_eq!(app.ui.crop_rect, Some([x0, y0, x1, y1]), "{what}: the frame lands exactly on the hidden edge"); + crate::canvas::commit_crop(&mut app); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.size.width, st.doc.size.height), ((x1 - x0) as u32, (y1 - y0) as u32), "{what}"); + let layer = st.doc.walk().into_iter().map(|(_, _, l)| l.id).next().unwrap(); + let r = photocraft_engine::snap::layer_rect(&st.doc, layer).unwrap(); + assert_eq!(r, [hidden[0] - x0, hidden[1] - y0, hidden[2] - x0, hidden[3] - y0], "{what}: no transparent strip"); + } + } + } + } + } } diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index 83d973739d..56fecd01eb 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -261,7 +261,7 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 return p; } let (out, lines) = match &snap.gesture { - Gesture::Point => live_crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)), + Gesture::Point => crop_handle(app, p, mods, &snap.targets, tol).unwrap_or_else(|| snap.targets.snap_point(p, tol)), Gesture::Guide => snap.targets.snap_point(p, tol), Gesture::Move { rect } | Gesture::TransformMove { rect } | Gesture::SelectionMove { rect } => { let d = [p[0] - snap.start[0], p[1] - snap.start[1]]; @@ -275,18 +275,26 @@ fn apply(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64; 2 out } -/// Snap the proposed handle on the image, calculated from the original camera. Snapping the -/// pointer in the moving camera feeds the previous frame's pan back into the resize and makes +/// Snap a crop handle being dragged: the handle itself (the frame's edge or corner), not the +/// pointer, which grabbed it up to the grab radius away (#1919: snapping the pointer left a strip +/// as wide as that offset between the frame and the edge it showed as snapped to). With Auto +/// Center Preview the handle is found on the image, calculated from the original camera: snapping +/// the pointer in the moving camera feeds the previous frame's pan back into the resize and makes /// a stationary pointer oscillate near the canvas edges. -fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec)> { +fn crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, targets: &SnapTargets, tol: f64) -> Option<([f64; 2], Vec)> { if app.ui.tool != Tool::Crop { return None; } - let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view: Some(view) } = app.crop.drag? else { return None }; + let crate::crop_ui::CropDrag::Resize { hx, hy, start, rect, view } = app.crop.drag? else { return None }; let deg = crate::crop_ui::angle(app); - let q = crate::crop_mode::to_press(app, Some(view), p); - let g = crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio: crate::crop_ui::resize_ratio(app, rect, mods), alt: mods.alt }; - let (frame, _) = crate::crop_mode::resized_frame(g)?; + let ratio = crate::crop_ui::resize_ratio(app, rect, mods); + let q = view.map_or(p, |view| crate::crop_mode::to_press(app, Some(view), p)); + // The frame for the pointer at `q`, as `crop_ui` resizes it. + let frame_at = |q: [f64; 2]| match view { + Some(_) => crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q, ratio, alt: mods.alt }).map(|(f, _)| f), + None => Some(crate::crop_ui::resized_turned(rect, deg, hx, hy, [q[0] - start[0], q[1] - start[1]], ratio, mods.alt)), + }; + let frame = frame_at(q)?; let at = |r: [f64; 4]| { let c = crate::crop_ui::center(r); crate::crop_ui::turn([c[0] + f64::from(hx) * (r[2] - r[0]) / 2.0, c[1] + f64::from(hy) * (r[3] - r[1]) / 2.0], c, deg) @@ -304,35 +312,39 @@ fn live_crop_handle(app: &PhotocraftApp, p: [f64; 2], mods: egui::Modifiers, tar if deg == 0.0 { lines.retain(|line| if line.vertical { hx != 0 } else { hy != 0 }); } - // Auto Center Preview moves both screen edges. On the image the opposite edge stays put, - // so the grabbed edge travels twice as far as the pointer (once with Alt). - let scale = if mods.alt { 1.0 } else { 2.0 }; if lines.is_empty() { return Some((p, lines)); } + // Auto Center Preview moves both screen edges. On the image the opposite edge stays put, + // so the grabbed edge travels twice as far as the pointer (once with Alt). Without it the + // handle follows the pointer. + let scale = if view.is_some() && !mods.alt { 2.0 } else { 1.0 }; + // How much the frame's extent grows per unit of pointer travel: both sides with Auto Center + // Preview or Alt, else the grabbed one. + let grow = if view.is_some() || mods.alt { 2.0 } else { 1.0 }; let original = at(rect); let travel = crate::crop_ui::turn([handle[0] - original[0], handle[1] - original[1]], [0.0, 0.0], -deg); let mut local = [(travel[0] + d[0]) / scale, (travel[1] + d[1]) / scale]; if deg == 0.0 && hx != 0 && hy != 0 - && let Some(k) = g.ratio + && let Some(k) = ratio { // A corner with a ratio has one degree of freedom. Put the other axis on the same // ratio, rather than letting the raw pointer's unused axis override the snapped edge. if lines.iter().any(|line| line.vertical) { - local[1] = f64::from(hy) * ((rect[2] - rect[0] + 2.0 * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / 2.0; + local[1] = f64::from(hy) * ((rect[2] - rect[0] + grow * f64::from(hx) * local[0]) / k - (rect[3] - rect[1])) / grow; } else { - local[0] = f64::from(hx) * ((rect[3] - rect[1] + 2.0 * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / 2.0; + local[0] = f64::from(hx) * ((rect[3] - rect[1] + grow * f64::from(hy) * local[1]) * k - (rect[2] - rect[0])) / grow; } } let d = crate::crop_ui::turn(local, [0.0, 0.0], deg); let q = [start[0] + d[0], start[1] + d[1]]; - if let Some((snapped_frame, _)) = crate::crop_mode::resized_frame(crate::crop_mode::LiveResize { q, ..g }) { + if let Some(snapped_frame) = frame_at(q) { let h = at(snapped_frame); lines.retain(|line| (if line.vertical { h[0] } else { h[1] } - line.pos).abs() < 1e-3); } - Some((crate::crop_mode::from_press(app, view, q), lines)) + Some((view.map_or(q, |view| crate::crop_mode::from_press(app, view, q)), lines)) } /// Snap a tool event's coordinates (called by `canvas::tool_event` before the tools see it). From 84aa8df05ab26b41283e1192791764d545cf34a2 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:10:55 +0700 Subject: [PATCH 029/523] fix(transform): keep nearest-neighbor previews on original texels (#3688) --- crates/ui-egui/src/transform_tex.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/transform_tex.rs b/crates/ui-egui/src/transform_tex.rs index 2811a6b586..e1ddb504bf 100644 --- a/crates/ui-egui/src/transform_tex.rs +++ b/crates/ui-egui/src/transform_tex.rs @@ -58,11 +58,16 @@ impl PreviewTextures { if !s.is_finite() || s <= 0.0 { return (0, false); } + // Nearest Neighbor is point sampling: downsampled mip levels have already averaged + // neighbouring texels, so using one would blur a pixel-art transform preview. + if nearest_interpolation { + return (0, true); + } if s < 0.5 { // log2(1/s) levels down, at most 12 (4096× smaller). - return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, nearest_interpolation); + return ((1.0 / s).log2().floor().clamp(0.0, 12.0) as u32, false); } - let crisp = nearest_interpolation || (zoom >= CRISP_ZOOM && scale <= 1.05); + let crisp = zoom >= CRISP_ZOOM && scale <= 1.05; (0, crisp) } @@ -203,6 +208,10 @@ mod tests { assert_eq!(PreviewTextures::choose(1.0, 1.0, true), (0, true)); assert_eq!(PreviewTextures::choose(0.25, 1.0, false), (2, false)); assert_eq!(PreviewTextures::choose(0.3, 1.0, false), (1, false)); + // Nearest Neighbor must use the full-resolution texels, even when zoomed far out. + // A mipmap would average adjacent opaque/transparent pixels before sampling. + assert_eq!(PreviewTextures::choose(0.25, 1.0, true), (0, true)); + assert_eq!(PreviewTextures::choose(1.0 / 4096.0, 1.0, true), (0, true)); assert_eq!(PreviewTextures::choose(f32::NAN, 1.0, false), (0, false)); assert_eq!(PreviewTextures::choose(1e-9, 1.0, false).0, 12); } From 8c6e4a3abc68fee141922cb76e1f3eb82078ff53 Mon Sep 17 00:00:00 2001 From: agent-346022 Date: Mon, 12 Oct 2026 11:13:20 +0300 Subject: [PATCH 030/523] fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572) anchor_factors resolved the x and y axes independently and with different matching rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase "top"/"bottom" prefix). A misspelling matched one rule and missed the other, so "topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not offer -- with no error. Match the anchor exactly against the nine documented values via one ANCHORS table; anything else is a BadParams error naming `anchor` and listing them, as #507 settled for other command params. The nine valid values keep the exact factors they had, and an omitted anchor still means center. Test covers the nine offsets, the omitted default, and the misspellings from the issue ("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"). Fixes #2946. Co-authored-by: photocraft-agent UNCONFIGURED --- crates/engine/src/image_cmds.rs | 76 +++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index c8055a4d3c..93a9990bd9 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -315,22 +315,28 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi Ok(()) } -fn anchor_factors(a: &str) -> (f64, f64) { - let x = if a.contains("Left") || a == "left" { - 0.0 - } else if a.contains("Right") || a == "right" { - 1.0 - } else { - 0.5 - }; - let y = if a.starts_with("top") { - 0.0 - } else if a.starts_with("bottom") { - 1.0 - } else { - 0.5 - }; - (x, y) +/// The nine documented canvas-size anchors, in the order the command lists them, each mapped to +/// its (x, y) factor (0.0 = top/left edge, 0.5 = centre, 1.0 = bottom/right edge). +const ANCHORS: [(&str, (f64, f64)); 9] = [ + ("topLeft", (0.0, 0.0)), + ("top", (0.5, 0.0)), + ("topRight", (1.0, 0.0)), + ("left", (0.0, 0.5)), + ("center", (0.5, 0.5)), + ("right", (1.0, 0.5)), + ("bottomLeft", (0.0, 1.0)), + ("bottom", (0.5, 1.0)), + ("bottomRight", (1.0, 1.0)), +]; + +/// Resolves an anchor name to its (x, y) factors, matching the nine documented values exactly. +/// Anything else is an error naming `anchor` and listing them, rather than resolving the two axes +/// independently (which let a misspelling like `"topleft"` land between two valid anchors, #2946). +fn anchor_factors(cmd: &str, a: &str) -> Result<(f64, f64)> { + ANCHORS.iter().find(|(name, _)| *name == a).map(|&(_, f)| f).ok_or_else(|| { + let names = ANCHORS.iter().map(|&(n, _)| n).collect::>().join("|"); + bad(cmd, format!("`anchor` must be one of {names}")) + }) } fn extension_color(s: &Session, p: &Value) -> [f32; 4] { @@ -357,7 +363,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { let h = p.get("height").and_then(Value::as_f64).unwrap_or(if relative { 0.0 } else { oh }); let (nw, nh) = if relative { (ow + w, oh + h) } else { (w, h) }; let (nw, nh) = (nw.round().clamp(1.0, 300_000.0) as u32, nh.round().clamp(1.0, 300_000.0) as u32); - let (ax, ay) = anchor_factors(p.get("anchor").and_then(Value::as_str).unwrap_or("center")); + let (ax, ay) = anchor_factors("image.canvasSize", p.get("anchor").and_then(Value::as_str).unwrap_or("center"))?; let dx = ((nw as f64 - ow) * ax).round() as i32; let dy = ((nh as f64 - oh) * ay).round() as i32; let ext = extension_color(s, p); @@ -980,6 +986,42 @@ mod tests { assert_eq!(doc(&s).layers[1].surface().unwrap().content_bounds(), Rect::new(20, 10, 30, 20)); } + #[test] + fn canvas_size_anchor_offsets_and_rejects_unlisted() { + // 64x48 -> 100x80: dx spans 36 and dy spans 32, so each anchor factor lands on a known + // offset. All nine documented values must anchor exactly where they do today (#2946). + let expected = [ + ("topLeft", [0, 0]), + ("top", [18, 0]), + ("topRight", [36, 0]), + ("left", [0, 16]), + ("center", [18, 16]), + ("right", [36, 16]), + ("bottomLeft", [0, 32]), + ("bottom", [18, 32]), + ("bottomRight", [36, 32]), + ]; + for (anchor, off) in expected { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap(); + let r = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap(); + assert_eq!(r["offset"], json!(off), "anchor {anchor}"); + } + // An omitted anchor still defaults to center. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap(); + assert_eq!(s.execute("image.canvasSize", json!({"width": 100, "height": 80})).unwrap()["offset"], json!([18, 16])); + // A misspelling or unknown value is a BadParams error naming `anchor`, never an anchor + // between two of the nine (the old independent-axis matching produced top-centre etc.). + for anchor in ["topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "background": "white"})).unwrap(); + let err = s.execute("image.canvasSize", json!({"width": 100, "height": 80, "anchor": anchor})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "anchor {anchor:?} must be rejected, got {err:?}"); + assert!(err.to_string().contains("anchor"), "the error for {anchor:?} must name `anchor`: {err}"); + } + } + #[test] fn crop_to_selection_and_trim() { let mut s = session(); From dac995ee6c11be6d5859cc5f6ed26e2f05e21f3c Mon Sep 17 00:00:00 2001 From: kayrockscreenprinting Date: Mon, 12 Oct 2026 01:18:33 -0700 Subject: [PATCH 031/523] =?UTF-8?q?Edit=20=E2=80=BA=20Transform=20?= =?UTF-8?q?=E2=80=BA=20Skew:=20corners=20and=20sides=20move=20along=20one?= =?UTF-8?q?=20axis,=20like=20Photoshop=20(#3437)=20(#3590)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Edit › Transform › Skew: corners and sides move along one axis, like Photoshop (#3437) In Skew mode a corner drag scaled the box and the side handles moved freely. Checked in Photoshop: a corner moves alone, horizontally or vertically (whichever way the drag mostly goes); the top and bottom handles slide only horizontally and the left and right ones only vertically; Option moves the opposite corner or edge the other way. On editable type a corner slides its whole edge instead, so the text stays a parallelogram and remains live type. Free Transform is unchanged. Co-Authored-By: Claude Opus 5.5 * Skew mode: a drag outside the box doesn't rotate it; ⇧ changes nothing (#3437) Checked in Photoshop. A click outside still commits. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/transform_tool.rs | 153 ++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 062cdbdcce..6d1c22886a 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1251,9 +1251,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> } // Distort only moves corners (and the whole box from inside): no rotating, no edges. // Even in Distort mode, remember an outside press so a plain click can - // accept the transform. A drag outside remains inert in that mode, and in + // accept the transform. A drag outside remains inert in that mode, in Skew, and in // Content-Aware Scale, which only scales and moves. - pv.gesture = (distort_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot }); + pv.gesture = (mode_allows(t.mode, h) || h == Hit::Outside).then_some(Gesture { hit: h, start: [x, y], quad0: t.quad, pivot0: t.pivot }); } ToolEvent::Move { x, y, .. } | ToolEvent::Up { x, y } => { let g = pv.gesture; @@ -1268,15 +1268,20 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> return true; } let legacy = app.session.prefs().general.use_legacy_free_transform; + let type_layer = moves_type(app, &t); if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) - && distort_allows(t.mode, g.hit) + && mode_allows(t.mode, g.hit) && (t.content_aware.is_none() || g.hit != Hit::Outside) { let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) }; - let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods)); - // Content-Aware Scale: ⌘ neither skews nor distorts. - mods.command &= t.content_aware.is_none(); - apply_drag(s, g, [x, y], mods); + if t.content_aware.is_some() { + let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods)); + // Content-Aware Scale: ⌘ neither skews nor distorts. + mods.command = false; + apply_drag(s, g, [x, y], mods); + } else { + apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, mods)); + } } } } @@ -1318,11 +1323,11 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> mods } -/// Skew / Distort / Perspective modes: a handle drag acts as if that gesture's keys were held -/// (⌘-drag an edge skews, ⌘-drag a corner distorts, ⌘⌥⇧-drag a corner adds perspective). +/// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a +/// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]). fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { match (mode, hit) { - (TransformMode::Skew, Hit::Edge(_)) | (TransformMode::Distort, Hit::Corner(_)) => mods.command = true, + (TransformMode::Distort, Hit::Corner(_)) => mods.command = true, (TransformMode::Perspective, Hit::Corner(_)) => { mods.command = true; mods.alt = true; @@ -1333,9 +1338,67 @@ fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui:: mods } -/// In Distort mode only corner drags (and moving the box from inside) do anything. -fn distort_allows(mode: TransformMode, h: Hit) -> bool { - mode != TransformMode::Distort || !matches!(h, Hit::Outside | Hit::Edge(_)) +/// A handle drag in Edit › Transform `mode` (`Free` for Free Transform). `type_layer`: the box +/// moves editable type, which only takes affine transforms. +pub(crate) fn apply_mode_drag(s: &mut TransformSession, g: Gesture, p: [f64; 2], mode: TransformMode, type_layer: bool, mut mods: egui::Modifiers) { + if !mode_allows(mode, g.hit) { + return; + } + match (mode, g.hit) { + // Skew, as in Photoshop (#3437): a corner moves alone along one of its two edges, + // whichever the drag mostly follows; ⌥ moves the opposite corner the other way. + (TransformMode::Skew, Hit::Corner(i)) if i < 4 => { + let (d, q0) = ([p[0] - g.start[0], p[1] - g.start[1]], g.quad0); + // Corners 0..3 = TL, TR, BR, BL: the neighbour along the top/bottom edge, and along + // the left/right one. + let along = |j: usize| { + let e = [q0[i][0] - q0[j][0], q0[i][1] - q0[j][1]]; + let l = e[0].hypot(e[1]); + let u = if l > 0.0 { [e[0] / l, e[1] / l] } else { [0.0, 0.0] }; + let t = d[0] * u[0] + d[1] * u[1]; + [t * u[0], t * u[1]] + }; + let (hn, vn) = ([1, 0, 3, 2][i], [3, 2, 1, 0][i]); + let (h, v) = (along(hn), along(vn)); + let (m, partner) = if h[0].hypot(h[1]) >= v[0].hypot(v[1]) { (h, hn) } else { (v, vn) }; + // Editable type stays a parallelogram: the corner's whole edge slides with it. + let moved: &[usize] = if type_layer { &[i, partner] } else { &[i] }; + let mut q = q0; + for k in 0..4 { + let sign = if moved.contains(&k) { + 1.0 + } else if mods.alt && (if type_layer { true } else { k == (i + 2) % 4 }) { + // ⌥: the opposite corner (on type, the opposite edge) the other way. + -1.0 + } else { + continue; + }; + if let (Some(dst), Some(src)) = (q.get_mut(k), q0.get(k)) { + *dst = [src[0] + sign * m[0], src[1] + sign * m[1]]; + } + } + s.quad = keep_convex(q0, q); + s.pivot = carry_pivot(q0, s.quad, g.pivot0); + } + // Skew: an edge slides only along itself (⌘⇧ in Free Transform). + (TransformMode::Skew, Hit::Edge(_)) => { + mods.command = true; + mods.shift = true; + apply_drag(s, g, p, mods); + } + _ => apply_drag(s, g, p, mode_mods(mode, g.hit, mods)), + } +} + +/// What a drag can do in each Edit › Transform mode: in Distort only the corners (and moving the +/// box from inside); in Skew everything but rotating from outside the box (#3437). A click +/// outside still commits. +fn mode_allows(mode: TransformMode, h: Hit) -> bool { + match mode { + TransformMode::Distort => !matches!(h, Hit::Outside | Hit::Edge(_)), + TransformMode::Skew => h != Hit::Outside, + _ => true, + } } /// The turn at each corner of a quad (cross products of consecutive edges): all the same sign @@ -1770,8 +1833,12 @@ pub(crate) fn rotate_cursor_dir(app: &PhotocraftApp, p: [f64; 2]) -> Option<[f64 } fn rotate_dir(t: &TransformSession, pv: Option<&TransformPreview>, p: [f64; 2], tol: f64) -> Option<[f64; 2]> { - // Content-Aware Scale's box only scales: no rotate arrow. - if !(p[0].is_finite() && p[1].is_finite()) || t.warp.is_some() || t.content_aware.is_some() || t.mode == TransformMode::Distort { + // Content-Aware Scale's box only scales, and Skew doesn't rotate: no rotate arrow. + if !(p[0].is_finite() && p[1].is_finite()) + || t.warp.is_some() + || t.content_aware.is_some() + || matches!(t.mode, TransformMode::Distort | TransformMode::Skew) + { return None; } let rotating = pv.is_some_and(|pv| pv.gesture.is_some_and(|g| g.hit == Hit::Outside)); @@ -1812,7 +1879,7 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2], quick_split: bool) -> Option [[f64; 2]; 4] { + drag_on(mode, false, from, to, mods) + } + + fn drag_on(mode: TransformMode, type_layer: bool, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) -> [[f64; 2]; 4] { + let mut s = session(); + let g = Gesture { hit: hit(&s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot }; + apply_mode_drag(&mut s, g, to, mode, type_layer, mods); + s.quad + } + + /// Skew on editable type, checked in Photoshop: a corner can't move alone (type stays a + /// parallelogram), so it slides its whole edge: the top or bottom edge for a mostly + /// horizontal drag, the left or right one for a mostly vertical drag. ⌥ moves the opposite + /// edge the other way. Side handles are as on any layer. + #[test] + fn skew_mode_on_type_slides_the_corners_edge() { + let (skew, none) = (TransformMode::Skew, egui::Modifiers::NONE); + assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], none), [[10.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]); + assert_eq!(drag_on(skew, true, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 41.0], [0.0, 50.0]]); + assert_eq!(drag_on(skew, true, [100.0, 0.0], [110.0, 3.0], egui::Modifiers::ALT), [[10.0, 0.0], [110.0, 0.0], [90.0, 50.0], [-10.0, 50.0]]); + assert_eq!(drag_on(skew, true, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]); + } + + /// Edit › Transform › Skew, checked in Photoshop (#3437): a corner moves alone, horizontally or + /// vertically (whichever way the drag mostly goes); the top and bottom handles slide only + /// horizontally, the left and right ones only vertically; ⌥ moves the opposite corner or edge + /// the other way. Nothing scales. + #[test] + fn skew_mode_moves_a_corner_or_an_edge_along_one_axis() { + let skew = TransformMode::Skew; + let none = egui::Modifiers::NONE; + // Corner (top right), mostly horizontal, then mostly vertical: only that corner moves. + assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], none), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [0.0, 50.0]]); + assert_eq!(drag_in(skew, [100.0, 0.0], [102.0, -9.0], none), [[0.0, 0.0], [100.0, -9.0], [100.0, 50.0], [0.0, 50.0]]); + // Top edge: horizontal only, both its corners. + assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], none), [[12.0, 0.0], [112.0, 0.0], [100.0, 50.0], [0.0, 50.0]]); + // Left edge: vertical only. + assert_eq!(drag_in(skew, [0.0, 25.0], [6.0, 33.0], none), [[0.0, 8.0], [100.0, 0.0], [100.0, 50.0], [0.0, 58.0]]); + // ⌥: the opposite corner, and the opposite edge, go the other way. + let alt = egui::Modifiers::ALT; + assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], alt), [[0.0, 0.0], [110.0, 0.0], [100.0, 50.0], [-10.0, 50.0]]); + assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], alt), [[12.0, 0.0], [112.0, 0.0], [88.0, 50.0], [-12.0, 50.0]]); + // ⇧ changes nothing in Skew mode. + let shift = egui::Modifiers::SHIFT; + assert_eq!(drag_in(skew, [100.0, 0.0], [110.0, 3.0], shift), drag_in(skew, [100.0, 0.0], [110.0, 3.0], none)); + assert_eq!(drag_in(skew, [50.0, 0.0], [62.0, 7.0], shift), drag_in(skew, [50.0, 0.0], [62.0, 7.0], none)); + // A drag outside the box doesn't rotate it in Skew mode. + assert_eq!(drag_in(skew, [130.0, 25.0], [120.0, 60.0], none), corners([0.0, 0.0, 100.0, 50.0])); + // Free Transform is unchanged: a corner scales. + assert_ne!(drag_in(TransformMode::Free, [100.0, 0.0], [110.0, 3.0], none)[2], [100.0, 50.0]); + } + fn close(a: [[f64; 2]; 4], b: [[f64; 2]; 4]) -> bool { a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() < 1e-6) } From 78c611007be069535d76813b339461bc2d75114e Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:22:10 +0200 Subject: [PATCH 032/523] PSD: alpha channel resources count the merged transparency, 13-byte DisplayInfo (#3577) Photoshop 27.11 refused two of PhotoCraft's re-exported corpus files (ag-psd layer-mask/src.psd, psd-tools effects/stroke-without-vector-mask) with "Could not import the clipboard because an unexpected end-of-file was encountered". Both carry transparency in the merged image plus an alpha channel: PhotoCraft wrote two extra channels but named and described only one in resources 1006, 1045 and 1077. In every corpus file that has them (177, Photoshop's included), those resources hold one entry per extra channel, the merged transparency first. - Export: a "Transparency" entry leads the names and DisplayInfo when the merged image carries transparency and there are alpha channels. - DisplayInfo (1077) records are 13 bytes, as in all 166 corpus files that have one; PhotoCraft wrote and read 14 (the older 1007 layout), which shifts every channel after the first. - Import skips the transparency's entry, so an alpha channel keeps its own name and options ("Alpha 1", not "Transparency"). Corpus oracles and round trips are unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258; every round trip renders the same). Co-authored-by: Claude Opus 5.5 --- crates/io/src/channel_map.rs | 38 ++++++++++++++++++++++--------- crates/io/src/multichannel_map.rs | 6 ++--- crates/io/src/psd_export.rs | 37 ++++++++++++++++++++++++++++-- crates/io/src/psd_import.rs | 7 ++++-- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index f8dada8b0b..a237759002 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -19,9 +19,16 @@ const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; -/// Resource 1077 for `channels` (in file order). -pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec { +/// Resource 1077 for `channels` (in file order): a version, then 13 bytes per channel (colour +/// space, four components, opacity, kind). Every corpus file, Photoshop's included, uses 13; +/// 14-byte records (the older 1007 layout) shift every channel after the first. +/// `transparency`: the merged image's transparency channel comes first and gets a record too. +pub(crate) fn display_info(channels: &[&AlphaChannel], transparency: bool) -> Vec { let mut out = 1u32.to_be_bytes().to_vec(); + if transparency { + // As Photoshop writes it: red, 100 %, masked areas. + out.extend_from_slice(&[0, 0, 0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 100, 1]); + } for c in channels { let (color, opacity, kind) = match c.spot { Some((ink, solidity)) => (ink, solidity, 2u8), @@ -34,15 +41,16 @@ pub(crate) fn display_info(channels: &[&AlphaChannel]) -> Vec { } out.extend_from_slice(&((opacity.clamp(0.0, 1.0) * 100.0).round() as u16).to_be_bytes()); out.push(kind); - out.push(0); } out } -/// Applies a DisplayInfo resource (`versioned` = 1077) to the imported channels in order. -pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [AlphaChannel]) { - let body = if versioned { data.get(4..).unwrap_or(&[]) } else { data }; - for (c, rec) in channels.iter_mut().zip(body.as_chunks::<14>().0) { +/// Applies a DisplayInfo resource (`versioned` = 1077: 13-byte records after a version; else the +/// older 1007 with 14-byte records) to the imported channels in order, after skipping `skip` +/// records (the merged transparency channel's). +pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, channels: &mut [AlphaChannel]) { + let (body, size) = if versioned { (data.get(4..).unwrap_or(&[]), 13) } else { (data, 14) }; + for (c, rec) in channels.iter_mut().zip(body.chunks_exact(size).skip(skip)) { let u = |i: usize| u16::from_be_bytes([rec[i], rec[i + 1]]); let comps = [u(2), u(4), u(6), u(8)]; let color = decode_color(u(0), comps); @@ -97,17 +105,25 @@ mod tests { a.indicates = ColorIndicates::SelectedAreas; let mut s = AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8)); s.spot = Some((Color::rgb(1.0, 0.0, 1.0), 0.8)); - let bytes = display_info(&[&a, &s]); - assert_eq!(bytes.len(), 4 + 28); + let bytes = display_info(&[&a, &s], false); + // 13 bytes per channel, as every corpus file (Photoshop's included) lays out 1077. + assert_eq!(bytes.len(), 4 + 2 * 13); let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; - apply_display_info(&bytes, true, &mut back); + apply_display_info(&bytes, true, 0, &mut back); assert_eq!(back[0].color, a.color); assert!((back[0].opacity - 0.3).abs() < 1e-6); assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); assert_eq!(back[1].spot, s.spot); + // With the merged transparency's record first, the channels still get their own. + let with_t = display_info(&[&a, &s], true); + assert_eq!(with_t.len(), 4 + 3 * 13); + let mut back = vec![AlphaChannel::new("A", Surface::new(PixelFormat::GRAY8)), AlphaChannel::new("S", Surface::new(PixelFormat::GRAY8))]; + apply_display_info(&with_t, true, 1, &mut back); + assert_eq!(back[0].indicates, ColorIndicates::SelectedAreas); + assert_eq!(back[1].spot, s.spot); // Truncated data is ignored rather than misread. let mut c = vec![AlphaChannel::new("x", Surface::new(PixelFormat::GRAY8))]; - apply_display_info(&bytes[..10], true, &mut c); + apply_display_info(&bytes[..10], true, 0, &mut c); assert_eq!(c[0].opacity, 0.5); } diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index 40b6884bec..d20a70efcd 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -48,9 +48,9 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) }); } if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels); + crate::channel_map::apply_display_info(&r.data, true, 0, &mut doc.channels); } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels); + crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels); } } @@ -102,7 +102,7 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect(); resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names))); - resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false))); } if let Some(x) = &doc.metadata.xmp { resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec())); diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 771ebcb780..5fdf837429 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1435,10 +1435,12 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c resources.push(ImageResource::new(1032, guides_resource(doc))); } if !extra.is_empty() { - let names: Vec<&str> = extra.iter().map(|a| a.name.as_str()).collect(); + // One entry per extra channel, the merged transparency included (as in every file + // Photoshop opens; one short, Photoshop refuses the file with "unexpected end-of-file"). + let names: Vec<&str> = has_alpha.then_some("Transparency").into_iter().chain(extra.iter().map(|a| a.name.as_str())).collect(); resources.push(ImageResource::new(1006, pascal_names_resource(&names))); resources.push(ImageResource::new(1045, unicode_names_resource(&names))); - resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra))); + resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&extra, has_alpha))); } if let Some(id) = quick_mask_id { let mut data = id.to_be_bytes().to_vec(); @@ -1717,4 +1719,35 @@ mod tests { assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd); assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb); } + + /// A layered document with transparency in the merged image and an alpha channel: the name + /// and DisplayInfo resources hold one entry per extra channel, the merged transparency first, + /// as in every file Photoshop opens (one short, Photoshop 27.11 refused the file with "Could + /// not import the clipboard because an unexpected end-of-file was encountered"). The alpha + /// channel keeps its own name and options on the way back. + #[test] + fn alpha_names_count_the_merged_transparency() { + let mut doc = document(8, 6); + let mut layer = Layer::raster("Layer 1", doc.pixel_format()); + layer.surface_mut().unwrap().write_pixel(1, 1, &[1.0, 0.0, 0.0, 1.0]); + doc.layers.push(layer); + let mut a = photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(PixelFormat::GRAY8)); + a.opacity = 0.5; + doc.channels.push(a); + let file = document_to_psd(&doc); + let header_extra = usize::from(file.header.channels) - 3; + let names = |id| file.resources.iter().find(|r| r.id == id).map(|r| r.data.clone()).unwrap(); + let pascal = names(1006); + let (mut count, mut i) = (0, 0); + while i < pascal.len() { + count += 1; + i += 1 + usize::from(pascal[i]); + } + assert_eq!((header_extra, count), (2, 2), "transparency + Alpha 1"); + assert_eq!(names(crate::channel_map::DISPLAY_INFO).len(), 4 + 2 * 13); + let back = crate::import("a.psd", &file.to_bytes().unwrap()).unwrap().document; + assert_eq!(back.channels.len(), 1); + assert_eq!(back.channels[0].name, "Alpha 1"); + assert!((back.channels[0].opacity - 0.5).abs() < 1e-6); + } } diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index da483c04d3..24beaa9044 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -808,6 +808,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) let first = cc + usize::from(file.merged_has_alpha()); let plane = h.row_bytes() * hh; let names = file.resource(1045).map(|r| unicode_names(&r.data)).unwrap_or_default(); + // Photoshop lists every extra channel, the merged transparency first when there is one. + let skip = usize::from(file.merged_has_alpha() && names.len() == usize::from(h.channels).saturating_sub(cc)); + let names = names.get(skip..).unwrap_or_default(); for (k, idx) in (first..usize::from(h.channels)).enumerate() { let Some(p) = all.get(idx * plane..(idx + 1) * plane) else { break }; let mut s = Surface::new(cx.mask_fmt); @@ -818,9 +821,9 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) } // Channel options (overlay colour, opacity, colour indicates) and spot inks. if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { - crate::channel_map::apply_display_info(&r.data, true, &mut doc.channels); + crate::channel_map::apply_display_info(&r.data, true, skip, &mut doc.channels); } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { - crate::channel_map::apply_display_info(&r.data, false, &mut doc.channels); + crate::channel_map::apply_display_info(&r.data, false, skip, &mut doc.channels); } // Saved in Quick Mask mode: that channel becomes the document's Quick Mask again. if let Some(r) = file.resource(crate::channel_map::QUICK_MASK_INFO) From 97c6769d6b65761f595e78b8028b7605b9ddc089 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:32:33 +0200 Subject: [PATCH 033/523] Glows: Photoshop's precise ramp, and centre-source glows inverted after the contour (#3578) Measured on Photoshop 27.11 (scripted: a 128 px square with one glow, sampled pixel by pixel across its edges; outer and inner, both techniques, sizes 10 and 20, spread 0/40 %, choke 0/30 %, Range 50 %): - A Precise glow is 1 - D / (size + 1) beyond the solid spread/choke part, D being the centre-to-centre distance to the shape's edge. PhotoCraft's ramp ended at `size` and the default Range then doubled it, so it stayed solid for half the size and fell off steeply: up to 122 levels off. The raw coverage is now 0.5 at the edge falling to 0 at size + 1, so the shared Range step (v / range) gives Photoshop's ramp at the default 50 %; other Range values are unmeasured. - A centre-source inner glow is exactly 1 - the edge glow, taken after the contour and Range. PhotoCraft inverted before them: the softer centre glow was 115 levels off, the precise one 64. All 16 glow cases are now within 2 levels (10 were 58 to 122 off); softer edge and outer glows are unchanged. On the GPU: the same ramp in fs_mglow, and an "invert after the contour" mode (p0.y = 2) in fs_mfinish for glows only (Satin keeps its own invert). The 44 GPU parity tests pass. Corpus: the two Photoshop precise-glow oracles go from 0.31 / 0.37 to 0.05 worst error (curved anti-aliased shapes, still not under 2/255); nothing else changes. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/effects.rs | 81 ++++++++++++++++++++++++++--------- crates/gpu/src/compose.wgsl | 16 ++++--- crates/gpu/src/fx.rs | 21 +++++++-- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d77941847c..cc3272a14c 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1114,27 +1114,24 @@ fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin: } fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map { - let src = if inner { - match g.source { - GlowSource::Edge => shape.clone().map(|a| 1.0 - a), - GlowSource::Center => shape.clone(), - } - } else { - shape.clone() - }; + // An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken + // after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly + // 1 − the edge glow, for both techniques). + let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() }; let mut m = match g.technique { GlowTechnique::Precise => { - // An inner glow, from either source, measures from the edge inwards; a centre glow is - // the inverse below (the distance outside is ≤ 0 everywhere inside, #966). + // Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to + // centre), solid within the spread / choke: after the default 50 % Range that is + // Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner, + // sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range + // values are unmeasured). The distances here are 0.5 px short of centre to centre. let d = if inner { dist_inside(shape) } else { dist_outside(&src) }; let solid = g.size * g.spread; - let soft = (g.size - solid).max(1e-3); + let soft = (g.size + 1.0 - solid).max(1e-3); let mut m = Map::new(shape.w, shape.h, 0.0); for (o, d) in m.v.iter_mut().zip(d) { - *o = if d <= solid { 1.0 } else { (1.0 - (d - solid) / soft).clamp(0.0, 1.0) }; - } - if inner && g.source == GlowSource::Center { - m = m.map(|v| 1.0 - v); + let d = d + 0.5; + *o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) }; } m } @@ -1142,16 +1139,13 @@ fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map { let (r, bw) = spread_split(g.size, g.spread); let mut m = dilate(&src, r); blur(&mut m, bw); - if inner && g.source == GlowSource::Center { - // Brightest in the middle: coverage from the distance to the edge. - let mut e = dilate(&shape.clone().map(|a| 1.0 - a), r); - blur(&mut e, bw); - m = e.map(|v| 1.0 - v); - } m } }; m = apply_lut(m, glow_lut(g)); + if inner && g.source == GlowSource::Center { + m = m.map(|v| 1.0 - v); + } noise(&mut m, g.noise, origin.0, origin.1); if inner { for (v, a) in m.v.iter_mut().zip(&shape.v) { @@ -2883,6 +2877,51 @@ mod tests { assert!(glow_lut(&g).is_none()); } + /// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise + /// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow. + #[test] + fn glows_match_photoshop() { + let glow = |technique, size: f32, spread: f32, source| Glow { + common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), + paint: FxPaint::Color(photocraft_color::Color::BLACK), + technique, + spread, + size, + contour: Contour::Linear, + anti_alias: false, + range: 0.5, + jitter: 0.0, + noise: 0.0, + source, + }; + let n = 60; + let mut shape = Map::new(n, n, 0.0); + for y in 20..40 { + for x in 20..40 { + shape.v[y * n + x] = 1.0; + } + } + let at = |m: &Map, x: usize| m.v[30 * n + x]; + // Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232 + // at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11). + let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0)); + for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] { + let got = 255.0 * (1.0 - at(&m, 20 - d)); + assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}"); + } + // Spread 40 %: solid for the first 4 px. + let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0)); + assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0); + // Centre source = 1 − edge, for both techniques. + for t in [GlowTechnique::Precise, GlowTechnique::Softer] { + let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0)); + let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0)); + for x in 20..40 { + assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}"); + } + } + } + #[test] fn offsets_follow_light() { let (dx, dy) = offset(120.0, 10.0); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index c6a0edbd57..6b5321fce5 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1152,25 +1152,27 @@ fn fs_mblur(in: VOut) -> @location(0) vec4 { return mout(acc); } -// Precise glow from a distance map: p0 = (solid, soft, invert). +// Precise glow from a distance map (`effects::glow_map`): p0 = (solid, soft). Coverage is 0.5 at +// the edge falling to 0 at `size + 1` px centre to centre (the field is 0.5 px short of that), +// solid within the spread / choke. @fragment fn fs_mglow(in: VOut) -> @location(0) vec4 { - let d = textureLoad(tex_a, local(in.pos), 0).r; - var m = clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0); + let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5; + var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0); if (d <= op.p0.x) { m = 1.0; } - if (op.p0.z > 0.5) { m = 1.0 - m; } return mout(m); } -// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v, contour (LUT row 0), × shape -// (`layer_tex`)). +// Last step of a coverage map: p0 = (|A - B| (satin), 1 - v (1: before the contour, 2: after it, +// as a centre-source inner glow), contour (LUT row 0), × shape (`layer_tex`)). @fragment fn fs_mfinish(in: VOut) -> @location(0) vec4 { let p = local(in.pos); var v = textureLoad(tex_a, p, 0).r; if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); } - if (op.p0.y > 0.5) { v = 1.0 - v; } + if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; } if (op.p0.z > 0.5) { v = lut(0, v); } + if (op.p0.y > 1.5) { v = 1.0 - v; } if (op.p0.w > 0.5) { v = v * textureLoad(layer_tex, p, 0).r; } return mout(v); } diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 48da34240e..e3a6f5863d 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -236,6 +236,19 @@ impl B { fn finish(&mut self, a: In, b: Option, invert: bool, contour: &Contour, times_shape: bool, out: usize) { self.finish_lut(a, b, invert, contour_lut(contour), times_shape, out); } + /// A glow's last step: its contour over the Range, then (centre-source inner glows) the + /// inverse, then × the shape when inner. + fn finish_glow(&mut self, a: In, lut: Option>, inner: bool, center: bool) { + let mut s = + stage(Kernel::MFinish, Some(a), None, [0.0, if center { 2.0 } else { 0.0 }, f32::from(u8::from(lut.is_some())), f32::from(u8::from(inner))], 0); + s.lut = lut.map(Arc::new); + if inner { + s.s = Some(In::Shape); + } + s.out = Some(0); + self.stages.push(s); + } + fn finish_lut(&mut self, a: In, b: Option, invert: bool, lut: Option>, times_shape: bool, out: usize) { let flag = |v: bool| f32::from(u8::from(v)); let mut s = stage(Kernel::MFinish, Some(a), b, [flag(b.is_some()), flag(invert), flag(lut.is_some()), flag(times_shape)], 0); @@ -319,9 +332,9 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, GlowTechnique::Precise => { let d = b.field(if inner { FieldKind::Inside } else { FieldKind::Outside }, g.size); let solid = g.size * g.spread; - let soft = (g.size - solid).max(1e-3); - let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, f32::from(u8::from(center)), 0.0], 0)); - b.finish_lut(m, None, false, photocraft_compose::effects::glow_lut(g), inner, 0); + let soft = (g.size + 1.0 - solid).max(1e-3); + let m = b.push(stage(Kernel::MGlow, Some(d), None, [solid, soft, 0.0, 0.0], 0)); + b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center); } GlowTechnique::Softer => { // Inner glows (edge, and centre as 1 - the edge result) spread 1 - alpha. @@ -333,7 +346,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, m = b.dilate(src, d, r); } let m = b.blur(m, bw); - b.finish_lut(m, None, center, photocraft_compose::effects::glow_lut(g), inner, 0); + b.finish_glow(m, photocraft_compose::effects::glow_lut(g), inner, center); } } 1 From 4cf8a5fd059f65dee60da27af83c301bd35cc201 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:12 +0200 Subject: [PATCH 034/523] Canvas: wheel and pinch zoom over the pasteboard keep the image in view (#3720) (#3731) With the pointer on the gray pasteboard, wheel/Alt-wheel/pinch zoom anchored on the pointer, so each notch slid the image away from it and out of the window. The anchor is now the nearest point of the image (clamped in document space, so rotated and flipped views work); over the image nothing changes. --- crates/ui-egui/src/canvas.rs | 35 +++++++++++++++++- crates/ui-egui/src/input_tests.rs | 59 +++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b68f6579c..54e32bd862 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2779,7 +2779,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // zooms nor slides the image towards the pointer. let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size); if nz != view.zoom { - zoom_about(&mut view, &xf, p, nz, false, ppp); + // Over the pasteboard the anchor is the image's nearest point (#3720). + let at = wheel_anchor(&xf, p, view.doc_size); + zoom_about(&mut view, &xf, at, nz, false, ppp); } } (Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => { @@ -3557,6 +3559,20 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: /// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under /// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the /// centre of the view instead (#204). +/// Where a wheel or pinch zoom anchors for a pointer at `p`: the pointer itself over the image, +/// otherwise the nearest point of the image (#3720). Anchoring on a pointer out on the gray +/// pasteboard slid the image away from it, out of the window after a few notches; Photoshop keeps +/// the image in view. Clamped in document space, so a rotated or flipped view behaves the same. +fn wheel_anchor(xf: &ViewXform, p: Pos2, doc_size: [u32; 2]) -> Pos2 { + let [w, h] = doc_size; + let d = xf.to_doc(p); + if w == 0 || h == 0 || !(d[0].is_finite() && d[1].is_finite()) { + return p; + } + let c = [d[0].clamp(0.0, f64::from(w)), d[1].clamp(0.0, f64::from(h))]; + if c == d { p } else { xf.to_screen(c[0] as f32, c[1] as f32) } +} + fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) { let before = xf.to_doc(p); view.zoom = new_zoom; @@ -6687,6 +6703,23 @@ mod tests { } } + #[test] + fn wheel_anchor_is_the_pointer_over_the_image_and_the_nearest_image_point_off_it() { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + for (rotation, flip) in [(0.0, false), (30.0, false), (90.0, true)] { + let xf = ViewXform { rect, zoom: 0.5, center: [400.0, 300.0], flip, rotation, aspect: 1.0 }; + let inside = xf.to_screen(100.0, 200.0); + assert_eq!(wheel_anchor(&xf, inside, [800, 600]), inside); + for (p, want) in [((-300.0, -200.0), [0.0, 0.0]), ((1200.0, 300.0), [800.0, 300.0]), ((400.0, 900.0), [400.0, 600.0])] { + let a = xf.to_doc(wheel_anchor(&xf, xf.to_screen(p.0, p.1), [800, 600])); + assert!((a[0] - want[0]).abs() < 0.01 && (a[1] - want[1]).abs() < 0.01, "rot {rotation}: {p:?} -> {a:?}"); + } + } + let p = pos2(5.0, 5.0); + let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0, aspect: 1.0 }; + assert_eq!(wheel_anchor(&xf, p, [0, 0]), p, "no document: the pointer"); + } + #[test] fn zoom_about_centres_the_clicked_point_with_the_preference() { let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index d78dbedbad..bb87d477e0 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -707,3 +707,62 @@ fn escape_with_targa_options_open_does_not_deselect() { h.run_steps(2); assert!(h.state().session.active().unwrap().doc.selection.is_some(), "Esc belongs to Targa Options"); } + +/// How much of the 400 × 300 document is on screen: the visible document span on each axis, +/// clipped to the image (0 when the image has left the view on that axis). +fn visible_image(h: &Harness<'static, PhotocraftApp>) -> [f32; 2] { + let r = h.state().last_canvas_rect; + let (a, b) = (doc_at(h, r.min), doc_at(h, r.max)); + [(b[0].min(400.0) - a[0].max(0.0)).max(0.0), (b[1].min(300.0) - a[1].max(0.0)).max(0.0)] +} + +#[test] +fn wheel_zoom_over_the_pasteboard_keeps_the_image_in_view() { + // #3720: zooming in with the pointer on the gray pasteboard anchored on the pointer, so a few + // notches pushed the image out of the window. The anchor is now the nearest point of the image. + for (zoom_with_wheel, m) in [(true, Modifiers::NONE), (false, Modifiers::ALT)] { + let mut h = harness(); + h.state_mut().session.edit_prefs(|p| p.general.zoom_with_scroll_wheel = zoom_with_wheel); + let r = h.state().last_canvas_rect; + let p = pos2(r.left() + 20.0, r.top() + 20.0); + let d = doc_at(&h, p); + assert!(d[0] < 0.0 && d[1] < 0.0, "the pointer starts on the pasteboard: {d:?}"); + h.hover_at(p); + h.run_steps(2); + let z0 = zoom(&h); + for _ in 0..20 { + wheel(&h, 1.0, m); + h.run_steps(3); + } + h.run_steps(20); + assert!(zoom(&h) > z0 * 5.0, "the notches zoomed in: {z0} -> {}", zoom(&h)); + let v = visible_image(&h); + assert!(v[0] > 0.0 && v[1] > 0.0, "zoom_with_wheel {zoom_with_wheel}: the image left the view {v:?}"); + // The image's corner nearest the pointer is the anchor: it stays where it was on screen. + let c = doc_at(&h, pos2(r.left() + 20.0, r.top() + 20.0)); + assert!(c[0] < 0.0 && c[1] < 0.0, "still pasteboard under the pointer, the corner held: {c:?}"); + // A pinch from there behaves the same. + h.event(egui::Event::Zoom(4.0)); + h.run_steps(3); + let v = visible_image(&h); + assert!(v[0] > 0.0 && v[1] > 0.0, "pinch: the image left the view {v:?}"); + } +} + +#[test] +fn wheel_zoom_over_the_image_still_anchors_on_the_pointer() { + let mut h = harness(); + let r = h.state().last_canvas_rect; + let p = pos2(r.center().x - 150.0, r.center().y - 100.0); + h.hover_at(p); + h.run_steps(2); + let d0 = doc_at(&h, p); + assert!(d0[0] > 0.0 && d0[1] > 0.0); + for _ in 0..5 { + wheel(&h, 1.0, Modifiers::ALT); + h.run_steps(3); + } + h.run_steps(20); + let d1 = doc_at(&h, p); + assert!((d1[0] - d0[0]).abs() < 0.1 && (d1[1] - d0[1]).abs() < 0.1, "{d0:?} -> {d1:?}"); +} From 403e3210343a0c25ea4a935df48655969ebf6675 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 10:33:25 +0200 Subject: [PATCH 035/523] Select: open the Save Selection and Load Selection dialogs (#3738) The menu items ran the engine commands with no params: Save Selection silently saved into a new alpha channel and Load Selection failed with "missing `channel`". The form dialogs already existed in view_cmds but the two ids were missing from wraps(), so they were never shown. Load Selection now only offers the layer mask when the active layer has one. Fixes #3713 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/view_cmds.rs | 12 ++++++- crates/ui-egui/src/view_cmds/tests.rs | 52 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index abedae867a..7f8d4024c9 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -459,6 +459,8 @@ fn wraps(id: &str) -> bool { | "type.pasteLoremIpsum" | "image.applyImage" | "image.calculations" + | "select.saveSelection" + | "select.loadSelection" ) } @@ -1118,8 +1120,16 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog( app, diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index bd91465706..4a957d3db6 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -291,3 +291,55 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() { assert_eq!(direct["layerCount"], 1); assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3")); } + +#[test] +fn save_selection_opens_its_dialog_and_saves_on_confirm() { + // #3713: Select › Save Selection… asks for the channel, name and operation first. + let (mut app, ctx) = app_with(1); + app.run("select.all", json!({})).unwrap(); + let revision = app.session.active().unwrap().revision; + let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().expect("Save Selection opens a dialog"); + assert_eq!(app.session.active().unwrap().revision, revision, "saved before confirmation"); + assert!(app.session.active().unwrap().doc.channels.is_empty()); + let fields = &mut app.ui.dialog_mut(dialog).unwrap().fields; + assert_eq!(fields["__command"], "select.saveSelection"); + assert_eq!(fields["channel"], "new"); + assert_eq!(fields["__choices"]["operation"], json!(["new", "replace", "add", "subtract", "intersect"])); + fields.insert("name".into(), json!("Keep")); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let names: Vec<_> = app.session.active().unwrap().doc.channels.iter().map(|c| c.name.clone()).collect(); + assert_eq!(names, ["Keep"]); + // A second save lists the new channel as a destination. + let dialog = menu(&mut app, &ctx, "select.saveSelection", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["__choices"]["channel"], json!(["new", "Keep"])); +} + +#[test] +fn load_selection_opens_its_dialog_and_loads_on_confirm() { + // #3713: Select › Load Selection… used to fail with "missing `channel`". + let (mut app, ctx) = app_with(1); + app.run("select.all", json!({})).unwrap(); + app.run("select.saveSelection", json!({"name": "Keep"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().expect("Load Selection opens a dialog"); + assert!(app.session.active().unwrap().doc.selection.is_none(), "loaded before confirmation"); + let fields = &app.ui.dialog_mut(dialog).unwrap().fields; + assert_eq!(fields["__command"], "select.loadSelection"); + assert_eq!(fields["channel"], "Keep"); + assert_eq!(fields["invert"], false); + assert_eq!(fields["operation"], "new"); + let channels = fields["__choices"]["channel"].as_array().unwrap(); + assert!(channels.contains(&json!("Keep")) && channels.contains(&json!("transparency"))); + // The layer has no mask, so its mask is not offered. + assert!(!channels.contains(&json!("mask")), "{channels:?}"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} + +#[test] +fn load_selection_without_alpha_channels_offers_the_composite() { + let (mut app, ctx) = app_with(1); + let dialog = menu(&mut app, &ctx, "select.loadSelection", json!({})).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); + crate::dialogs::confirm(&mut app, dialog).unwrap(); +} From 986f68f969b7ee9e376b8f2eb2db421d5e55c485 Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:36 +0200 Subject: [PATCH 036/523] Toolbar: the colour chips sit right under the tools again (#3745) Since the shared docking migration (#3097) the tool column's scroll area filled the toolbar's whole height, so a window taller than the tools pushed the colour chips, Quick Mask and Screen Mode to the bottom. Photoshop keeps them right under the last tool. The scroll area now shrinks to its tools and only a column taller than the window scrolls, with the chips kept at the bottom as before. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/panels.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 4bda4fcdef..696e5b38c0 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -128,9 +128,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + chips_height(t.pro, t.round_chips, t.round_chips && double) + if t.pro { 8.0 + if double { bx } else { bx * 2.0 + 3.0 } } else { 0.0 } + 6.0; + // As tall as the tools, so the colour chips sit right under them (Photoshop); a column + // taller than the window scrolls and keeps the chips at the bottom. egui::ScrollArea::vertical() .id_salt("toolbar-columns") - .auto_shrink([false, false]) + .auto_shrink([false, true]) .max_height((ui.available_height() - footer).max(0.0)) .min_scrolled_height(0.0) .show(ui, |ui| { @@ -5234,8 +5236,12 @@ mod compact_visual_regressions { } fn toolbar_harness(kind: theme::ThemeKind, scale: f32) -> egui_kittest::Harness<'static, PhotocraftApp> { + toolbar_harness_sized(kind, scale, 360.0) + } + + fn toolbar_harness_sized(kind: theme::ThemeKind, scale: f32, height: f32) -> egui_kittest::Harness<'static, PhotocraftApp> { let mut ready = false; - let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 360.0)).with_pixels_per_point(scale).build_ui_state( + let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, height)).with_pixels_per_point(scale).build_ui_state( move |ui, app: &mut PhotocraftApp| { if !ready { egui_extras::install_image_loaders(ui.ctx()); @@ -5304,6 +5310,18 @@ mod compact_visual_regressions { } } + /// A window taller than the tools keeps the colour chips right under the last tool, as + /// Photoshop does, instead of at the bottom of the toolbar. + #[test] + fn tall_toolbar_keeps_the_colour_chips_under_the_tools() { + for kind in theme::ThemeKind::ALL { + let harness = toolbar_harness_sized(kind, 1.0, 1600.0); + let last = harness.get_by_label("Zoom Tool (Z)").rect(); + let swap = harness.get_by_label("Swap colours (X)").rect(); + assert!(swap.top() - last.bottom() < 80.0, "{kind:?}: the chips {swap:?} sit far below the last tool {last:?}"); + } + } + #[test] fn capture_compact_visual_fixtures() { let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; From 986f977a010df511d7b57f3440f467beeead14bd Mon Sep 17 00:00:00 2001 From: Giuseppe <6475246+peppepol@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:48 +0200 Subject: [PATCH 037/523] Fix: video frames to layers working using folder dialog request (#3513) (#3718) --- apps/photocraft-web/src/web.rs | 2 ++ apps/photocraft/src/services.rs | 7 +++++++ crates/ui-egui/src/file_dialog.rs | 21 ++++++++++++++++++-- crates/ui-egui/src/file_dialog/tests.rs | 24 +++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 3 +++ crates/ui-egui/tests/export_as_scaling.rs | 1 + 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 4f674560e8..438e0799b5 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -382,6 +382,8 @@ fn services(inbox: Inbox) -> Services { }; reply.send(answer); }), + // Browser folder handles cannot be represented as filesystem paths for engine commands. + FileDialogRequest::Folder { .. } => reply.send(None), // No save dialog on the web: the suggested name becomes the download name. FileDialogRequest::Save { suggested } => { let name = std::path::Path::new(&suggested).file_name().map_or_else(|| suggested.clone(), |n| n.to_string_lossy().to_string()); diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 5df122c4dc..8342baa257 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -115,6 +115,13 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, Box::pin(async move { picked.await.map(|file| FileDialogAnswer::Paths(vec![path_of(&file)])) }) } } + FileDialogRequest::Folder { initial_dir } => { + if let Some(dir) = initial_dir { + dialog = dialog.set_directory(dir); + } + let picked = dialog.pick_folder(); + Box::pin(async move { picked.await.map(|folder| FileDialogAnswer::FolderTo(path_of(&folder))) }) + } FileDialogRequest::Save { suggested } => { for (name, exts) in save_filters(&suggested) { dialog = dialog.add_filter(name, &exts); diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index a1f6e5240b..30b650c9b3 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -21,12 +21,14 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::file_open::display_name; -/// What a file dialog asks the user for. +/// What a file or folder dialog asks the user for. #[derive(Clone, Debug, PartialEq, Eq)] pub enum FileDialogRequest { /// Files to read: several for File › Open, one for a command that reads a file (Place, /// scripts, notes, presets). Starts in `initial_dir` (the last-used folder, UI-217-3). Open { multiple: bool, initial_dir: Option, extensions: Option> }, + /// Which folder to read from. Starts in `initial_dir` when available. + Folder { initial_dir: Option }, /// Where to write, starting from `suggested` (a file name, or the document's own path). Save { suggested: String }, } @@ -42,6 +44,8 @@ pub enum FileDialogAnswer { ContentsMany(Vec<(String, Vec)>), /// The path to save to (desktop), or the download's name (web). SaveTo(String), + /// The selected folder's path (desktop). + FolderTo(String), /// No dialog could be shown (on Linux: no desktop portal and no zenity, #3354); the message /// says what to install. Reported as an error rather than taken for Cancel. Unavailable(String), @@ -88,7 +92,7 @@ impl PhotocraftApp { } /// Show a file dialog; `then` continues with the user's choice on a later frame (see the - /// module docs). Returns at once with `{"fileDialog": "open" | "save"}`; `Err` without a + /// module docs). Returns at once with `{"fileDialog": "open" | "folder" | "save"}`; `Err` without a /// dialog service ("cancelled", as before there were async dialogs) or while one is open. pub(crate) fn ask_file( &mut self, @@ -103,6 +107,7 @@ impl PhotocraftApp { } let kind = match request { FileDialogRequest::Open { .. } => "open", + FileDialogRequest::Folder { .. } => "folder", FileDialogRequest::Save { .. } => "save", }; let then: Then = Box::new(move |app, answer| match answer { @@ -114,6 +119,16 @@ impl PhotocraftApp { Ok(json!({ "fileDialog": kind })) } + /// Ask the user to choose a folder, starting in the folder of the most recent file when known. + /// `then` receives the selected path when the dialog is answered. + pub(crate) fn ask_folder(&mut self, then: impl FnOnce(&mut Self, String) -> Result + 'static) -> Result { + let initial_dir = last_used_dir(&self.ui.recent_files); + self.ask_file(FileDialogRequest::Folder { initial_dir }, move |app, answer| match answer { + FileDialogAnswer::FolderTo(path) => then(app, path), + _ => Err(UNEXPECTED.into()), + }) + } + /// Ask where to save: `then` gets the chosen path, with a lowercase extension when /// Preferences ▸ File Handling › Lowercase Extension is on (its default). /// @@ -193,6 +208,7 @@ impl PhotocraftApp { } FileDialogAnswer::Contents(name, bytes) => app.open_contents(vec![(name, bytes)]), FileDialogAnswer::ContentsMany(files) => app.open_contents(files), + FileDialogAnswer::FolderTo(_) => return Err(UNEXPECTED.into()), FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => return Err(UNEXPECTED.into()), } Ok(Value::Null) @@ -290,6 +306,7 @@ fn read_picked(answer: FileDialogAnswer) -> Result<(String, Vec), String> { let bytes = photocraft_format::read_file(std::path::Path::new(&path)).map_err(|e| format!("{}: {e}", display_name(&path)))?; Ok((path, bytes)) } + FileDialogAnswer::FolderTo(_) => Err(UNEXPECTED.into()), FileDialogAnswer::SaveTo(_) | FileDialogAnswer::Unavailable(_) => Err(UNEXPECTED.into()), } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 59c8c49731..e985a486c7 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -175,6 +175,30 @@ fn open_starts_in_the_last_used_folder() { ); } +#[test] +fn ask_folder_uses_the_async_dialog_and_returns_the_selected_path() { + let (mut app, open, _) = app(); + let ctx = egui::Context::default(); + app.ui.recent_files = vec!["/pics/cat.psd".into()]; + let selected = Rc::new(RefCell::new(None)); + let result = selected.clone(); + + assert_eq!( + app.ask_folder(move |_, path| { + *result.borrow_mut() = Some(path); + Ok(Value::Null) + }) + .unwrap(), + json!({"fileDialog": "folder"}) + ); + app.poll_file_dialog(&ctx, None); + assert!(matches!(open.borrow().as_slice(), [(FileDialogRequest::Folder { initial_dir: Some(dir) }, _)] if dir == "/pics")); + + answer(&open, Some(FileDialogAnswer::FolderTo("/pics/frames".into()))); + app.poll_file_dialog(&ctx, None); + assert_eq!(selected.borrow().as_deref(), Some("/pics/frames")); +} + #[test] fn filtered_picker_requests_only_the_given_extensions() { let (mut app, open, _) = app(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1817cf1c42..690bf6be5a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -292,6 +292,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: app.open_dialog_file() } } + "file.import.videoFramesToLayers" if params.get("path").and_then(Value::as_str).is_none() => { + app.ask_folder(|app, path| app.run("file.import.videoFramesToLayers", json!({"path": path})).map_err(|e| e.to_string())) + } "file.save" => { // Writes back only to a layered file; a flat one goes through Save As. let tga_bits = crate::targa_options_ui::bits_param(¶ms, id)?; diff --git a/crates/ui-egui/tests/export_as_scaling.rs b/crates/ui-egui/tests/export_as_scaling.rs index a05edbc6af..f1c9cba32c 100644 --- a/crates/ui-egui/tests/export_as_scaling.rs +++ b/crates/ui-egui/tests/export_as_scaling.rs @@ -24,6 +24,7 @@ fn harness() -> Option<(Harness, Writes)> { file_dialog: Some(Box::new(|request, _parent, reply| { reply.send(match request { FileDialogRequest::Save { suggested } => Some(FileDialogAnswer::SaveTo(suggested)), + FileDialogRequest::Folder { .. } => None, FileDialogRequest::Open { .. } => None, }) })), From 62f4b9d4534bdc14287e037d04c0c252964dd9b4 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:33:54 +0200 Subject: [PATCH 038/523] Menus: keep clear of the taskbar when Wayland hides the window position (#3737) (#3739) * test(ui): scaffold reported menu viewport coverage (#3737) * chore: opt FredApps into public profile credit * Menus keep clear of the taskbar when the window position is unknown (Wayland) (#3737) On Wayland winit can't report a window's position, so egui has no inner_rect/outer_rect, only the monitor size. work_area::visible_part then returned the whole window, and fit_window never maximized an oversized window, so long menus (Layer, View, Window...) were sized to the full window and ran under a bottom panel. Without a position, cut the window to the monitor size less the taskbar (the most that can be visible wherever the window is), and use the content size as the outer size for fit_window. Replaces the scaffold test with one that fails on main. --------- Signed-off-by: FredApps <153173854+FredApps@users.noreply.github.com> --- contributors/people.toml | 3 ++ crates/ui-egui/src/menu_nav_tests.rs | 46 ++++++++++++++++++++++ crates/ui-egui/src/work_area.rs | 59 ++++++++++++++++++++++++---- 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 4765d92ac2..e182a21144 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -114,3 +114,6 @@ sync_github_name = true [people.Losminek] sync_github_name = true + +[people.FredApps] +sync_github_name = true diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index fa610c3c53..2dafc1c5c1 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -11,6 +11,52 @@ const DISPLAYS: [(f32, f32, f32); 4] = [(1280.0, 720.0, 1.0), (1024.0, 600.0, 1. /// The tallest menus. const LONGEST: [&str; 4] = ["Filter", "Layer", "Image", "Edit"]; +/// #3737: on Wayland egui knows the monitor's size but never where the window is (no +/// `inner_rect`). A window taller than the monitor less its taskbar or panel runs under it, so +/// Layer, View and Window (and every other long menu) must end above it and scroll to their last +/// row, at 1× and 1.5×. +#[test] +fn reported_menus_stay_above_the_panel_when_the_window_position_is_unknown() { + for scale in [1.0, 1.5] { + for top in ["Layer", "View", "Window", "Filter", "Image", "Edit"] { + let mut h = harness((1366.0 * scale, 740.0 * scale, scale)); + let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + v.monitor_size = Some(egui::vec2(1366.0, 768.0)); + v.inner_rect = None; + v.outer_rect = None; + v.maximized = Some(false); + h.run_steps(3); + let bottom = 768.0 - crate::work_area::TASKBAR; + let what = format!("{scale}x {top}"); + open(&mut h, top); + for r in popups(&h) { + assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})"); + } + check_bar_clear(&h, &what); + let last = rows(&h, 0).len() - 1; + let in_view = |h: &Harness<'static, PhotocraftApp>| { + let nav = Nav::current(&h.ctx); + nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5 + }; + let view = Nav::current(&h.ctx).views[0]; + h.hover_at(view.center()); + for _ in 0..80 { + if in_view(&h) { + break; + } + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -60.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(2); + } + assert!(in_view(&h), "{what}: the wheel reaches the last row, above the panel"); + } + } +} + fn app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); diff --git a/crates/ui-egui/src/work_area.rs b/crates/ui-egui/src/work_area.rs index 5b4411140a..0ac79ea6a2 100644 --- a/crates/ui-egui/src/work_area.rs +++ b/crates/ui-egui/src/work_area.rs @@ -10,7 +10,7 @@ //! [`fit_window`] also fixes the cause at startup: a window that doesn't fit its monitor is //! maximized, which puts it in the work area (Photoshop opens maximized on small displays). -use egui::{Rect, Vec2, pos2}; +use egui::{Rect, Vec2, pos2, vec2}; /// Room kept free at the monitor's bottom edge for a taskbar or Dock egui can't see (the Windows /// taskbar is 40–48 pt tall; a bottom Dock about as much). @@ -22,9 +22,19 @@ const MIN_VISIBLE: f32 = 160.0; /// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen` /// is true for a maximized or full-screen window (already inside the work area). pub fn visible_part(content: Rect, monitor: Option, inner: Option, fills_screen: bool) -> Rect { - let (Some(m), Some(inner)) = (monitor, inner) else { return content }; - let finite = m.x.is_finite() && m.y.is_finite() && inner.min.is_finite() && inner.max.is_finite(); - if fills_screen || !finite || m.x < 1.0 || m.y < 1.0 { + let Some(m) = monitor else { return content }; + if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + return content; + } + // #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has + // no `inner_rect` there, only the monitor's size. A window bigger than the monitor less the + // taskbar still runs under it wherever it is: at best its content starts at the monitor's top, + // so at most that much of it can be seen. + let Some(inner) = inner else { + let room = vec2(m.x, m.y - TASKBAR); + return trusted(content, Rect::from_min_size(content.min, room)); + }; + if !inner.min.is_finite() || !inner.max.is_finite() { return content; } // Only the monitor's size is known, not where it is on the desktop: assume monitors tile the @@ -36,7 +46,12 @@ pub fn visible_part(content: Rect, monitor: Option, inner: Option, f // Desktop → window coordinates. let shift = content.min - inner.min; let local = usable.translate(shift); - let visible = content.intersect(local); + trusted(content, local) +} + +/// `content` cut to `usable`, unless that leaves too little to be a sensible guess. +fn trusted(content: Rect, usable: Rect) -> Rect { + let visible = content.intersect(usable); if !visible.is_positive() || visible.height() < MIN_VISIBLE.min(content.height()) || visible.width() < MIN_VISIBLE.min(content.width()) { return content; } @@ -68,11 +83,15 @@ pub fn fit_window(ctx: &egui::Context) -> bool { if ctx.data(|d| d.get_temp::(fitted_id())).is_some() { return false; } + let content = ctx.content_rect(); let (monitor, outer, maximized, fullscreen) = ctx.input(|i| { let v = i.viewport(); (v.monitor_size, v.outer_rect, v.maximized, v.fullscreen) }); - let (Some(m), Some(outer)) = (monitor, outer) else { return false }; + // On Wayland the window's position, and so its outer rect, is never known (#3737): its content + // size is the least the window takes up. + let outer = outer.unwrap_or(content); + let Some(m) = monitor else { return false }; // macOS keeps new windows inside the screen's visible frame itself. if cfg!(target_os = "macos") { return false; @@ -117,7 +136,6 @@ mod tests { let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area"); assert_eq!(visible_part(content, None, Some(inner), false), content); - assert_eq!(visible_part(content, Some(MONITOR), None, false), content); assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content); assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content); assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content); @@ -126,6 +144,20 @@ mod tests { assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); } + #[test] + fn an_unknown_window_position_still_keeps_clear_of_the_taskbar() { + // #3737, Wayland: only the monitor's size is known. A window taller than the monitor less + // the taskbar can be seen at most down to there, wherever it is. + let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0)); + assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0)); + assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + // One that fits, or a maximized one, is all visible. + let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)); + assert_eq!(visible_part(small, Some(MONITOR), None, false), small); + assert_eq!(visible_part(content, Some(MONITOR), None, true), content); + } + fn frame(ctx: &egui::Context, monitor: Option, outer: Option, maximized: Option) -> Vec { let mut input = egui::RawInput::default(); if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) { @@ -158,6 +190,19 @@ mod tests { ); let ctx = egui::Context::default(); assert!(!frame(&ctx, Some(MONITOR), big, Some(true)).contains(&egui::ViewportCommand::Maximized(true))); + // Wayland: no outer rect, but a content area bigger than the monitor is maximized too (#3737). + let ctx = egui::Context::default(); + let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0))), ..Default::default() }; + if let Some(v) = input.viewports.get_mut(&egui::ViewportId::ROOT) { + v.monitor_size = Some(MONITOR); + v.maximized = Some(false); + } + let mut out = ctx.run_ui(input, |ui| { + fit_window(ui.ctx()); + }); + out.textures_delta.clear(); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + assert_eq!(cmds.contains(&egui::ViewportCommand::Maximized(true)), !cfg!(target_os = "macos")); assert!(fits(vec2(1366.0, 720.0), MONITOR) && !fits(vec2(1366.0, 740.0), MONITOR) && !fits(vec2(1440.0, 700.0), MONITOR)); } } From dbf9d0d9981751b9c909a1beb4049aeb4a4ae97c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Mon, 12 Oct 2026 10:38:03 +0200 Subject: [PATCH 039/523] i18n(it): translate the dynamic labels the Italian catalog was missing (#3742) Adds the 1,287 rows the other catalogs have and Italian lacked (filter and adjustment parameters and choices, Camera Raw, Lighting Effects, History and Layers labels, metadata, dialogs), fixes mistranslations in existing rows (Facet, Gradient stops, Save As Preset, Canvas, Spot Color) and uses one term per concept (Predefinito, Traccia, clic, scorciatoia, filmato). Factors the Korean generated-label check into missing_generated_filter_labels and runs it for Italian too. Adds docs/localization-it.md and updates localization-parity. --- crates/ui-egui/src/filter_dialog.rs | 20 +- crates/ui-egui/src/i18n/it.tsv | 1426 +++++++++++++++++++++++++-- docs/localization-it.md | 62 ++ docs/localization-parity.md | 8 +- 4 files changed, 1442 insertions(+), 74 deletions(-) create mode 100644 docs/localization-it.md diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 13ded76048..997bd56854 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1093,12 +1093,12 @@ mod tests { assert!(!has_dialog("filter.stylize.findEdges")); } - #[test] - fn korean_covers_generated_filter_options_and_gallery_names() { - let ko = crate::i18n::lang_from_tag("ko-KR").unwrap(); + /// Generated filter and adjustment labels (param names, symbolic choices) and gallery + /// names that `lang` has no catalog row for. + fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet { let mut missing = std::collections::BTreeSet::new(); let mut check = |s: String| { - if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) { + if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) { missing.insert(s); } }; @@ -1125,9 +1125,21 @@ mod tests { for cat in photocraft_algo::GALLERY_CATEGORIES { check(cat.to_string()); } + missing + } + + #[test] + fn korean_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("ko-KR").unwrap()); assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}"); } + #[test] + fn italian_covers_generated_filter_options_and_gallery_names() { + let missing = missing_generated_filter_labels(crate::i18n::lang_from_tag("it-IT").unwrap()); + assert!(missing.is_empty(), "missing Italian dynamic labels: {missing:#?}"); + } + /// The Select menu's generated dialogs (Select and Mask, Modify ▸ …) show their /// params, choices and title in every UI language, not just Korean. #[test] diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ccd6ce7d24..ad8dc89f5f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -7,7 +7,7 @@ # lines starting with # and blank lines are ignored. # # Clean-room: every entry was written from the plain meaning of the English label using ordinary -# Italian computing vocabulary (livello, pennello, selezione, tela ...). No proprietary localisation +# Italian computing vocabulary (livello, pennello, selezione, quadro ...). No proprietary localisation # resource, string table or product UI was consulted or copied; add entries the same way. # Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin # letters; the trailing … of dialog commands is kept. @@ -26,7 +26,7 @@ Center Columns Centra colonne Clear Existing Guides Elimina guide esistenti Invalid guide layout Layout delle guide non valido - Leave empty for automatic size Lasciare vuoto per la dimensione automatica + Leave empty for automatic size Lascia vuoto per la dimensione automatica 5 Columns, 3 Rows 5 colonne, 3 righe 8 Columns 8 colonne 12 Columns 12 colonne @@ -53,7 +53,7 @@ Open… Apri… Open As… Apri come… Open EXR Parts… Apri parti EXR… - Add beauty precomp from the AOVs Aggiungi la precomposizione della bellezza dagli AOV + Add beauty precomp from the AOVs Aggiungi la precomposizione del passaggio beauty dagli AOV Both Entrambe Left Sinistra Right Destra @@ -83,12 +83,12 @@ Color Lookup Tables… Tabelle ricerca colore… Data Sets as Files… Set di dati come file… Paths to Illustrator… Tracciati in Illustrator… - Render Video… Rendering video… + Render Video… Esegui rendering video… Generate Genera Image Assets Risorse immagine Place Embedded… Inserisci incorporato… Place Linked… Inserisci collegato… - Package… Pacchetto… + Package… Crea pacchetto… Automate Automatizza Batch… Batch… Create Droplet… Crea droplet… @@ -129,7 +129,7 @@ Paste Lorem Ipsum Incolla Lorem Ipsum Clear Cancella Search… Cerca… - Check Spelling… Controllo ortografico… + Check Spelling… Controlla ortografia… Find and Replace Text… Trova e sostituisci testo… Fill… Riempi… Stroke… Traccia… @@ -196,7 +196,7 @@ Type… Testo… Enhanced Controls… Controlli avanzati… Camera Raw… Camera Raw… - Raw Defaults… Default Raw… + Raw Defaults… Predefiniti Raw… Integrations… Integrazioni… AI Integrations… Integrazioni IA… Install Plug-in… Installa plug-in… @@ -368,8 +368,8 @@ Summation Somma Variance Varianza Video Layers Livelli video - Interpret Footage… Interpreta metraggio… - Replace Footage… Sostituisci metraggio… + Interpret Footage… Interpreta filmato… + Replace Footage… Sostituisci filmato… Reload Frame Ricarica fotogramma Show Altered Video Mostra video modificato Restore Frame Ripristina fotogramma @@ -558,7 +558,7 @@ Pixelate Pixellatura Color Halftone… Mezzetinte a colori… Crystallize… Cristallizza… - Facet Frattale + Facet Sfaccetta Fragment Frammento Mezzotint… Mezzatinta… Mosaic… Mosaico… @@ -772,7 +772,7 @@ Split Warp Horizontally Dividi altera orizzontalmente Split Warp Vertically Dividi altera verticalmente Statistics… Statistiche… - Refine Strokes Tratti di perfezionamento + Refine Strokes Tracce di perfezionamento @plural {n} item|{n} items {n} elemento|{n} elementi Auto Automatico @@ -809,7 +809,7 @@ All Caps Tutto maiuscolo All Slices Tutte le sezioni All User Slices Tutte le sezioni utente - All shortcuts reset to PhotoCraft defaults. Tutte le scorciatoie ripristinate ai default di PhotoCraft. + All shortcuts reset to PhotoCraft defaults. Tutte le scorciatoie ripristinate ai predefiniti di PhotoCraft. Alt Alt Always use pressure for opacity Usa sempre pressione per opacità Always use pressure for size Usa sempre pressione per dimensione @@ -882,7 +882,7 @@ Canvas extension color: Colore estensione quadro: Capture Acquisisci Capture the visibility, position and style of every layer with + below, then switch between versions. Acquisisci visibilità, posizione e stile di ogni livello con + qui sotto, poi passa da una versione all'altra. - Catch-up on Stroke End Recupero a fine tratto + Catch-up on Stroke End Recupero a fine traccia Center Centro Center Crop Marks Crocini di ritaglio centrati Center text Centra testo @@ -893,21 +893,21 @@ Channel Mixer Miscelatore canali Channel: Canale: Check All Layers Controlla tutti i livelli - Check Spelling Controllo ortografico + Check Spelling Controlla ortografia Clarity Chiarezza Clear All Cancella tutto Clear Override Rimuovi eccezione Clear Samples Cancella campioni - Click sets foreground · right-click sets background Click imposta primo piano · click destro imposta sfondo - Click the first point or press {key} to close · Esc cancels Fai click sul primo punto o premi {key} per chiudere · Esc annulla - Click to add a point · drag off or {key}-click or Delete to remove Click per aggiungere un punto · trascina fuori, {key}-click o Canc per rimuovere - Click to apply, {key}-click to clear overrides, double-click for Style Options Click per applicare, {key}-click per rimuovere eccezioni, doppio click per Opzioni stile - Click to edit the gradient Click per modificare la sfumatura - Click to restore the current colour Click per ripristinare il colore corrente - Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Click per ingrandire · {key}-click per ridurre · trascina dx/sx per zoom - Click with the Note tool to add a note. Fai click con lo strumento Nota per aggiungere una nota. - Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Click: angolo · Trascina: morbido · Click primo punto: chiudi · {key} termina · Esc annulla - Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Click: angolo · Trascina: morbido · {alt}+trascina: spezza maniglia · Click o trascina sul primo punto: chiudi · {key} termina · Esc annulla + Click sets foreground · right-click sets background Clic imposta primo piano · clic destro imposta sfondo + Click the first point or press {key} to close · Esc cancels Fai clic sul primo punto o premi {key} per chiudere · Esc annulla + Click to add a point · drag off or {key}-click or Delete to remove Clic per aggiungere un punto · trascina fuori, {key}-clic o Canc per rimuovere + Click to apply, {key}-click to clear overrides, double-click for Style Options Clic per applicare, {key}-clic per rimuovere eccezioni, doppio clic per Opzioni stile + Click to edit the gradient Clic per modificare la sfumatura + Click to restore the current colour Clic per ripristinare il colore corrente + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Clic per ingrandire · {key}-clic per ridurre · trascina dx/sx per zoom + Click with the Note tool to add a note. Fai clic con lo strumento Nota per aggiungere una nota. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Clic: angolo · Trascina: morbido · Clic primo punto: chiudi · {key} termina · Esc annulla + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Clic: angolo · Trascina: morbido · {alt}+trascina: spezza maniglia · Clic o trascina sul primo punto: chiudi · {key} termina · Esc annulla Clip to layer below Ritaglia al livello sotto Clipped Ritagliato Close Style Options Chiudi opzioni stile @@ -988,7 +988,7 @@ Default Predefinito Default colours (D) Colori predefiniti (D) Default printer Stampante predefinita - Default restored. Predefiniti ripristinati. + Default restored. Predefinito ripristinato. Define Definisci Define at least one variable first. Definisci almeno una variabile prima. Dehaze Elimina foschia @@ -1043,7 +1043,7 @@ Done Fatto Dots Punti Drag a rectangle around the object Trascina un rettangolo attorno all'oggetto - Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; click destro per eliminare. + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Trascina sull'immagine per aggiungere un vincolo; Maiusc per orizz/vert; clic destro per eliminare. Drag to move the active shape's path or the Work Path Trascina per spostare il tracciato forma attiva o il Tracciato di lavoro Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Clic: seleziona punto · Trascina: sposta punto, maniglia o segmento · {shift}-clic: aggiungi · {alt}-clic: intero sottotracciato Draw with the Pen tool (P) or make a work path from a selection. Disegna con Penna (P) o crea un tracciato di lavoro da una selezione. @@ -1270,7 +1270,7 @@ Open a document to use the timeline. Apri un documento per usare la timeline. Optical Ottico Optimized Ottimizzato - Option-click to define a source point to clone from Opzione-click per definire un punto sorgente da clonare + Option-click to define a source point to clone from Opzione-clic per definire un punto sorgente da clonare Original Originale Output Channel: Canale output: Output Levels: Livelli output: @@ -1371,17 +1371,17 @@ Rename… Rinomina… Resample Ricampiona Reset All to Defaults Ripristina tutto ai predefiniti - Import Shortcuts… Importa scelte rapide… - Load a keyboard shortcut set saved as .kys Carica un set di scelte rapide da tastiera salvato come .kys - Imported {n} shortcuts from {file}. Click OK to keep them. Importate {n} scelte rapide da {file}. Fai clic su OK per conservarle. + Import Shortcuts… Importa scorciatoie… + Load a keyboard shortcut set saved as .kys Carica un set di scorciatoie da tastiera salvato come .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Importate {n} scorciatoie da {file}. Fai clic su OK per conservarle. {n} commands are not in PhotoCraft. {n} comandi non esistono in PhotoCraft. - Keyboard shortcuts imported Scelte rapide da tastiera importate - Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Il tuo set di scelte rapide da tastiera {set} è in uso qui: {n} scelte rapide differiscono dai valori predefiniti. Modifica › Scelte rapide da tastiera le mostra; Ripristina tutto ai valori predefiniti annulla l'importazione. - Import keyboard shortcuts? Importare le scelte rapide da tastiera? - PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft ha trovato su questo computer il tuo set di scelte rapide da tastiera {set} di un altro editor di immagini: {n} delle sue scelte rapide differiscono da quelle di PhotoCraft. Importarlo? - Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Modifica › Scelte rapide da tastiera › Ripristina tutto ai valori predefiniti annulla un'importazione. + Keyboard shortcuts imported Scorciatoie da tastiera importate + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Il tuo set di scorciatoie da tastiera {set} è in uso qui: {n} scorciatoie differiscono dai predefiniti. Modifica › Scorciatoie da tastiera le mostra; Ripristina tutto ai predefiniti annulla l'importazione. + Import keyboard shortcuts? Importare le scorciatoie da tastiera? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft ha trovato su questo computer il tuo set di scorciatoie da tastiera {set} di un altro editor di immagini: {n} delle sue scorciatoie differiscono da quelle di PhotoCraft. Importarlo? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Modifica › Scorciatoie da tastiera › Ripristina tutto ai predefiniti annulla un'importazione. Don't Import Non importare - The keyboard shortcut set was not imported: {error} Il set di scelte rapide da tastiera non è stato importato: {error} + The keyboard shortcut set was not imported: {error} Il set di scorciatoie da tastiera non è stato importato: {error} Reset Section Reimposta sezione Reset Transform Reimposta trasformazione Reset to defaults Ripristina predefiniti @@ -1419,7 +1419,7 @@ Search fonts Cerca font Select All Seleziona tutto Select Subject Seleziona soggetto - Select a type layer or click into text to insert glyphs Seleziona un livello testo o fai click nel testo per inserire glifi + Select a type layer or click into text to insert glyphs Seleziona un livello testo o fai clic nel testo per inserire glifi Select text to apply a character style Seleziona testo per applicare uno stile carattere Select: Selezione: Selections Selezioni @@ -1586,8 +1586,8 @@ Centimeters Centimetri Choke Strozza Classic Classico - Click to add text Fai click per aggiungere testo - Click to fill similar colours Fai click per riempire colori simili + Click to add text Fai clic per aggiungere testo + Click to fill similar colours Fai clic per riempire colori simili Clone Stamp Tool Strumento Timbro clone Color Burn Colore brucia Color Dodge Colore scherma @@ -1631,8 +1631,8 @@ Emboss Rilievo Eraser Gomma Eraser Tool Strumento Gomma - ExtraBold Extra grassetto - ExtraLight Extra leggero + ExtraBold Extragrassetto + ExtraLight Extraleggero Eyedropper Tool Strumento Contagocce Fade Dissolvi Fill Opacity Opacità riempimento @@ -1754,7 +1754,7 @@ Scratch Sizes Dimensioni disco virtuale Select Data Points Seleziona punti dati Selection Selezione - SemiBold Semi grassetto + SemiBold Semigrassetto Shadows up to: Ombre fino a: Shape Dynamics Dinamica forma Sharpen Tool Strumento Contrasta @@ -1842,7 +1842,7 @@ Brush preview color Colore anteprima pennello Cache levels Livelli cache Cache tile size Dimensione tile cache - Camera Raw Defaults Default Camera Raw + Camera Raw Defaults Predefiniti Camera Raw Canvas border Bordo quadro Show canvas dots Mostra punti del quadro Canvas color Colore quadro @@ -1863,7 +1863,7 @@ Detailed Dettagliato Disks Dischi Display p3 Display P3 - Double click layer mask launches select and mask Doppio click su maschera livello apre Seleziona e maschera + Double click layer mask launches select and mask Doppio clic su maschera livello apre Seleziona e maschera Drop shadow Ombra esterna Dynamic color sliders Slider colore dinamici East asian Asia orientale @@ -2024,7 +2024,7 @@ Collapse Comprimi Collapse All Groups Comprimi tutti i gruppi Collapse Panel Group Comprimi gruppo pannelli - Collapse group ({key}-click: all groups) Comprimi gruppo ({key}-click: tutti i gruppi) + Collapse group ({key}-click: all groups) Comprimi gruppo ({key}-clic: tutti i gruppi) Create new brush from the current settings Crea nuovo pennello da impostazioni correnti Create new brush from these settings Crea nuovo pennello da queste impostazioni Update the current brush with these settings Aggiorna il pennello attuale con queste impostazioni @@ -2032,9 +2032,9 @@ Drag the arrow to set the angle, the dots to set the roundness Trascina la freccia per impostare l'angolo, i puntini per la rotondità Expand Espandi Expand Panel Group Espandi gruppo pannelli - Expand group ({key}-click: all groups) Espandi gruppo ({key}-click: tutti i gruppi) + Expand group ({key}-click: all groups) Espandi gruppo ({key}-clic: tutti i gruppi) Fade steps Passi dissolvenza - Hide the layer's effects ({key}-click: all layers) Nascondi effetti livello ({key}-click: tutti i livelli) + Hide the layer's effects ({key}-click: all layers) Nascondi effetti livello ({key}-clic: tutti i livelli) Import Brushes… (.abr) Importa pennelli… (.abr) Move Group Down Sposta gruppo giù Move Group Up Sposta gruppo su @@ -2042,10 +2042,10 @@ Restore original size Ripristina dimensione originale Roundness: Rotondità: Search Brushes Cerca pennelli - Show the layer's effects ({key}-click: all layers) Mostra effetti livello ({key}-click: tutti i livelli) + Show the layer's effects ({key}-click: all layers) Mostra effetti livello ({key}-clic: tutti i livelli) Brush picker Selettore pennelli Erase Cancella - Right click with painting tools Click destro con strumenti pittura + Right click with painting tools Clic destro con strumenti pittura # Strings added to the UI after this catalog was first written (rebase onto main, 2026-10). System Info… Info sistema… @@ -2056,11 +2056,11 @@ Graphics Grafica Reset GPU Backend Reimposta backend GPU Applies at next launch. Si applica al prossimo avvio. - Click to sample the foreground colour · {key}-click for background Click per campionare colore primo piano · {key}-click per sfondo - {key}-click to set the source {key}-click per impostare sorgente + Click to sample the foreground colour · {key}-click for background Clic per campionare colore primo piano · {key}-clic per sfondo + {key}-click to set the source {key}-clic per impostare sorgente {key} to subtract {key} per sottrarre - Clone source {n} ({key}-click with Clone Stamp to set) Sorgente clone {n} ({key}-click con Timbro clone per impostare) - Source: not set ({key}-click with Clone Stamp) Sorgente: non impostata ({key}-click con Timbro clone) + Clone source {n} ({key}-click with Clone Stamp to set) Sorgente clone {n} ({key}-clic con Timbro clone per impostare) + Source: not set ({key}-click with Clone Stamp) Sorgente: non impostata ({key}-clic con Timbro clone) Pixel Layer Livello pixel Type Layer Livello testo Shape Layer Livello forma @@ -2086,7 +2086,7 @@ Group name Nome gruppo Brush name Nome pennello Brush Name Nome pennello - Brush Stroke Tratto pennello + Brush Stroke Traccia pennello Brush Tip Punta pennello Off: the speed of the pointer sets the spacing Off: la velocità del puntatore imposta la spaziatura Tilt Scale Scala inclinazione @@ -2111,8 +2111,8 @@ # Gradient Editor window, opened from the options bar's gradient swatch. Gradient Editor Editor sfumatura Gradient picker Selettore sfumatura - Gradient stops Flag sfumatura - Save As Preset Salva come preimpostazione + Gradient stops Punti della sfumatura + Save As Preset Salva come predefinito The gradient the Gradient tool paints with La sfumatura con cui disegna lo strumento Sfumatura Adds the current gradient to the Gradients panel Aggiunge la sfumatura corrente al pannello Sfumature The current gradient (pick one in Window › Gradients) Sfumatura corrente (scegline una in Finestra › Sfumature) @@ -2139,7 +2139,7 @@ OpenGL OpenGL Use tablet pressure Usa pressione tavoletta Vulkan Vulkan - Alt-click to define a source point to clone from Alt-click per definire un punto sorgente da clonare + Alt-click to define a source point to clone from Alt-clic per definire un punto sorgente da clonare No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nessun tracciato da cui creare selezione: disegnane uno con Penna o selezionane uno nel pannello Tracciati. Cancel opening Annulla apertura Cancelled opening {name} Apertura {name} annullata @@ -2194,7 +2194,7 @@ Model Modello F F Artist Artista - Spot Color Colore spot + Spot Color Tinta piatta Roman Normale Bold Italic Grassetto corsivo Justification Giustificazione @@ -2379,7 +2379,7 @@ cameraRaw Amount Quantità Reset Swatches Ripristina campioni Add to Swatches Aggiungi ai campioni Convert to Layers Converti in livelli - Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro + Apply At Canvas Bounds Applica ai limiti del quadro # Shared screen eyedropper and text Color Picker (#1128). Color Picker (Text Color) Selettore colore (Colore del testo) @@ -2459,13 +2459,13 @@ fontWeight Regular Normale fontWeight Medium Medio fontWeight SemiBold Semigrassetto fontWeight Bold Grassetto -fontWeight ExtraBold Extra-grassetto +fontWeight ExtraBold Extragrassetto fontWeight Black Nero fontWeight Italic Corsivo # Pattern Stamp tool - Pattern Stamp Tool Strumento Timbro motivo - Pattern Stamp Timbro motivo + Pattern Stamp Tool Strumento Timbro pattern + Pattern Stamp Timbro pattern Impressionist Impressionista Rotate View Ruota vista Rotate View Tool Strumento Ruota vista @@ -2510,7 +2510,7 @@ fontWeight Italic Corsivo Cycle Overlay Cambia sovrapposizione Cycle Orientation Cambia orientamento Set the overlay options for the Crop tool Imposta le opzioni di sovrapposizione dello strumento Taglierina - Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-click è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori. + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. L’Alt-clic è andato al desktop, che sposta le finestre con Alt-trascina. Imposta il suo tasto per spostare le finestre su Super, oppure blocca Alt in Finestra › Tasti modificatori. # Shared Layer Style color picker Color Picker (Layer Style Color) Selettore colore (colore dello stile livello) @@ -2529,7 +2529,7 @@ fontWeight Italic Corsivo Set additional Crop options Imposta opzioni di ritaglio aggiuntive Show Cropped Area Mostra area ritagliata Enable Crop Shield Attiva schermo di ritaglio - Match Canvas Come la tela + Match Canvas Come il quadro Auto Adjust Opacity Regola automaticamente opacità Crop Shield color Colore dello schermo di ritaglio Color Picker (Crop Shield Color) Selettore colore (Colore dello schermo di ritaglio) @@ -2631,7 +2631,7 @@ cameraData Lens Obiettivo Curvature Pen Tool Strumento Penna curvatura Curve Fit: Adattamento curva: Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Trascina: disegna a mano libera · {alt}-clic: segmento retto · Rilascia sul punto iniziale: chiudi - Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Click: punto morbido · Doppio click: angolo · Trascina un punto: sposta · Click primo punto: chiudi · {key} termina + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Clic: punto morbido · Doppio clic: angolo · Trascina un punto: sposta · Clic primo punto: chiudi · {key} termina New Crop Preset… Nuovo predefinito di ritaglio… Delete Crop Preset… Elimina predefinito di ritaglio… Select the crop presets to delete: Seleziona i predefiniti di ritaglio da eliminare: @@ -2680,11 +2680,11 @@ curves Smooth Leviga Expand Detection Area Espandi area di rilevamento Restore Original Edge Ripristina bordo originale Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità - Clear Brush Strokes Cancella tratti del pennello + Clear Brush Strokes Cancella tracce del pennello Show rulers Mostra righelli # Color Sampler (added with the i18n coverage repair) - Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dalla tela per rimuovere + Click to place · Alt-click or drag off-canvas to remove Fai clic per posizionare · Alt-clic o trascina fuori dal quadro per rimuovere No document open Nessun documento aperto {n} of {max} sample points {n} di {max} punti di campionamento # Print dialog (#2697) @@ -2719,3 +2719,1295 @@ curves Smooth Leviga Light Red Rosso chiaro Medium Blue Blu medio Light Gray Grigio chiaro + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) that the +# other catalogs already translate + No No + Yes Sì +@id select.all Seleziona tutto + Delete Smart Filters Elimina filtri avanzati + Enable Smart Filters Attiva filtri avanzati + New Layer… Nuovo livello… + Delete Layer… Elimina livello… + Select Similar Colors Seleziona colori simili + New Selection Nuova selezione + Canvas rotation Rotazione quadro + Extensions Estensioni + Libraries Librerie + 3D 3D + Toolbars Barre strumenti + Wand Bacchetta + Healing Correttivo + Pen Penna + Hand Mano + Sampler Campionatore + Note tool Strumento Nota + Magnetic Lasso Lazo magnetico + Art History Brush Pennello storia artistica + Spot Healing Correttivo al volo + Patch Toppa + Content-Aware Move Spostamento in base al contenuto + Perspective Crop Taglierina prospettica + Dodge/Burn Scherma/Brucia + Dynamics Dinamica + Tilt Inclinazione + Defaults restored. Predefiniti ripristinati. + Stop Interrompi + Create new set Crea nuovo set + Technology Previews Anteprime tecnologiche + Create new layer Crea nuovo livello + Group · {n} layer Gruppo · {n} livello + General Blend Fusione generale + Advanced Blending Fusione avanzata + RGB RGB + Spot Channel Canale tinta piatta + Working Gray Plate Lastra grigia di lavoro + HSL/Grayscale HSL/Scala di grigio + Tone Curve Curva tonale + Split Toning Viraggio separato + Lens Corrections Correzioni lente + Camera Calibration Calibrazione fotocamera + Snapshots Istantanee + Recovery Recupero + Fill Light Luce di riempimento + Convert to Grayscale Converti in scala di grigio + Sharpness Nitidezza + Color Noise Reduction Riduzione disturbo cromatico + Luminance Smoothness Uniformità luminanza + Lens Obiettivo + ISO ISO + Focal Length35mm Lunghezza focale equivalente (35 mm) + Aspect Ratio Proporzioni + Time Ora + Enter description… Inserisci una descrizione… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Standard di settore + Uncoated CMYK CMYK carta non patinata + Newspaper Carta da giornale + Working Spaces Spazi di lavoro + Color Management Policies Criteri di gestione colore + Detect Faces Rileva volti + Localize Color Clusters Localizza cluster colore + Duplicated selection name Nome selezione duplicata + Operation Operazione + New channel only Solo nuovo canale + Masked Areas Aree mascherate + Selected Areas Aree selezionate + Color indicates Il colore indica + Create Snapshot Crea istantanea + Delete Snapshot Elimina istantanea + New Snapshot Nuova istantanea + Snapshot 1 Istantanea 1 + Step Forward Avanza di un passo + Step Backward Torna indietro di un passo + Nonlinear History Cronologia non lineare + Set New Snapshot as Default Imposta nuova istantanea come predefinita + Automatically Create First Snapshot Crea automaticamente prima istantanea + Automatically Create New Snapshot When Saving Crea automaticamente nuova istantanea al salvataggio + Show New Snapshot Dialog by Default Mostra per impostazione predefinita la finestra Nuova istantanea + Clear History Cancella cronologia + Panel Options… Opzioni pannello… + View menu Menu Vista + Keep view in sync Mantieni vista sincronizzata + {zoom}% {zoom}% + Mouse Controls Controlli mouse + Initial Direction of Stroke Direzione iniziale della traccia + Direction of Stroke Direzione della traccia + Humidity Umidità + Auto Clean Brush Pulisci automaticamente il pennello + Spacing: Spaziatura: + New Layer from Brush Nuovo livello da pennello + New Brush Nuovo pennello + New Tool Preset Nuovo predefinito strumento + Auto-Leading Interlinea automatica + Scale Horizontally Scala orizzontale + Scale Vertically Scala verticale + Baseline Shift Scostamento linea base + Mac LCD LCD Mac + Mac Sharp Mac nitido + Mac Crisp Mac definito + Mac Strong Mac forte + Mac Smooth Mac morbido + Superscript Apice + Subscript Pedice + Ligature Legatura + Alternative Alternativa + Ultra Light Ultraleggero + Extra Light Extraleggero + Semi Bold Semigrassetto + Extra Bold Extragrassetto + Ultra Bold Ultra grassetto + Condensed Condensato + First Line Indent Rientro prima riga + Hanging Punctuation Punteggiatura sospesa + Roman Hanging Punctuation Punteggiatura latina sospesa + Avoid Widows/Orphans Evita righe vedove/orfane + Compose Composizione + Adobe Single-line Composer Compositore riga singola Adobe + Adobe Every-line Composer Compositore ogni riga Adobe + Word Spacing Spaziatura parole + Letter Spacing Spaziatura lettere + Glyph Scaling Ridimensionamento glifi + Auto-leading % Interlinea automatica % + Single Word Justification Giustificazione parola singola + Justify Giustifica + Glyph Glifo + Alternates for Current Selection Alternative per la selezione corrente + Digits Cifre + Path: Tracciato: + Path to Illustrator… Tracciato in Illustrator… + Clipping Path Tracciato di ritaglio + New Path Nuovo tracciato + Duplicate Path Duplica tracciato + Auto-Hide Nascondi automaticamente + X X + Y Y + Frame Sync Sincronizzazione fotogrammi + Quick Sync Sincronizzazione rapida + Audio Audio + Frame Duration Durata fotogramma + Loop Ciclo + Split at Playhead Dividi all'indicatore di riproduzione + Flatten Frames Into Clips Unisci fotogrammi in clip + Flatten Frames Into Layers Unisci fotogrammi in livelli + Convert Frames > Video Layers Converti fotogrammi > livelli video + New Video Layer Nuovo livello video + New Video Group Nuovo gruppo video + Limit Playback Limita riproduzione + Enable Timeline Quick Config Attiva configurazione rapida timeline + Set Start Timecode… Imposta timecode iniziale… + Timecode Timecode + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Tempo corrente + Out Fine + In Inizio + Duration Durata + Repeat Ripeti + Play Once Riproduci una volta + Info Options… Opzioni informazioni… + First Color Readout Prima lettura colore + Second Color Readout Seconda lettura colore + Actual Color Colore effettivo + Grayscale Slider Cursore scala di grigio + Web Color Colore web + HSB Color Colore HSB + Total Ink Inchiostro totale + Opacity Mask Maschera di opacità + Document Size Dimensione documento + Timing Tempi + Status Information Informazioni di stato + Saved Multichannel Multicanale salvato + Mouse Coordinates Coordinate mouse + Ruler Units Unità righelli + Hide Nascondi + Restore last viewed comp Ripristina ultima composizione visualizzata + Select All Layers Seleziona tutti i livelli + Keep proportions Mantieni proporzioni + Interpolation Interpolazione + Caption Didascalia + Caption Credits Autore didascalia + Keywords Parole chiave + Copyright Info URL URL info copyright + Copyright Status Stato copyright + Copyrighted Protetto da copyright + Public Domain Pubblico dominio + Unknown Sconosciuto + Credit Crediti + Transmission Reference Riferimento trasmissione + Urgency Urgenza + High Alta + Low Bassa + Category Categoria + Supplemental Categories Categorie supplementari + Object Name / Title Nome oggetto / titolo + Date Created Data di creazione + City Città + Province / State Provincia / stato + Country Paese + Byline Autore + Byline Title Qualifica autore + Phone Telefono + Email Email + URLs URL + Last Modified Ultima modifica + EXIF EXIF + GPS GPS + Camera Model Modello fotocamera + Camera Make Marca fotocamera + EXIF Version Versione EXIF + Exposure Program Programma di esposizione + Aperture Apertura diaframma + Shutter Speed Tempo di posa + Metering Mode Modalità di misurazione + Light Source Sorgente luminosa + Flash Flash + Lens Info Info obiettivo + ISO Speed Sensibilità ISO + Subject Distance Distanza soggetto + DateTime Original Data e ora originali + Create Date Data di creazione + Modify Date Data di modifica + Raw Data Dati grezzi + Import… Importa… + PDF Presentation… Presentazione PDF… + Create Multiple Picture Packages Crea più pacchetti di immagini + Picture Package… Pacchetto di immagini… + Web Photo Gallery… Galleria fotografica web… + Choose… Scegli… + Select Folder… Seleziona cartella… + Override Action "Save" Commands Ignora i comandi "Salva" dell'azione + File Naming Denominazione file + Compatibility Compatibilità + Mac OS Mac OS + Unix Unix + Include ICC Profile Includi profilo ICC + Error File File di errori + Stop For Errors Interrompi in caso di errori + Log Errors To File Registra errori su file + Errors Errori + Settings Impostazioni + Settings: Impostazioni: + Working Space Spazio di lavoro + RGB: RGB: + CMYK: CMYK: + Gray: Grigio: + Spot: Tinta piatta: + Advanced Controls Controlli avanzati + Desaturate Monitor Colors By Desatura i colori del monitor di + Blend RGB Colors Using Gamma 1.0 Fondi colori RGB con gamma 1,0 + Profile Mismatches: Ask When Opening Profili non corrispondenti: chiedi all'apertura + Ask When Pasting Chiedi quando incolli + Missing Profiles: Ask When Opening Profili mancanti: chiedi all'apertura + Embed profiles Incorpora profili + Ask Before Saving Saved Embedded Chiedi prima di salvare il profilo incorporato + Coerce to sRGB Forza in sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web Grafica e web + Minimalistic Minimalista + Expand All Groups Espandi tutti i gruppi + Photo (2 × 3 in) Foto (2 × 3 in) + Photo (4 × 6 in) Foto (4 × 6 in) + Photo (5 × 7 in) Foto (5 × 7 in) + Photo (8 × 10 in) Foto (8 × 10 in) + Recent Items Elementi recenti + Delete Preset… Elimina predefinito… + Preset Name: Nome del predefinito: + Width and Height Larghezza e altezza + Color Profile Profilo colore + Units Unità + Create new count group Crea nuovo gruppo conteggio + Adjustment Layer Livello di regolazione + Drag to move the layer Trascina per spostare il livello + Go to Layer Vai al livello + After (X) Dopo (X) + Current Corrente + Reference point location Posizione punto di riferimento + Toggle reference point Mostra/nascondi punto di riferimento + Add Pins Aggiungi pin + Delete Pins Elimina pin + Rotate Auto Rotazione automatica + Rotate Fixed Rotazione fissa + Select Plane Seleziona piano + Measure Misura + Heal Correzione + Heal Off Correzione disattivata + Heal On Correzione attivata + Heal Auto Correzione automatica + Blend Fusione + Brush Size Dimensione pennello + Heal Color Colore correzione + Result Risultato + Duplicate Duplica + Fast Veloce + Accurate Preciso + Add to Aggiungi a + Subtract from Sottrai da + Intersect with Interseca con + Capture Comp Acquisisci composizione + No Layer Comps Nessuna composizione livelli + Adobe PDF Presets… Predefiniti Adobe PDF… + 3D… 3D… + Technology Previews… Anteprime tecnologiche… + Add a mask (from the selection; ⌥ inverts) Aggiungi una maschera (dalla selezione; ⌥ inverte) + Add to selection (⇧) Aggiungi alla selezione (⇧) + Airbrush: keeps painting while the pointer rests. Aerografo: continua a dipingere mentre il puntatore è fermo. + Click the first point or press ↵ to close · Esc cancels Fai clic sul primo punto o premi ↵ per chiudere · Esc annulla + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Clic per aggiungere un punto · trascina fuori, ⌘/Ctrl-clic o Canc per rimuovere + Click to apply, ⌥-click to clear overrides, double-click for Style Options Clic per applicare, ⌥-clic per rimuovere eccezioni, doppio clic per Opzioni stile + Click to zoom in · ⌥-click to zoom out Clic per ingrandire · ⌥-clic per ridurre + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Clic: angolo · Trascina: morbido · Clic primo punto: chiudi · ↩ termina · Esc annulla + Commit Perspective Warp (↩) Conferma altera prospettiva (↩) + Commit Puppet Warp (↩) Conferma altera marionetta (↩) + Commit any current edits (⌘↩) Conferma modifiche correnti (⌘↩) + Commit current crop operation (↵) Conferma operazione ritaglio corrente (↵) + Commit transform (↩) Conferma trasformazione (↩) + Commit warp (↩) Conferma altera (↩) + Create a new layer (⇧⌘N) Crea nuovo livello (⇧⌘N) + Custom pattern Pattern personalizzato + General Brushes Pennelli generici + Grid (⌘') Griglia (⌘') + Intersect with selection (⇧⌥) Interseca con selezione (⇧⌥) + New document… ⌘N Nuovo documento… ⌘N + Open… ⌘O Apri… ⌘O + Rulers (⌘R) Righelli (⌘R) + Search commands (⌘K) Cerca comandi (⌘K) + Show Guides (⌘;) Mostra guide (⌘;) + Subtract from selection (⌥) Sottrai da selezione (⌥) + Uses the foreground and background colors. Usa i colori di primo piano e di sfondo. + ↑↓ navigate ↵ run esc close ↑↓ naviga ↵ esegui esc chiudi + Adds grain to the soft edges of the brush tip. Aggiunge grana ai bordi morbidi della punta del pennello. + Keeps the current texture when switching presets. Mantiene la texture corrente quando cambi predefinito. + Watercolour look: lighter interior, darker rims. Effetto acquerello: interno più chiaro, bordi più scuri. + Collapse group (⌥-click: all groups) Comprimi gruppo (⌥-clic: tutti i gruppi) + Expand group (⌥-click: all groups) Espandi gruppo (⌥-clic: tutti i gruppi) + Hide the layer's effects (⌥-click: all layers) Nascondi effetti livello (⌥-clic: tutti i livelli) + Show the layer's effects (⌥-click: all layers) Mostra effetti livello (⌥-clic: tutti i livelli) +cameraRaw Blacks Neri +cameraRaw Masking Mascheratura +@id edit.stroke Traccia… +@id edit.transform.scale Scala +cameraRaw Luminance Luminanza +cameraRaw Color Colore +cameraRaw Vibrance Vividezza +cameraRaw Color Grading Gradazione colore +cameraRaw Global Globale +cameraRaw Hue Tonalità +cameraRaw Luminance Detail Dettaglio luminanza +cameraRaw Color Detail Dettaglio colore +cameraRaw Roughness Rugosità +cameraRaw Whites Bianchi + Content-Aware Move Tool Strumento Sposta in base al contenuto +layerLabel No Color Nessun colore + Preferences… Preferenze… + Actual Pixels Pixel effettivi + Clipping Mask Maschera di ritaglio + Tool Options Opzioni strumento + Color Picker Selettore colore + Color Swatches Campioni colore + Linear Dodge Scherma lineare + Preview Image Anteprima immagine + Save As Salva con nome + Remove All Rimuovi tutto + Unlock Sblocca + Enable Attiva + Disable Disattiva + On Attivato + Clear Selection Deseleziona + Invert Selection Inverti selezione + Select Inverse Inverti selezione + Contract Contrai + Sample Size Dimensione campione + Strength Forza + Gamma Gamma + Input Input + Input Levels Livelli input + Output Levels Livelli output + Layer Styles Stili livello + Blending Mode Metodo di fusione + Fill Color Colore di riempimento + Stroke Color Colore della traccia + Stroke Width Spessore traccia + Shadow Ombra + Adjustment Layers Livelli di regolazione + Text Layer Livello di testo + Documents Documenti + File Name Nome file + File Format Formato file + Compression Compressione + Artboards Tavole da disegno + Snap to Grid Aggancia alla griglia + Full Screen Schermo intero + Fullscreen Schermo intero + Workspaces Aree di lavoro + Show All Mostra tutto + Hidden Nascosto + Duplicate Layer Duplica livello + Ungroup Separa + Lock Transparent Pixels Blocca pixel trasparenti + Lock Image Pixels Blocca pixel immagine + Lock Position Blocca posizione + Lock All Blocca tutto + Show Rulers Mostra righelli + Guide Layout… Layout guide… + Page Setup… Imposta pagina… + Reset Preferences Ripristina preferenze + Documentation Documentazione + Check for Updates Verifica aggiornamenti + Loading… Caricamento… + Processing… Elaborazione… + Saving… Salvataggio… + Exporting… Esportazione… + Please wait… Attendi… + No selection Nessuna selezione + No layers Nessun livello + No recent files Nessun file recente + Unsupported file format Formato file non supportato + Failed to open file Impossibile aprire il file + Failed to save file Impossibile salvare il file + Operation canceled Operazione annullata + Changes have not been saved. Le modifiche non sono state salvate. + Are you sure? Sei sicuro? + Discard Changes Ignora modifiche + Recent Files File recenti + Open File Apri file + Save File Salva file + Save Changes Salva modifiche + Rendering… Rendering… + Importing… Importazione… + Ready Pronto + Failed Non riuscito + Warning Avviso + Information Informazioni + Retry Riprova + Continue Continua + Skip Salta + Resume Riprendi + Reset All Ripristina tutto + Defaults Predefiniti + Apply to All Applica a tutti + Confirm Conferma + Finish Termina + Back Indietro + Next Avanti + Previous Precedente + More Altro + Less Meno + Manual Manuale + Disabled Disattivato + Locked Bloccato + Unlocked Sbloccato + Delete… Elimina… + Create… Crea… + Edit… Modifica… + Change… Cambia… + Transformation Trasformazione + Scaling Ridimensionamento + Canvas Size Dimensione quadro + Percentage Percentuale + Degrees Gradi + Middle Centro + Diameter Diametro + Intensity Intensità + Sensitivity Sensibilità + Softness Morbidezza + Feather Radius Raggio sfumatura + Library Libreria + Layer Properties Proprietà livello + Groups Gruppi + Masks Maschere + Add Mask Aggiungi maschera + Delete Mask Elimina maschera + Invert Mask Inverti maschera + Enable Mask Attiva maschera + Disable Mask Disattiva maschera + Apply Mask Applica maschera + Import Settings Importa impostazioni + Export Settings Esporta impostazioni + Shortcut Scorciatoia + Shortcuts Scorciatoie + Keyboard Tastiera + Mouse Mouse + Tablet Tavoletta grafica + Display Monitor + Panel Pannello + Hide Grid Nascondi griglia + Fit to Window Adatta alla finestra + Live Preview Anteprima in tempo reale + Before Prima + After Dopo + Compare Confronta + File Size Dimensione file + Filename Nome file + Folder Cartella + Open Folder Apri cartella + Choose Folder Scegli cartella + Browse Sfoglia + Find Trova + Replace Sostituisci + Date Data + Learn More Ulteriori informazioni + Report a Bug Segnala un bug + Updates Aggiornamenti + Update Aggiorna + Restart Required Riavvio necessario + Restart Riavvia + Quit Esci + Quit PhotoCraft Esci da PhotoCraft + Close Window Chiudi finestra + New Window Nuova finestra + Open Image Apri immagine + Save Image Salva immagine + Export Image Esporta immagine + Import Image Importa immagine + Export As Esporta come + Export to Esporta in + Export Options Opzioni di esportazione + Import Options Opzioni di importazione + No Selection Nessuna selezione + No Document Nessun documento + No Image Nessuna immagine + No Layers Nessun livello + No Results Nessun risultato + No Results Found Nessun risultato trovato + Nothing to Undo Niente da annullare + Nothing to Redo Niente da ripetere + Changes Saved Modifiche salvate + Unsaved Changes Modifiche non salvate + Save before closing? Vuoi salvare prima di chiudere? + Are you sure you want to quit? Vuoi davvero uscire? + The file could not be opened. Impossibile aprire il file. + The file could not be saved. Impossibile salvare il file. + Unsupported format Formato non supportato + Out of memory Memoria insufficiente + Permission denied Permesso negato + File not found File non trovato + Operation failed Operazione non riuscita + Operation completed Operazione completata + Operation cancelled Operazione annullata + Sharpen Amount Quantità contrasto + Sharpen Radius Raggio contrasto + Sharpen Detail Dettaglio contrasto + Sharpen Masking Mascheratura contrasto + Noise Luminance Riduzione disturbo di luminanza + Noise Luminance Detail Dettaglio luminanza (disturbo) + Noise Color Riduzione disturbo cromatico + Noise Color Detail Dettaglio colore (disturbo) + Grain Amount Quantità grana + Grain Size Dimensione grana + Grain Roughness Rugosità grana + Vignette Roundness Rotondità vignettatura + Vignette Feather Sfumatura vignettatura + Vignette Highlights Luci vignettatura + Vignette Style Stile vignettatura + Planes Piani + Plane Piano + Shadow Amount Quantità ombre + Shadow Tone Ampiezza tonale ombre + Shadow Radius Raggio ombre + Highlight Amount Quantità luci + Highlight Tone Ampiezza tonale luci + Highlight Radius Raggio luci + Midtone Contrasto mezzi toni + Black Clip Taglio neri + White Clip Taglio bianchi + Fuzziness Tolleranza + Source Layer Livello sorgente + Neutralize Neutralizza + Use Selection In Source Usa selezione nella sorgente + Use Selection In Target Usa selezione nella destinazione + Flame Type Tipo di fiamma + Randomize Length Lunghezza casuale + Interval Intervallo + Adjust Interval For Loops Regola intervallo per cicli + Use Custom Color Usa colore personalizzato + Flame Lines Linee di fiamma + Flame Bottom Alignment Allineamento base fiamma + Flame Style Stile fiamma + Flame Shape Forma fiamma + Randomize Shapes Forme casuali + Frame Cornice + Arrangement Disposizione + Thickness Spessore + Vine Color Colore rampicante + Flower Color Colore fiori + Leaf Color Colore foglie + Base Tree Type Tipo di albero base + Light Direction Direzione luce + Leaves Amount Quantità foglie + Leaves Size Dimensione foglie + Branches Height Altezza rami + Branches Thickness Spessore rami + Default Leaves Foglie predefinite + Leaves Color Colore foglie + Custom Branch Color Colore rami personalizzato + Branches Color Colore rami + Flat Shading Ombreggiatura piatta + Date and Time Data e ora + Scale Units Unità di scala + Scale Factor Fattore di scala + Area Area + Perimeter Perimetro + Circularity Circolarità + Gray Value (Minimum) Valore di grigio (minimo) + Gray Value (Maximum) Valore di grigio (massimo) + Gray Value (Mean) Valore di grigio (media) + Gray Value (Median) Valore di grigio (mediana) + Integrated Density Densità integrata + Length Lunghezza + In Black Nero in input + In White Bianco in input + Out Black Nero in output + Out White Bianco in output + Tint Color Colore tinta + Stops Punti della sfumatura + Center X Centro X + Center Y Centro Y + Distribution Distribuzione + Monochromatic Monocromatico + Cell Size Dimensione cella + Generators Generatori + Wavelength Min Lunghezza d'onda min. + Wavelength Max Lunghezza d'onda max. + Amplitude Min Ampiezza min. + Amplitude Max Ampiezza max. + Undefined Areas Aree non definite + Preserve Mantieni + Max Radius Raggio massimo + Depth Mode Modalità profondità + Solid Front Faces Facce anteriori piene + Mask Incomplete Blocks Maschera blocchi incompleti + Stylization Stilizzazione + Cleanliness Pulizia + Bristle Detail Dettaglio setole + Lighting Illuminazione + Shine Brillantezza + Max Offset Scostamento massimo + Map Document Documento mappa + Map Path Percorso file mappa + Map Layer Livello mappa + Ridges Creste + Light Type Tipo di luce + Light X Luce X + Light Y Luce Y + Light Z Luce Z + Target X Destinazione X + Target Y Destinazione Y + Hotspot Area di massima intensità + Elevation Elevazione + Ambient Luce ambiente + Warmth Calore + Gloss Lucentezza + Metallic Metallico + Ambience Luce ambiente + White Is High Bianco in rilievo + Reduce Color Noise Riduci disturbo cromatico + Sharpen Details Contrasta dettagli + Remove Jpeg Artifact Rimuovi artefatti JPEG + Blade Curvature Curvatura lamelle + Depth Map Mappa di profondità + Focal Distance Distanza di messa a fuoco + Focus Messa a fuoco + Transition Transizione + Radius X Raggio X + Radius Y Raggio Y + Pins Pin + Blur Angle Angolo sfocatura + Speed Velocità + Taper Rastremazione + Start X Inizio X + Start Y Inizio Y + End X Fine X + End Y Fine Y + Kernel Kernel + Input Mode Modalità input + Row Order Ordine righe + Eliminate Elimina + Create By Crea tramite + List Elenco + Pencil Width Larghezza matita + Stroke Pressure Pressione traccia + Paper Brightness Luminosità carta + Number Of Levels Numero di livelli + Edge Simplicity Semplicità bordi + Edge Fidelity Fedeltà bordi + Brush Detail Dettaglio pennello + Highlight Area Area luci + Glow Size Dimensione bagliore + Glow Brightness Luminosità bagliore + Brush Type Tipo di pennello + Stroke Size Dimensione traccia + Stroke Detail Dettaglio traccia + Highlight Strength Intensità luci + Smoothness Levigatezza + Edge Thickness Spessore bordi + Edge Intensity Intensità bordi + Posterization Posterizzazione + Stroke Length Lunghezza traccia + Relief Rilievo + Definition Definizione + Texture Coverage Copertura texture + Shadow Intensity Intensità ombre + Edge Width Larghezza bordi + Edge Brightness Luminosità bordi + Direction Balance Bilanciamento direzione + Black Intensity Intensità nero + White Intensity Intensità bianco + Dark Intensity Intensità scuri + Custom Pixel Aspect Ratio Proporzioni pixel personalizzate + 32-bit Preview Options Opzioni anteprima 32 bit + New Guide Nuova guida + Join the ArtCraft Discord Unisciti al Discord di ArtCraft + Report an Issue Segnala un problema + Load Files into Stack Carica file in pila + Statistics Statistiche + Duplicate Layers Duplica livelli + Edit Toolbar Modifica barra strumenti + Import Brushes Importa pennelli + Load 3D LUT Carica LUT 3D + New document Nuovo documento + Press keys Premi tasti + Rename Group Rinomina gruppo + Rename Tool Preset Rinomina predefinito strumento + Search commands, tools and panels Cerca comandi, strumenti e pannelli + Slice Options Opzioni sezione + Rename Brush Rinomina pennello + Opening Apertura + Reading Lettura + Working Elaborazione + Accented Edges Bordi accentuati + Angled Strokes Tracce angolate + Anisotropic Anisotropico + Around Center Attorno al centro + Artistic Artistico + Bamboo Bambù + Bas Relief Bassorilievo + Berries Bacche + Blast Esplosione + Bleach Bypass Bleach bypass + Blocks Blocchi + Brick Mattoni + Brush Strokes Pennellate + Burlap Tela di iuta + Candle Light Luce di candela + Circle Cerchio + Clumped Raggruppato + Coarse Dots Punti grossi + Colored Pencil Matita colorata + Contrasty Contrastato + Cool Freddo + Craquelure Craquelure + Cross Croce + Cross Process Processo incrociato + Crosshatch Tratteggio incrociato + Cutout Ritaglio + Daisies Margherite + Dark Rough Ruvido scuro + Dark Strokes Tracce scure + Darken Only Scurisci soltanto + Day For Night Notte americana + Deep Blue Blu intenso + Deep Emerald Smeraldo intenso + Deep Red Rosso intenso + Deep Yellow Giallo intenso + Dot Punto + Double Line Linea doppia + Draft Bozza + Dry Brush Pennello asciutto + Duplication Duplicazione + Edge Extension Estensione bordi + Edge Only Solo bordi + Enlarged Ingrandito + Even Fields Campi pari + Faded Film Pellicola sbiadita + Film Grain Grana pellicola + Fine Fine + Fine Dots Punti fini + Flat Piatto + Fresco Affresco + From Left Da sinistra + From Right Da destra + Frosted Smerigliato + Generic Generico + Glass Vetro + Glowing Edges Bordi luminosi + Grainy Dots Punti granulosi + Hearts Cuori + Heptagon Ettagono + Hexagon Esagono + Horizontal Only Solo orizzontale + Infinite Infinita + Ink Outlines Contorni a inchiostro + Ivy Edera + Left Diagonal Diagonale sinistra + Level Based Basato su livelli + Light Rough Ruvido chiaro + Lighten Only Schiarisci soltanto + Long Lines Linee lunghe + Long Strokes Tracce lunghe + Lower Inferiore + Medium Dots Punti medi + Medium Lines Linee medie + Medium Strokes Tracce medie + Mono Contrast Contrasto monocromatico + Mosaic Tiles Tessere mosaico + Multiple Flames Along Path Più fiamme lungo il tracciato + Multiple Flames One Direction Più fiamme in una direzione + Multiple Flames Path Directions Più fiamme nelle direzioni del tracciato + Multiple Flames Various Length Più fiamme di lunghezza variabile + Octagon Ottagono + Odd Fields Campi dispari + One Flame Along Path Una fiamma lungo il tracciato + Out From Center Dal centro verso l'esterno + Oval Ovale + Overlay Edge Sovrapponi bordi + Palette Knife Spatola + Parallel Parallelo + Patchwork Patchwork + Pentagon Pentagono + Point Puntiforme + Pointed Appuntito + Pond Ripples Increspature stagno + Pyramids Piramidi + Random Casuale + Right Diagonal Diagonale destra + Rope Corda + Roses Rose + Sandstone Arenaria + Scallops Smerli + Sepia Seppia + Short Lines Linee corte + Short Strokes Tracce corte + Simple Semplice + Light Intensity Intensità luce + Spray Radius Raggio spruzzo + Stroke Direction Direzione traccia + Graininess Granulosità + Glow Amount Quantità bagliore + Clear Amount Area senza bagliore + Distortion Distorsione + Ripple Size Dimensione increspatura + Ripple Magnitude Intensità increspatura + Charcoal Area Area carboncino + Chalk Area Area gessetto + Charcoal Thickness Spessore carboncino + Light Dark Balance Bilanciamento chiaro/scuro + Foreground Level Intensità primo piano + Background Level Intensità sfondo + Pattern Type Tipo pattern + Image Balance Bilanciamento immagine + Darkness Oscurità + Fiber Length Lunghezza fibre + Crack Spacing Spaziatura crepe + Crack Depth Profondità crepe + Crack Brightness Luminosità crepe + Grain Type Tipo grana + Tile Size Dimensione tessere + Grout Width Larghezza fughe + Lighten Grout Schiarisci fughe + Square Size Dimensione quadrati + Border Thickness Spessore bordo + Correct Distortion Correggi distorsione + Correct Vignette Correggi vignettatura + Correct C A Correggi aberrazione cromatica + Auto Scale Scala automatica + Red Cyan Rosso/ciano + Blue Yellow Blu/giallo + Vignette Amount Quantità vignettatura + Vignette Midpoint Punto medio vignettatura + Constraints Vincoli + Curve Highlights Luci curva + Curve Lights Toni chiari curva + Curve Darks Toni scuri curva + Curve Shadows Ombre curva + Curve Splits Divisioni curva + Point Curve Curva a punti + Point Curve Red Curva a punti (rosso) + Point Curve Green Curva a punti (verde) + Point Curve Blue Curva a punti (blu) + Hsl Hue Tonalità HSL + Hsl Sat Saturazione HSL + Hsl Lum Luminanza HSL + Grade Shadows Gradazione ombre + Grade Midtones Gradazione mezzi toni + Grade Highlights Gradazione luci + Grade Global Gradazione globale + Grade Blending Fusione gradazione + Grade Balance Bilanciamento gradazione + Sine Sinusoide + Sketch Schizzo + Snowflakes Fiocchi di neve + Sparkle Scintillio + Spatter Spruzzo + Speckle Picchiettato + Spin Rotazione + Sprinkles Granelli sparsi + Squareness Forma quadrata + Stagger Sfalsamento + Stained Glass Vetrata + Star Stella + Stars Stelle + Stippled Punteggiato + Stretch Adatta alle dimensioni + Teal Orange Verde acqua e arancio + Tiny Lens Lente minuscola + To Center Verso il centro + Unaltered Inalterato + Underpainting Pittura di fondo + Underwater Subacqueo + Uniform Uniforme + Upper Superiore + Vertical Only Solo verticale + Vine With Flowers Rampicante con fiori + Vine With Leaves Rampicante con foglie + Violent Violento + Warm Caldo + Watercolor Acquerello + Waves Onde + Wide Blurry Ampio sfocato + Wide Sharp Ampio nitido + Wrap Ripeti ciclicamente + Zigzag Zigzag + Wetness Bagnato + AI Integrations Integrazioni IA + Add Noise Aggiungi disturbo + Apply Data Set Applica set di dati + Apply Image Applica immagine + Arbitrary Arbitrario + Artboard from Group Tavola da gruppo + Artboard from Layers Tavola da livelli + Artboards to Files Tavole da disegno in file + Artboards to PDF Tavole da disegno in PDF + Assign Profile Assegna profilo + At Posizione + Auto-Align Layers Allinea automaticamente livelli + Auto-Blend Layers Fondi automaticamente livelli + Batch Batch + Border Bordo + Box Blur Sfocatura casella + Calculations Calcoli + Camera Raw Camera Raw + Camera Raw Filter Filtro Camera Raw + Channel1 Canale 1 + Channel2 Canale 2 + Channel3 Canale 3 + Channel4 Canale 4 + Clone Clona + Color Decontaminate Decontamina colore + Color Halftone Mezzetinte a colori + Color Lookup Tables Tabelle ricerca colore + Color Settings Impostazioni colore + Color Table Tabella colori + Conditional Mode Change Cambio modo condizionale + Content-Aware Fill Riempimento in base al contenuto + Convert to Linked Converti in collegato + Convert to Profile Converti in profilo + Crystallize Cristallizza + Data Dati + Data Sets as Files Set di dati come file + De-Interlace Deinterlaccia + Define Brush Preset Definisci predefinito pennello + Define Custom Shape Definisci forma personale + Define Pattern Definisci pattern + Defringe Elimina frange + Diffuse Diffondi + Diffuse Glow Bagliore diffuso + Displace Sposta + Do you want to save you made to “{name}” before quitting? Vuoi salvare le modifiche apportate a “{name}” prima di uscire? + Dust & Scratches Polvere e graffi + Export Contents Esporta contenuto + Export Preferences Preferenze di esportazione + Export/Import Presets Esporta/Importa predefiniti + Extrude Estrudi + Fibers Fibre + Field Blur Sfocatura campo + File Info Info file + Filter Gallery Galleria filtri + Find and Replace Text Trova e sostituisci testo + Fit Image Adatta immagine + Flame Fiamma + Focus Area Area di messa a fuoco + Gaussian Blur Sfocatura gaussiana + Global Light Luce globale + Group from Layers Gruppo da livelli + HDR Toning Viraggio HDR + High Pass Passa alto + Highlight Luci + Image Processor Elaborazione immagini + Install Plug-in Installa plug-in + Interpret Footage Interpreta filmato + Iris Blur Sfocatura diaframma + Jag Frastagliato + Label Etichetta + Layer Comps to Files Composizioni livelli in file + Layer Content Options Opzioni contenuto livello + Layer From Background Livello dallo sfondo + Layer from Background Livello dallo sfondo + Layers to Files Livelli in file + Legacy Legacy + Lens Blur Sfocatura lente + Lens Correction Correzione lente + Lens Flare Riflesso obiettivo + Lighting Effects Effetti di luce + Liquify Fluidifica + Lock Layers Blocca livelli + Lum Lum + Match Color Corrispondenza colore + Merge to HDR Pro Unisci in HDR Pro + Mezzotint Mezzatinta + Mosaic Mosaico + Motion Blur Movimento sfocato + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Il drag-and-drop nativo dei file non è ancora supportato su Wayland. Usa File › Apri, oppure avvia PhotoCraft sotto XWayland con `WAYLAND_DISPLAY= photocraft`. + Neon Glow Bagliore neon + New Guide Layout Nuovo layout guide + New Video Layer from File Nuovo livello video da file + Ocean Ripple Increspatura oceano + Oil Paint Pittura a olio + Open As Apri come + Package Crea pacchetto + Paint Daubs Macchie di colore + Path Blur Sfocatura tracciato + Paths to Illustrator Tracciati in Illustrator + Photomerge Photomerge + Picture Frame Cornice immagine + Pinch Pizzica + Place Embedded Inserisci incorporato + Place Linked Inserisci collegato + Place Scale Marker Inserisci indicatore scala + Plastic Wrap Pellicola plastica + Pointillize Puntinatura + Polar Coordinates Coordinate polari + Polar To Rectangular Da polari a rettangolari + Poster Edges Bordi poster + Preset Manager Gestore predefiniti + Profile Profilo + Radial Blur Sfocatura radiale + Raw Defaults Predefiniti Raw + Rectangular To Polar Da rettangolari a polari + Reduce Noise Riduci disturbo + Relink to File Ricollega al file + Render Video Esegui rendering video + Replace Color Sostituisci colore + Replace Contents Sostituisci contenuto + Replace Footage Sostituisci filmato + Resolve Missing Fonts Risolvi font mancanti + Ripple Increspatura + Rough Pastels Pastelli ruvidi + Sat Sat + Save a Copy Salva una copia + Save for Web (Legacy) Salva per Web (legacy) + Scale Effects Scala effetti + Script Events Manager Gestore eventi script + Search menus Cerca nei menu + Seed Seme + Select and Mask Seleziona e maschera + Set Measurement Scale Imposta scala misurazione + Shadows/Highlights Ombre/Luci + Shape Blur Sfocatura forma + Shear Inclina + Smart Blur Sfocatura avanzata + Smart Sharpen Contrasto intelligente + Smudge Stick Bastoncino sfumino + Solid Color Colore pieno + Spherize Sferizza + Spin Blur Sfocatura rotazione + Spot Faretto + Sprayed Strokes Tracce spruzzate + Strokes Tracce + Sumi-e Sumi-e + Surface Blur Sfocatura superficie + Texturizer Texturizzatore + Tiles Mattonelle + Tilt-Shift Miniatura + Trace Contour Traccia contorno + Trap Sovrastampa + Tree Albero + Trim Elimina bordi + Turbulent Turbolento + Twirl Vortice + Unsharp Mask Maschera di contrasto + Use File › Open to open an image. Usa File › Apri per aprire un'immagine. + Vanishing Point Punto di fuga + Variable Data Sets Set di dati variabili + Video Frames to Layers Fotogrammi video in livelli + WIA Support Supporto WIA + Warp Text Altera testo + Wind Vento + ZigZag ZigZag + Vertical Type Tool Strumento Testo verticale + About {app} Informazioni su {app} + Hide {app} Nascondi {app} + Quit {app} Esci da {app} + Services Servizi + Hide Others Nascondi altre app + Lut LUT + Cooling L B B Filtro freddo (LBB) + Cooling80 Filtro freddo (80) + Cooling82 Filtro freddo (82) + Hsb HSB + Hsl HSL + Movie Prime Focale fissa cinematografica + Prime105 Focale fissa 105 mm + Prime35 Focale fissa 35 mm + Rgb RGB + Warming L B A Filtro caldo (LBA) + Warming81 Filtro caldo (81) + Warming85 Filtro caldo (85) +@id filter.sharpen.sharpen Contrasta + Good Buona + Best Migliore + Adjustment Properties Proprietà regolazione + Default Foreground and Background Colors Colori di primo piano e sfondo predefiniti + Move Layer Sposta livello + Rectangular Selection Selezione rettangolare + Reorder Layer Riordina livello + Select Layer Seleziona livello + Set Colors Imposta colori + Switch Foreground and Background Colors Scambia colori di primo piano e sfondo + Target Destinazione + +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Crea tracciato di lavoro… + Create Vector Mask Crea maschera vettoriale + Make Selection… Crea selezione… + Fill Path… Riempi tracciato… + Clipping Path… Tracciato di ritaglio… + Free Transform Path Trasformazione libera tracciato + Unite Shapes at Overlap Unisci forme nella sovrapposizione + Subtract Shapes at Overlap Sottrai forme nella sovrapposizione + Copy Fill Copia riempimento + Copy Complete Stroke Copia traccia completa + Paste Fill Incolla riempimento + Paste Complete Stroke Incolla traccia completa + Make Symmetry Path Crea tracciato di simmetria + Disable Symmetry Path Disattiva tracciato di simmetria + Dark Gray Grigio scuro + Medium Gray Grigio medio + Select Custom Color… Seleziona colore personale… + Color Picker (Custom Canvas Color) Selettore colore (colore quadro personale) + Duplicate Channel Duplica canale + Delete Channel Elimina canale + Rename Channel… Rinomina canale… + Merge Spot Channel Unisci canale tinta piatta + Convert to Alpha Channel Converti in canale alfa + Exit Quick Mask Esci da maschera veloce + New Channel… Nuovo canale… + New Spot Channel… Nuovo canale tinta piatta… + Split Channels Dividi canali + Merge Channels… Unisci canali… + Color Indicates Masked Areas Il colore indica le aree mascherate + Color Indicates Selected Areas Il colore indica le aree selezionate + ✓ Color Indicates Masked Areas ✓ Il colore indica le aree mascherate + ✓ Color Indicates Selected Areas ✓ Il colore indica le aree selezionate + Up Su + To A + Shallow Superficiale + Deep Profonda + Blend Interior Effects as Group Fondi effetti interni come gruppo + Blend Clipped Layers as Group Fondi livelli ritagliati come gruppo + Transparency Shapes Layer Forme di trasparenza del livello + Layer Mask Hides Effects La maschera di livello nasconde gli effetti + Vector Mask Hides Effects La maschera vettoriale nasconde gli effetti + General Blending Fusione generale + This Layer: Questo livello: + Underlying Layer: Livello sottostante: + Nothing to paste: the clipboard holds no image Niente da incollare: gli appunti non contengono un'immagine + Preferences saved Preferenze salvate + Invalid panel layout; restored the default dock Disposizione dei pannelli non valida; ripristinata quella predefinita + Preferences were reset: {error} Le preferenze sono state reimpostate: {error} + Recovered {name} Recuperato: {name} + Couldn't save preferences: {error}. Retrying… Impossibile salvare le preferenze: {error}. Nuovo tentativo… + Autosave failed: {error} Salvataggio automatico non riuscito: {error} + Exported presets to {path} Predefiniti esportati in {path} + Ran script {name} Script eseguito: {name} + Sent to the printer ({spooler}) Inviato alla stampante ({spooler}) + Printed to {path} Stampato in {path} + Exported {path} ({size}) Esportato: {path} ({size}) + Exported {path} Esportato: {path} + Added swatch {name} Campione aggiunto: {name} + Step {step} ({id}) failed: {error} Passaggio {step} ({id}) non riuscito: {error} + Could not rename the action: {error} Impossibile rinominare l'azione: {error} + Action playback failed: {error} Riproduzione dell'azione non riuscita: {error} + Clone source set at {x}, {y} Sorgente clone impostata in {x}, {y} + Color management: {error} Gestione del colore: {error} + Opened {url} Aperto: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nessuna forma personale "{name}": scegline una nella barra delle opzioni o in Finestra › Forme + Saved a copy as {path} Copia salvata come {path} + Exported contents to {path} Contenuto esportato in {path} + {label} is not available: {reason} {label} non è disponibile: {reason} + Saved {path}; image assets: {n} in {dir} Salvato: {path}; risorse immagine: {n} in {dir} + {message} (notes: {n}; {warning}) {message} (note: {n}; {warning}) + Preferences can't be saved Impossibile salvare le preferenze + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft continua a riprovare; finché un salvataggio non riesce, le modifiche alle preferenze andranno perse alla chiusura. + GPU retry scheduled Nuovo tentativo GPU programmato + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Salva il lavoro e riavvia PhotoCraft per riprovare l'accelerazione GPU. Per questa sessione resta attivo il rendering su CPU. + GPU device was lost; using the CPU renderer. Dispositivo GPU perso; uso il rendering su CPU. + GPU error; using the CPU renderer. Errore GPU; uso il rendering su CPU. + Couldn't open {name}: {error} Impossibile aprire {name}: {error} + Toggle the Brush Settings panel Mostra/nascondi il pannello Impostazioni pennello + New Brush Preset Nuovo predefinito pennello + Grid view Vista a griglia + List view Vista a elenco + {verb} {mask} {layer} {verb}: {mask}, {layer} + Unlink Scollega + Link Collega + layer mask maschera di livello + vector mask maschera vettoriale + Commit transform Conferma trasformazione + Cancel transform Annulla trasformazione + Scroll menu up Scorri il menu in alto + Scroll menu down Scorri il menu in basso + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Per procedere il livello di testo deve essere rasterizzato. Il testo non sarà più modificabile. Rasterizzare il testo? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Per procedere il livello forma deve essere rasterizzato. Non sarà più modificabile come forma. Rasterizzare la forma? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Per procedere l'oggetto avanzato deve essere rasterizzato. Il contenuto non sarà più modificabile. Rasterizzare l'oggetto avanzato? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Per procedere il livello di riempimento deve essere rasterizzato. Il riempimento non sarà più modificabile. Rasterizzare il livello? + This layer must be rasterized before proceeding. Per procedere il livello deve essere rasterizzato. + Color Picker (Background Color) Selettore colore (colore sfondo) + Color Picker (Foreground Color) Selettore colore (colore primo piano) + Adwaita (Light) Adwaita (chiaro) + Adwaita (Dark) Adwaita (scuro) + Breeze Light Breeze chiaro + Breeze Dark Breeze scuro + Solarized Dark Solarized scuro + Open Recent is unavailable on the web Apri recenti non è disponibile sul web + Graphics adapter: {adapter} Scheda grafica: {adapter} + Backend: {backend} ({type}) Backend: {backend} ({type}) + Driver: {driver} Driver: {driver} + GPU backend preference: {backend} Backend GPU preferito: {backend} + Selected at launch: {backend} Scelto all'avvio: {backend} + Image compositor: {processor} Compositore immagine: {processor} + Fallback: {reason} Ripiego: {reason} + This session: {reason} Questa sessione: {reason} + Window renderer: {renderer} Renderer finestra: {renderer} + Software adapter Scheda software + Graphics adapter Scheda grafica + unknown sconosciuto + unknown type tipo sconosciuto + auto automatico + Platform: {os} {arch} Piattaforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} lettura precedente mantenuta; l'ultima rilettura non è riuscita: {reason} + Monitor profile: {profile} Profilo monitor: {profile} + Display profiles: {reason} Profili dello schermo: {reason} + Monitor profile in use on {display}: {profile} Profilo monitor in uso su {display}: {profile} + Monitor profile in use: {profile} Profilo monitor in uso: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Il quadro è mostrato come sRGB. Scegli un profilo monitor in Modifica › Impostazioni colore. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Il quadro su {display} è mostrato come sRGB. Scegli un profilo monitor in Modifica › Impostazioni colore. + Display profile not used Profilo dello schermo non usato + Monitor profile on {display} (main window): {profile} Profilo monitor su {display} (finestra principale): {profile} + Monitor profile on {display}: {profile} Profilo monitor su {display}: {profile} + manual manuale + fallback ripiego + The document does not have an embedded colour profile. Working space: {working}. Il documento non ha un profilo colore incorporato. Spazio di lavoro: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Il documento ha un profilo colore incorporato che non corrisponde allo spazio di lavoro.\nIncorporato: {embedded}\nDi lavoro: {working} + Importing {what} from {file}… Importazione di {what} da {file}… + brushes pennelli + gradients sfumature +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} pennello importato da {file}|{n} pennelli importati da {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} sfumatura importata da {file}|{n} sfumature importate da {file} + Data Set: {name} ({index}/{total}) Set di dati: {name} ({index}/{total}) + (none) (nessuno) +navigator Zoom Zoom + Apply {option}: {state} Applica {option}: {state} + on attivo + off disattivo + This adjustment ({key}) is kept as imported and can't be edited yet. Questa regolazione ({key}) resta come è stata importata e non si può ancora modificare. + Join the ArtCraft Discord ({url}) Unisciti al Discord di ArtCraft ({url}) + {index} of {total} {index} di {total} + {shortcut} assigned. {shortcut} assegnata. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. diff --git a/docs/localization-it.md b/docs/localization-it.md new file mode 100644 index 0000000000..d2cf0a516b --- /dev/null +++ b/docs/localization-it.md @@ -0,0 +1,62 @@ +# Italian terminology and dynamic UI labels + +This note goes with the completion of the Italian catalog (`crates/ui-egui/src/i18n/it.tsv`). +The catalog already covered every key `cargo xtask i18n-coverage` measures, but it lacked 1,287 +labels that only exist at runtime and that other catalogs (Brazilian Portuguese, Dutch, Czech) +already translate: filter and adjustment parameter names, Camera Raw panels, lighting effects, +History, Layers, path and channel menu labels, metadata fields, status messages, System Info and +dialog text. In Italian those showed in English. +They are now translated, and a review pass fixed mistranslations and inconsistent terms in the +existing entries. Translations are written from the meaning of PhotoCraft's English labels; no +proprietary translation resources, fonts, screenshots or user documents are included. + +## Register and style + +Commands are imperatives in the second person singular (*Elimina*, *Applica*, *Crea +pacchetto*); text addresses the user as *tu*. Only the first word is capitalised, except in +command names joined by a slash, which keep both capitals (*Luminosità/Contrasto*, +*HSL/Scala di grigio*). Decimals use a comma. + +## Terminology + +| English source | Italian | Note | +|---|---|---| +| Layer | Livello | | +| Canvas | Quadro | also in *Come il quadro*, *Applica ai limiti del quadro* | +| Preset | Predefinito | *Salva come predefinito* | +| Default(s) | Predefinito / Predefiniti | never *default* or *valori predefiniti* | +| Stroke | Traccia | contour and painterly strokes alike (*Tracce angolate*, *Direzione traccia*); a dash stays *Tratto* | +| Sharpen | Contrasta / Contrasto | also in Camera Raw (*Quantità contrasto*) | +| Noise | Disturbo | *Riduzione disturbo di luminanza*, *Riduzione disturbo cromatico* | +| Highlights | Luci | pairs with *Ombre* | +| Snapshot | Istantanea | | +| Smart Filters | Filtri avanzati | | +| Pattern | Pattern | *Timbro pattern* | +| Gradient stops | Punti della sfumatura | | +| Spot color | Tinta piatta | *Canale tinta piatta* | +| Click | clic | *Fai clic*, *{key}-clic*, *doppio clic* | +| Keyboard shortcut | Scorciatoia da tastiera | | +| Footage | Filmato | *Interpreta filmato*, *Sostituisci filmato* | +| Facet | Sfaccetta | | +| SemiBold / ExtraBold / ExtraLight | Semigrassetto / Extragrassetto / Extraleggero | one spelling for every weight | + +## Not covered + +Engine error and status messages, user document, layer and preset names, and automation +command IDs stay in English, as in every language. A few dynamic labels the other catalogs carry +(`Ask Before Saving Saved Embedded`, `Clear Selection`, the `Heal` family) are not used by the +current code; they are translated to stay aligned with the other catalogs. + +## Validation + +- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) + enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the + Korean test, and requires an Italian row for each. With the catalog on `main` before this change + it lists 388 missing labels; now none. +- `cargo test -p photocraft-ui-egui --lib`: the catalog tests pass. Two layout tests, + `type_controls_fill_the_panel_at_every_width_and_scale` and + `properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well + without this change. +- `cargo xtask i18n-coverage`: 1,726 / 1,726 for `it`. +- The offscreen `snapshot` example with `PHOTOCRAFT_LOCALE=it` rendered Lighting Effects, the + Camera Raw filter and Wind with Italian labels, without missing glyphs or clipped text. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e5ec2dbdad..8b0ebf40ab 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech review follow-ups) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian dynamic labels; previously minor: Czech review follow-ups) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), -[Simplified Chinese](localization-zh-hans.md). +[Simplified Chinese](localization-zh-hans.md), [Italian](localization-it.md). **How measured.** `cargo xtask i18n-coverage` (`xtask/src/i18n_coverage.rs`) defines the key universe: `tl!("…")` literals and `trn(` plurals in `ui-egui` (tests excluded), menu-catalog @@ -47,7 +47,8 @@ catalog). 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), -Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (2,411). +Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the +other catalogs translate, [notes](localization-it.md)). Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -77,3 +78,4 @@ engine localization or native review is complete. | 2026-10-10 | minor | Right-to-left shell layout (menu bar, title, options and status bars, toolbox and dock sides) | | 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English | | 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed | +| 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | From 8b216216717c7105df105bf1664e592daeb38cd6 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:43:01 +0200 Subject: [PATCH 040/523] Chisel bevels: Photoshop's facet slope and ramp ends (#3595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on Photoshop 27.11 (scripted: a 128 px square with one Bevel & Emboss, light from the top at 30°, sampled pixel by pixel across its edges; Chisel Hard and Soft, inner and outer, size 10 and 20, depth 100 and 300): - A chisel facet's slope is depth × size / (2 (size + 1)): it rises to the same depth × size / 2 as a Smooth bevel, over size + 1 px. PhotoCraft gave it the full depth, about twice as steep (facet 217 where Photoshop shows 188). - Inner Hard's ramp runs from half a pixel beyond the edge to size + ½, so the facet is flat right up to the edge pixel; Inner Soft starts a pixel further out (a steeper edge pixel) and is not blurred; Outer Hard ends a pixel further out; Outer Soft is a pixel shorter with a steeper edge pixel. Outer chisel bevels also shade the pixel where their ramp reaches zero (it still slopes), as emboss styles already did. All six inner and outer chisel cases are now within 1 level (they were 27 to 154 off); Smooth bevels are unchanged. Emboss and Pillow chisel are not modelled yet (they keep the blur). On the GPU: the same ramps in fs_mbevelh (Chisel Soft flag in p0.z), the emboss-only blur, and the outer reach-past flag in p3.x; the 44 GPU parity tests pass. Corpus: every Photoshop chisel oracle improves (bevel-inner-chisel-hard 20.1 -> 6.5 % differing pixels, outer-chisel- hard 13.6 -> 5.2 %, inner-chisel-soft 22.3 -> 9.3 %, emboss and pillow chisel a few points); pass counts unchanged. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/effects.rs | 92 ++++++++++++++++++++++++++++++----- crates/gpu/src/compose.wgsl | 34 ++++++++++--- crates/gpu/src/fx.rs | 7 ++- 3 files changed, 112 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index cc3272a14c..915eb3ddd3 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1455,7 +1455,9 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom { BevelGeom { paint, width, - depth: b.depth * if smooth { width } else { size } * sign, + // A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the + // edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`). + depth: b.depth * if smooth { width } else { size / 2.0 } * sign, pillow: b.style == BevelStyle::PillowEmboss, chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 }, } @@ -1476,10 +1478,13 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern let sharp = sharp_edges(shape); let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size)); let mut h = Map::new(shape.w, shape.h, 0.0); + let soft = g.chisel_soft > 0.0; for i in 0..h.v.len() { - h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i]); + h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft); } - if g.chisel_soft > 0.0 { + // Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel + // Hard's across a straight edge); emboss styles keep the blur until measured. + if soft && g.paint == BevelPaint::Both { tent(&mut h, g.chisel_soft); } h @@ -1521,17 +1526,46 @@ pub struct TextureCtx<'a> { } /// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`). -pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32) -> f32 { +/// +/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel +/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of +/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp +/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge +/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a +/// pixel further out. All within 1 level of Photoshop on those profiles. +pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 { + let ramp = size + 1.0; match paint { - BevelPaint::Outer => 1.0 - (dout / size).clamp(0.0, 1.0), - BevelPaint::Both => { - if din > 0.0 { - 0.5 + 0.5 * (din / (size / 2.0)).clamp(0.0, 1.0) + BevelPaint::Outer if !soft => { + let top = (size + 2.0) / ramp; + if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top } + } + // Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel. + BevelPaint::Outer => { + if dout > 0.0 { + 1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0) } else { - 0.5 - 0.5 * (dout / (size / 2.0)).clamp(0.0, 1.0) + (size + 2.0) / ramp + } + } + BevelPaint::Both => { + let half = size / 2.0 + 0.5; + if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) } + } + BevelPaint::Inner if soft => { + if din > 0.0 { + ((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp) + } else { + 0.0 + } + } + BevelPaint::Inner => { + if din > 0.0 { + ((din + 0.5) / ramp).clamp(0.0, 1.0) + } else { + 0.0 } } - BevelPaint::Inner => (din / size).clamp(0.0, 1.0), } } @@ -1549,7 +1583,9 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat // past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects' // emboss shadow runs one row past it), outer bevels don't (Photoshop oracle // bevel-outer-smooth). - let reach_past = g.paint == BevelPaint::Both; + // Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop + // 27.11 shades it, half strength, at `size + 1½` px out). + let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth); let on_bevel = |x: i64, y: i64| { hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS)) }; @@ -2334,6 +2370,40 @@ mod tests { assert!(at(&maps[0], 20, 15) < 0.05); } + /// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top + /// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the + /// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard + /// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out. + #[test] + fn chisel_facets_match_photoshop() { + let amount = |g: f32| { + let (se, ce) = 30f32.to_radians().sin_cos(); + ((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se) + }; + let n = 80; + let shape = square(n, 20, 60); + let maps = |style, technique| { + let mut b = bevel_of(style, technique); + b.size = 10.0; + bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0 + }; + let at = |m: &Map, y: usize| m.v[y * n + 40]; + let facet = amount(10.0 / 22.0); + let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard); + for y in 20..30 { + assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y)); + } + assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end"); + let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft); + assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20)); + assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet"); + let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard); + // Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½. + assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4); + assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70)); + assert_eq!(at(&outer[1], 72), 0.0); + } + #[test] fn emboss_paints_both_sides_and_pillow_flips_the_outside() { let shape = square(40, 10, 30); diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 6b5321fce5..5a3a6fe58e 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1178,7 +1178,7 @@ fn fs_mfinish(in: VOut) -> @location(0) vec4 { } // Chiselled bevel height from A = dist_inside, B = dist_outside (`effects::bevel_chisel_h`): -// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size). +// p0 = (paint (0 outer, 1 both / emboss, 2 inner), size, Chisel Soft). @fragment fn fs_mbevelh(in: VOut) -> @location(0) vec4 { let p = local(in.pos); @@ -1186,16 +1186,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4 { let dout = textureLoad(tex_b, p, 0).r; let size = op.p0.y; var h: f32; + // The ramp spans size + 1 px, from half a pixel beyond the edge (`effects::bevel_chisel_h`). + let ramp = size + 1.0; + let soft = op.p0.z > 0.5; switch i32(op.p0.x) { - case 0: { h = 1.0 - clamp(dout / size, 0.0, 1.0); } - case 1: { - if (din > 0.0) { - h = 0.5 + 0.5 * clamp(din / (size / 2.0), 0.0, 1.0); + case 0: { + if (soft) { + h = select((size + 2.0) / ramp, 1.0 - clamp((dout + 0.5) / ramp, 0.0, 1.0), dout > 0.0); } else { - h = 0.5 - 0.5 * clamp(dout / (size / 2.0), 0.0, 1.0); + let top = (size + 2.0) / ramp; + h = select(top, clamp((size + 1.5 - dout) / ramp, 0.0, top), dout > 0.0); + } + } + case 1: { + let half = size / 2.0 + 0.5; + if (din > 0.0) { + h = 0.5 + 0.5 * clamp(din / half, 0.0, 1.0); + } else { + h = 0.5 - 0.5 * clamp(dout / half, 0.0, 1.0); + } + } + default: { + if (soft) { + h = select(0.0, clamp((din + 1.5) / ramp, 0.0, (size + 2.0) / ramp), din > 0.0); + } else { + h = select(0.0, clamp((din + 0.5) / ramp, 0.0, 1.0), din > 0.0); } } - default: { h = clamp(din / size, 0.0, 1.0); } } return mout(h); } @@ -1209,7 +1226,8 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4 { // effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a // 4-neighbour's). var hmax = ra(p); - if (op.p1.y > 1.5) { + // p3.x: an outer chisel bevel, which also reaches one pixel past its ramp. + if (op.p1.y > 1.5 || op.p3.x > 0.5) { hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1)))); } let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5); diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index e3a6f5863d..13cea90af8 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -376,8 +376,10 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, } else { let din = b.field(FieldKind::BevelInside, size); let dout = b.field(FieldKind::BevelOutside, size); - let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, 0.0, 0.0], 0)); - if g.chisel_soft > 0.0 { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h } + let soft = g.chisel_soft > 0.0; + let h = b.push(stage(Kernel::MBevelH, Some(din), Some(dout), [paint(g.paint), size, f32::from(u8::from(soft)), 0.0], 0)); + // As `effects::bevel_height`: only emboss styles blur a Chisel Soft ramp. + if soft && g.paint == BevelPaint::Both { b.conv(h, photocraft_compose::effects::tent_kernel(g.chisel_soft)) } else { h } }; if let Some(c) = &bv.contour { // Contour element: the height through the contour (`bevel_contour_lut`). @@ -418,6 +420,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, for which in [0.0, 1.0] { let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1); s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))]; + s.p3 = [f32::from(u8::from(g.paint == BevelPaint::Outer && bv.technique != BevelTechnique::Smooth)), 0.0, 0.0, 0.0]; s.lut = lut.clone(); s.s = Some(In::Shape); s.out = Some(out); From 4736a4bcbea53b9c953c53b9c4a1fc7824cde014 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 10:52:23 +0200 Subject: [PATCH 041/523] Character panel: OpenType feature buttons (TYPE-218-5, #3752) (#3757) The Character panel (and the Properties Character section) gains Photoshop's row of eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals and Fractions. Each shows pressed while its feature is on at the selection (else the layer) and toggles it through the existing type.openType.* command, on the same scope as the other Character controls (the selected characters while typing, else every selected type layer). Scorecard TYPE-218-5 stays partial: the Numerals and Stylistic Sets submenus are still missing. Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/type_tool.rs | 98 +++++++++++++++++++++++++++ crates/ui-egui/src/type_tool_tests.rs | 45 ++++++++++++ docs/gaps.md | 8 ++- docs/scorecard.md | 2 +- scorecard/type.toml | 2 +- 5 files changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index d7015a37a3..e1e3573e04 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1783,6 +1783,102 @@ fn toggle_cell(ui: &mut egui::Ui, w: f32, on: bool, tip: &str, paint: impl FnOnc resp.on_hover_text(tl!(&tip)).clicked() } +/// The Character panel's OpenType buttons, in Photoshop's order: (feature tag, command id, +/// tooltip). The tooltips are the Type › OpenType menu labels. +pub(crate) const OPENTYPE_BUTTONS: [(&str, &str, &str); 8] = [ + ("liga", "type.openType.standardLigatures", "Standard Ligatures"), + ("calt", "type.openType.contextualAlternates", "Contextual Alternates"), + ("dlig", "type.openType.discretionaryLigatures", "Discretionary Ligatures"), + ("swsh", "type.openType.swash", "Swash"), + ("salt", "type.openType.stylisticAlternates", "Stylistic Alternates"), + ("titl", "type.openType.titlingAlternates", "Titling Alternates"), + ("ordn", "type.openType.ordinals", "Ordinals"), + ("frac", "type.openType.fractions", "Fractions"), +]; + +/// Photoshop's OpenType button row: each shows pressed while its feature is on at the type +/// target (the selection start, else the layer) and toggles it through the Type › OpenType +/// command, on the same scope as the other Character controls. +fn opentype_row(app: &mut PhotocraftApp, ui: &mut egui::Ui, c: &photocraft_doc::text::CharStyle, full: f32) { + let cw = cell_width(full, OPENTYPE_BUTTONS.len()); + let mut clicked = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + for (tag, id, tip) in OPENTYPE_BUTTONS { + let on = photocraft_engine::type_extra_cmds::feature_on(c, tag); + if toggle_cell(ui, cw, on, tip, |ui, r, col| opentype_glyph(ui.painter(), r, tag, col)) { + clicked = Some((id, on)); + } + } + }); + if let Some((id, on)) = clicked { + toggle_opentype(app, id, on); + } +} + +/// Runs an OpenType toggle command on the Character panel's scope: the edited layer's selected +/// characters (or its whole text) while typing, else every selected type layer. The new state +/// is explicit, so it follows the button the user saw rather than the layer's first run. +pub(crate) fn toggle_opentype(app: &mut PhotocraftApp, id: &str, shown_on: bool) { + let Some(mut p) = formatting_params(app) else { return }; + p["on"] = json!(!shown_on); + let _ = app.run(id, p); +} + +/// Small glyph labels for the OpenType buttons (drawn here, no Adobe artwork): fi, st, ct with +/// a ligature arc, a swash A, aa, T, 1st and ½. +fn opentype_glyph(p: &egui::Painter, r: egui::Rect, tag: &str, col: Color32) { + let font = egui::FontId::proportional(13.0); + let centred = |s: &str, font: egui::FontId| { + let g = p.layout_no_wrap(s.to_string(), font, col); + let pos = r.center() - g.size() / 2.0; + let gr = egui::Rect::from_min_size(pos, g.size()); + p.galley(pos, g, col); + gr + }; + match tag { + "liga" => { + centred("fi", font); + } + "calt" => { + centred("st", font); + } + "dlig" => { + let gr = centred("ct", font); + // The ligature's connecting arc from the c over to the t. + let a = egui::pos2(gr.left() + gr.width() * 0.3, gr.top() + gr.height() * 0.42); + let b = egui::pos2(gr.right() - gr.width() * 0.2, gr.top() + 4.0); + let mid = egui::pos2((a.x + b.x) / 2.0, gr.top() + 1.5); + p.line_segment([a, mid], Stroke::new(1.0, col)); + p.line_segment([mid, b], Stroke::new(1.0, col)); + } + "swsh" => { + let gr = centred("A", crate::theme::semibold(13.0)); + // A swash tail sweeping out under the letter. + let y = gr.bottom() - 3.0; + p.line_segment([egui::pos2(gr.left() - 3.0, y + 1.5), egui::pos2(gr.right() + 2.0, y - 0.5)], Stroke::new(1.0, col)); + } + "salt" => { + centred("aa", font); + } + "titl" => { + centred("T", crate::theme::semibold(13.0)); + } + "ordn" => { + let one = p.layout_no_wrap("1".into(), font, col); + let st = p.layout_no_wrap("st".into(), egui::FontId::proportional(9.0), col); + let left = r.center().x - (one.size().x + st.size().x) / 2.0; + let top = r.center().y - one.size().y / 2.0; + let st_x = left + one.size().x; + p.galley(egui::pos2(left, top), one, col); + p.galley(egui::pos2(st_x, top - 1.0), st, col); + } + _ => { + centred("½", font); + } + } +} + /// Width of each of `n` equal cells filling `avail` with 2 pt gaps. fn cell_width(avail: f32, n: usize) -> f32 { let n = n.max(1) as f32; @@ -1957,6 +2053,8 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa } }); ui.add_space(ROW_GAP); + opentype_row(app, ui, &c, full); + ui.add_space(ROW_GAP); } if paragraph && section(ui, "paragraph", tl!("Paragraph")) { use photocraft_doc::text::TextAlign as A; diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 46d763e5b9..a1c6415b95 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1455,6 +1455,51 @@ mod multi_layer_formatting { assert_eq!(text(&app, ids[0]).antialias, AntiAlias::Sharp); } + /// The Character panel's OpenType buttons (TYPE-218-5, #3752): Photoshop's eight, in its + /// order; each shows pressed while its feature is on and toggles it on the selected type + /// layers, or on the selected characters while typing. + #[test] + fn character_panel_opentype_buttons_toggle_and_show_the_feature() { + use egui::accesskit::Toggled; + use egui_kittest::kittest::NodeT; + use photocraft_engine::type_extra_cmds::feature_on; + let (app, ids) = fixture(); + let untouched = style(&app, ids[2], 0); + let mut h = controls(app, true); + let shown = |h: &Harness<'static, PhotocraftApp>, label: &str| h.get_by_label(label).accesskit_node().toggled(); + let order: Vec = h + .get_all_by_role(Role::Button) + .filter_map(|n| n.accesskit_node().label()) + .filter(|l| super::super::OPENTYPE_BUTTONS.iter().any(|(_, _, tip)| tip == l)) + .collect(); + let expected: Vec<&str> = super::super::OPENTYPE_BUTTONS.iter().map(|(_, _, tip)| *tip).collect(); + assert_eq!(order, expected, "every OpenType button, in Photoshop's order"); + assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::True), "ligatures are on by default"); + assert_eq!(shown(&h, "Fractions"), Some(Toggled::False)); + h.get_by_label("Fractions").click(); + h.run(); + for id in &ids[..2] { + assert!(feature_on(&style(h.state(), *id, 0), "frac"), "selected layers get the feature"); + } + assert_eq!(style(h.state(), ids[2], 0), untouched, "an unselected layer is untouched"); + h.run(); + assert_eq!(shown(&h, "Fractions"), Some(Toggled::True), "the button shows the feature on"); + h.get_by_label("Standard Ligatures").click(); + h.run(); + h.run(); + assert!(!style(h.state(), ids[0], 0).ligatures && !style(h.state(), ids[1], 0).ligatures); + assert_eq!(shown(&h, "Standard Ligatures"), Some(Toggled::False)); + // While typing, only the selected characters (1..3) change. + editing(h.state_mut(), ids[0]); + h.run(); + h.get_by_label("Swash").click(); + h.run(); + let swash = |h: &Harness<'static, PhotocraftApp>, ci| feature_on(&style(h.state(), ids[0], ci), "swsh"); + assert!(!swash(&h, 0) && swash(&h, 1) && swash(&h, 2) && !swash(&h, 3)); + h.run(); + assert_eq!(shown(&h, "Swash"), Some(Toggled::True), "shown at the selection start"); + } + /// Type › Anti-Alias and Type › Orientation check the edited (else selected) layer's value. #[test] fn type_menu_checks_the_current_anti_alias_and_orientation() { diff --git a/docs/gaps.md b/docs/gaps.md index 7937e66640..2ceba09952 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -76,8 +76,9 @@ what an agent can't finish alone. ### G8. Type depth - **Missing:** type on a path (TYPE-218-1), paragraph direction UI (TYPE-218-2), complex-script - fallback fonts and golden tests (TYPE-218-3), OpenType panel UI (TYPE-218-5), live Warp Text - preview (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type + fallback fonts and golden tests (TYPE-218-3), OpenType Numerals and Stylistic Sets submenus + (TYPE-218-5; the Character panel buttons landed 2026-10-11, #3752), live Warp Text preview + (TYPE-218-7, #2689), "rasterize and continue" for distort/perspective on type (TYPE-218-9), text free transform (#2630). Done since the re-measure: manual and optical kerning with PSD round trip (TYPE-218-6, #206), variable-font axes (TYPE-218-4, #2985). - **Evidence:** scorecard Type: 3 done, 2 partial, 4 missing. 2026-10-11: 2/3/4/5 clicks select @@ -227,6 +228,7 @@ what an agent can't finish alone. | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | +| 2026-10-11 | minor | G8: Character panel OpenType buttons landed (TYPE-218-5, #3752); Numerals and Stylistic Sets submenus still missing | | 2026-10-10 | minor | G2: recorded Tilt-Shift computation/filtered-proxy measurements and limits; gap and estimate remain open | | 2026-10-10 | minor | Checked against the alpha gate (no alpha blockers); #2374 marked closed | | 2026-10-10 | major | First version, from the full re-measure against Photoshop 2026 27.11.0 and the open issues | diff --git a/docs/scorecard.md b/docs/scorecard.md index 5b6275f0ea..8e8aa3080a 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -214,7 +214,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | -| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus | +| TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | | TYPE-218-7 | Warp Text dialog previews live and opens with the layer's current warp | missing | [#218](https://github.com/storytold/photocraft/issues/218) | view_cmds.rs:834 always seeds arc, bend 50 | | TYPE-218-8 | Text editing: select all, double/triple/quadruple-click selection, paste, with tests | done | [#218](https://github.com/storytold/photocraft/issues/218) | Like Photoshop, 2 clicks select the word, 3 the laid-out line (a column in vertical type), 4 the paragraph up to the hard return, 5 all text, and the next quick click starts over (#3493; type_tool.rs next_click_count, click_selection, multi_click). Tested through real canvas clicks in point, paragraph and vertical type, with paste replacing the selection and Cmd+A selecting all (type_tool_tests.rs multi_click_selects_word_line_paragraph_and_all_in_point_text, multi_click_separates_wrapped_lines_from_paragraphs, click_counts_cycle_and_restart, click_selection_without_layout_and_on_hostile_input, multi_click_without_a_session_does_nothing) | diff --git a/scorecard/type.toml b/scorecard/type.toml index ea1b35d6b6..81064ce8d3 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -37,7 +37,7 @@ id = "TYPE-218-5" target = "OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus" status = "partial" issue = 218 -note = "Type > OpenType toggles (type_extra_cmds.rs:512); no panel buttons or submenus" +note = "Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks" [[item]] id = "TYPE-218-6" From 6d21a69968aa0bf0c24cf03c536363751078e1ab Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Mon, 12 Oct 2026 16:53:36 +0800 Subject: [PATCH 042/523] Brush: symmetric live strokes update only what changed (#3593) (#3605) With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and composites only the coverage tiles that changed (new dabs, the smoothing tails now and before), leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once. --- crates/engine/src/brush_cmds.rs | 51 ++++++-------- crates/paint/src/brush_tests.rs | 85 ++++++++++++++++++++++ crates/paint/src/lib.rs | 2 +- crates/paint/src/render.rs | 121 ++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 2167cb1a0d..dd5c46608c 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -415,8 +415,9 @@ pub struct LiveStroke { symmetry: Option, mirrors: Vec, mirror_distinct: Vec, - /// Restore passes separately; their union may span an otherwise untouched document. - symmetry_regions: Vec, + /// What the symmetric preview drew last, once a mirror differs from the stroke, and how many + /// passes it merged (a mirror joining redraws every tile). + union_live: Option<(photocraft_paint::UnionLive, usize)>, pre: Surface, sel: Option, lock: bool, @@ -508,7 +509,7 @@ impl LiveStroke { symmetry, mirrors, mirror_distinct, - symmetry_regions: Vec::new(), + union_live: None, pre, sel, lock, @@ -567,33 +568,27 @@ impl LiveStroke { } let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; if self.mirror_distinct.iter().any(|d| *d) { - // Merge every finished pass once, both here and on commit, including shared axes. + // The stroke and its distinct mirrors composite as one stroke, as the commit + // merges them; only the tiles that changed are redrawn. let old = self.tail; - let mut original_preview = self.renderer.clone(); - original_preview.finish(); - let copies: Vec<_> = self - .mirrors - .iter() - .zip(&self.mirror_distinct) - .filter(|(_, d)| **d) - .map(|(r, _)| { - let mut r = r.clone(); - r.finish(); - r - }) + let passes = 1 + self.mirror_distinct.iter().filter(|d| **d).count(); + let (mut live, full, mut dmg) = match self.union_live.take() { + Some((live, merged)) => (live, merged != passes, Rect::EMPTY), + None => { + // Leaving the single-pass preview: take its smoothing tail back first. + if !old.is_empty() { + surf.write_region(old, &self.pre.read_region(old)); + } + (photocraft_paint::UnionLive::default(), true, old) + } + }; + let mut renderers: Vec<&mut StrokeRenderer> = std::iter::once(&mut self.renderer) + .chain(self.mirrors.iter_mut().zip(&self.mirror_distinct).filter(|(_, d)| **d).map(|(r, _)| r)) .collect(); - let bounds = copies.iter().fold(original_preview.bounds().union(&old), |b, r| b.union(&r.bounds())); - if self.symmetry_regions.is_empty() && !old.is_empty() { - surf.write_region(old, &self.pre.read_region(old)); - } - for region in &self.symmetry_regions { - surf.write_region(*region, &self.pre.read_region(*region)); - } - self.symmetry_regions = - std::iter::once(original_preview.bounds()).chain(copies.iter().map(StrokeRenderer::bounds)).filter(|r| !r.is_empty()).collect(); - let damage = original_preview.composite_union_many(&copies, &self.pre, surf, self.sel.as_ref(), self.lock); - self.tail = bounds; - return Ok(damage.union(&bounds)); + dmg = dmg.union(&StrokeRenderer::composite_union_live(&mut renderers, &mut live, full, &self.pre, surf, self.sel.as_ref(), self.lock)); + self.tail = old.union(&live.bounds()); + self.union_live = Some((live, passes)); + return Ok(dmg); } let mut dmg = Rect::EMPTY; let old = std::mem::replace(&mut self.tail, Rect::EMPTY); diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 61772e4ea1..a68f84f1f7 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -857,6 +857,91 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() { assert!(r.tail_preview().is_none()); } +#[test] +fn live_union_of_passes_matches_merging_the_finished_passes_after_every_chunk() { + // Symmetric painting previews the stroke and its mirrors with `composite_union_live`, which + // redraws only changed tiles; after every chunk the target must be exactly what finishing + // every pass and merging them with `composite_union_many` gives (the commit takes these pixels). + let base = BrushSettings { size: 14.0, hardness: 0.6, opacity: 0.6, ..brush() }; + let mut brushes = vec![base.clone()]; + brushes.push(BrushSettings { smoothing: Smoothing { amount: 0.7, ..Default::default() }, ..base.clone() }); + let mut b = base.clone(); + b.dual_brush.enabled = true; + b.dual_brush.size = 6.0; + b.dual_brush.scatter = 1.5; + brushes.push(b); + let mut b = base.clone(); + b.color_dynamics.enabled = true; + b.color_dynamics.hue_jitter = 0.5; + brushes.push(b); + brushes.push(BrushSettings { wet_edges: true, ..base.clone() }); + brushes.push(BrushSettings { mode: BlendMode::Dissolve, ..base.clone() }); + brushes.push(BrushSettings { erase: true, ..base.clone() }); + let mut b = base.clone(); + b.texture.enabled = true; + b.texture.each_tip = false; + brushes.push(b); + let stroke: Vec = (0..40).map(|i| StrokePoint::new(30.0 + i as f64 * 3.7, 40.0 + 25.0 * (i as f64 / 5.0).sin(), 1.0)).collect(); + // Mirrors about x = 100 and y = 75; the first point sits near the vertical axis so passes overlap. + let mirror = |fx: bool, fy: bool| -> Vec { + stroke.iter().map(|p| StrokePoint::new(if fx { 200.0 - p.x } else { p.x }, if fy { 150.0 - p.y } else { p.y }, p.pressure)).collect() + }; + let paths = [stroke.clone(), mirror(true, false), mirror(false, true), mirror(true, true)]; + let area = Rect::new(-20, -20, 240, 190); + let mut sel = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false)); + let sel_rect = Rect::new(0, 0, 160, 120); + sel.write_region(sel_rect, &(0..160 * 120).map(|i| ((i % 160) as f32 / 120.0).min(1.0)).collect::>()); + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat::new(ColorMode::Rgb, sample, true); + let mut pre = Surface::new(fmt); + let fill = Rect::new(10, 10, 190, 140); + let n = fmt.channels(); + let px: Vec = + (0..fill.width() * fill.height()).flat_map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.8].into_iter().take(n)).collect(); + pre.write_region(fill, &px); + for (bi, b) in brushes.iter().enumerate() { + // (passes included at each chunk, selection): two passes from the start; four, of + // which two join part-way. + for (four, selection) in [(false, None), (true, None), (true, Some(&sel))] { + let mut renderers: Vec = (0..4).map(|_| StrokeRenderer::new(b, Some(fmt), 1.0)).collect(); + let mut live = crate::render::UnionLive::default(); + let mut target = pre.clone(); + let (mut at, mut chunk, mut merged) = (0, 1, 0); + while at < stroke.len() { + let end = (at + chunk).min(stroke.len()); + for (r, path) in renderers.iter_mut().zip(&paths) { + r.push(&path[at..end]); + } + let count = if four && at >= 12 { 4 } else { 2 }; + let full = merged != count; + merged = count; + let mut passes: Vec<&mut StrokeRenderer> = renderers.iter_mut().take(count).collect(); + StrokeRenderer::composite_union_live(&mut passes, &mut live, full, &pre, &mut target, selection, false); + let finished: Vec = renderers + .iter() + .take(count) + .map(|r| { + let mut r = r.clone(); + r.finish(); + r + }) + .collect(); + let mut expected = pre.clone(); + finished[0].composite_union_many(&finished[1..], &pre, &mut expected, selection, false); + assert!( + target.read_region(area) == expected.read_region(area), + "{sample:?}, brush {bi}, four {four}, selection {}, points ..{end}", + selection.is_some() + ); + assert_eq!(live.bounds(), finished.iter().fold(Rect::EMPTY, |r, f| r.union(&f.bounds()))); + at = end; + chunk = chunk % 3 + 1; + } + } + } + } +} + // ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ---------- /// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre. diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 74e4389f1e..c5d065249f 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -37,7 +37,7 @@ pub use brush::{ SectionLocks, ShapeDynamics, Smoothing, Texture, TipShape, Transfer, }; pub use mixer::MixerSettings; -pub use render::{BrushContext, StrokeRenderer, grid_center, grid_square, render_stroke}; +pub use render::{BrushContext, StrokeRenderer, UnionLive, grid_center, grid_square, render_stroke}; pub use tile::{GrayTile, StoredTile}; /// One input sample. Missing stylus data defaults to "mouse": full pressure, no tilt/rotation. diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index aa6bd0b963..a92b75fbda 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -491,6 +491,13 @@ impl CoverageMap { v } + /// The finished coverage over the tiles `keys`: `tail`'s copy of a tile where finishing the + /// stroke changes it (see [`StrokeRenderer::tail_preview`]), else this map's own. + fn finished_subset(&self, tail: Option<&Self>, keys: &HashSet<(i32, i32)>) -> Self { + let tile = |k: &(i32, i32)| tail.and_then(|t| t.tiles.get(k)).or_else(|| self.tiles.get(k)).map(|t| (*k, t.clone())); + Self { tiles: keys.iter().filter_map(tile).collect(), nc: self.nc, bounds: tail.map_or(self.bounds, |t| t.bounds), dirty: HashSet::new() } + } + /// Union two passes of the same brush stroke without applying opacity twice where they /// overlap. The stronger coverage wins, including its per-dab colour when present. fn union_max(&mut self, other: &Self) { @@ -527,6 +534,21 @@ impl CoverageMap { // Stroke renderer // --------------------------------------------------------------------------- +/// What [`StrokeRenderer::composite_union_live`] drew last: the coverage tiles of the passes' +/// smoothing tails (redrawn without them next time) and the merged bounds. +#[derive(Clone, Debug, Default)] +pub struct UnionLive { + tail_keys: HashSet<(i32, i32)>, + bounds: Rect, +} + +impl UnionLive { + /// The merged bounds of the finished passes, as last drawn. + pub fn bounds(&self) -> Rect { + self.bounds + } +} + /// Incremental stroke rendering: feed points, composite dirty tiles from the pre-stroke pixels. #[derive(Clone, Debug)] pub struct StrokeRenderer { @@ -570,6 +592,105 @@ impl StrokeRenderer { } merged.composite(pre, target, selection, lock_transparency, true) } + + /// Coverage tiles (stroke and Dual Brush) changed since the last call or composite, into + /// `keys`; forgets them. + fn drain_dirty_into(&mut self, keys: &mut HashSet<(i32, i32)>) { + keys.extend(self.cov.dirty.drain()); + if let Some(d) = self.dual.as_mut() { + keys.extend(d.dirty.drain()); + } + } + + /// Every coverage tile (stroke and Dual Brush), into `keys`. + fn keys_into(&self, keys: &mut HashSet<(i32, i32)>) { + keys.extend(self.cov.tiles.keys()); + if let Some(d) = &self.dual { + keys.extend(d.tiles.keys()); + } + } + + /// Live preview of a stroke painted as several passes (the stroke, then its symmetry + /// mirrors): leaves `target` as finishing every pass now and compositing them with + /// [`composite_union_many`](Self::composite_union_many) over `pre` would, but redraws only the + /// coverage tiles that changed since the previous call (new dabs, and the smoothing tail now + /// and before). `full` redraws every tile: on the first call, or when a pass joins. Returns + /// the damaged rectangle. + pub fn composite_union_live( + passes: &mut [&mut Self], + live: &mut UnionLive, + full: bool, + pre: &Surface, + target: &mut Surface, + selection: Option<&Surface>, + lock_transparency: bool, + ) -> Rect { + let mut keys = std::mem::take(&mut live.tail_keys); + for p in passes.iter_mut() { + p.drain_dirty_into(&mut keys); + if full { + p.keys_into(&mut keys); + } + } + let tails: Vec<_> = passes.iter().map(|p| p.tail_preview()).collect(); + let mut tail_keys = HashSet::new(); + for t in tails.iter().flatten() { + t.keys_into(&mut tail_keys); + } + keys.extend(&tail_keys); + let mut merged: Option = None; + for (p, t) in passes.iter().zip(&tails) { + let cov = p.cov.finished_subset(t.as_ref().map(|t| &t.cov), &keys); + let dual = p.dual.as_ref().map(|d| d.finished_subset(t.as_ref().and_then(|t| t.dual.as_ref()), &keys)); + match merged.as_mut() { + None => { + merged = Some(Self { + ctx: p.ctx.clone(), + generator: p.generator.clone(), + cov, + dual, + fmt: p.fmt, + per_dab_color: p.per_dab_color, + dabs_done: p.dabs_done, + scratch: Vec::new(), + depth_scratch: Vec::new(), + dab_buf: Vec::new(), + dual_buf: Vec::new(), + all_dabs: None, + }) + } + Some(m) => { + m.cov.union_max(&cov); + if let (Some(to), Some(from)) = (&mut m.dual, &dual) { + to.union_max(from); + } + } + } + } + let Some(mut merged) = merged else { return Rect::EMPTY }; + let (before, after) = (live.bounds, merged.bounds()); + // Back to the pre-stroke pixels where the previous call drew and nothing covers now. + let mut keys: Vec<_> = keys.into_iter().collect(); + keys.sort_unstable(); + let mut dmg = Rect::EMPTY; + let mut region = Vec::new(); + for &(tx, ty) in &keys { + let tile = Rect::new(tx * COV_TILE, ty * COV_TILE, (tx + 1) * COV_TILE, (ty + 1) * COV_TILE); + let old = tile.intersect(&before); + if !old.is_empty() { + pre.read_region_into(old, &mut region); + target.write_region(old, ®ion); + dmg = dmg.union(&old); + } + if merged.cov.tiles.contains_key(&(tx, ty)) { + dmg = dmg.union(&tile.intersect(&after)); + } + } + merged.composite(pre, target, selection, lock_transparency, true); + live.tail_keys = tail_keys; + live.bounds = after; + dmg + } /// `fmt` = target pixel format (needed for per-dab colour); `zoom` for smoothing. pub fn new(brush: &BrushSettings, fmt: Option, zoom: f32) -> Self { let per_dab_color = brush.color_dynamics.enabled && !brush.erase && fmt.is_some(); From 9f49cae0934a8a82c15b0e4312b28dc015d43497 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 11:04:13 +0200 Subject: [PATCH 043/523] Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611) layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only (the shell adds the target when the Layers panel targets the mask), live preview included. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/channel_cmds.rs | 3 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 88 ++++++++++++++++++++++++++- crates/ui-egui/src/move_ui.rs | 35 ++++++++++- 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 5fd7e5c97e..a85ff75b78 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -608,7 +608,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option bool { // Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's. - routed(id) || id == "select.float" + // Moving a layer with its unlinked mask targeted moves the mask alone (`layer.translate`). + routed(id) || id == "select.float" || id == "layer.translate" } /// Commands whose target follows the Channels panel. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index f12e017c40..2ce0b3762f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -878,7 +878,7 @@ fn build() -> Vec { "Move Layer", [], None, - r##"{"layer":id?,"dx":i32,"dy":i32} (no layer: every selected layer; linked layers follow)"##, + r##"{"layer":id?,"dx":i32,"dy":i32,"target":"pixels"|"mask"="pixels"} (no layer: every selected layer; linked layers follow; target mask: a layer whose mask is unlinked moves that mask alone, a linked mask moves with the layer)"##, has_layer, crate::layer_multi_cmds::translate ), diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 31dc49d09c..7f3cc09616 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -283,7 +283,14 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> Ok(()) } +/// Does a move aimed at the layer mask (`"target":"mask"`) move `l`'s mask alone? Only an +/// unlinked mask does (the chain off); a linked one moves with its layer, as in Photoshop. +pub fn moves_mask_alone(l: &Layer) -> bool { + l.mask.as_ref().is_some_and(|m| !m.linked) +} + /// `layer.translate`: the explicit layer, or every selected layer, plus their linked layers. +/// With `"target":"mask"` a layer whose mask is unlinked moves that mask alone. pub fn translate(s: &mut Session, p: &Value) -> Result { let dx = int_i32("layer.translate", p, "dx")?.unwrap_or(0); let dy = int_i32("layer.translate", p, "dy")?.unwrap_or(0); @@ -297,14 +304,27 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if roots.is_empty() { return Err(EngineError::Other("no active layer".into())); } + let mask_target = p.get("target").and_then(Value::as_str) == Some("mask"); let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let ids = s.edit("Move", |doc, _| { - let ids = move_targets(doc, &roots); - if ids.is_empty() { + let (alone, rest): (Vec, Vec) = roots.iter().partition(|id| mask_target && doc.layer(**id).is_some_and(moves_mask_alone)); + let mut ids = move_targets(doc, &rest); + if ids.is_empty() && alone.is_empty() { return Err(EngineError::NoLayer(roots[0])); } let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect(); move_layers(doc, &moves)?; + for id in alone { + let locks = doc.effective_locks(id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if locks.position || locks.all { + return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name))); + } + if let Some(m) = &mut l.mask { + m.surface = shift_surface(&m.surface, dx, dy); + } + ids.push(id); + } // A board moved past any canvas edge grows the canvas, as a new one does (#1531, #3070). if ids.iter().any(|id| doc.layer(*id).is_some_and(|l| l.artboard().is_some())) { crate::artboard_cmds::fit_canvas(doc); @@ -326,6 +346,11 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec { /// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs /// copying the moving pixels only. pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result { + moved_target(doc, ids, dx, dy, false) +} + +/// [`moved`] for a drag aimed at the layer mask (`mask_target`): unlinked masks move alone. +pub fn moved_target(doc: &Document, ids: &[LayerId], dx: i32, dy: i32, mask_target: bool) -> Result { let mut out = doc.clone(); for &id in ids { let locks = doc.effective_locks(id); @@ -333,6 +358,12 @@ pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result 0.9, "{depth}-bit: unlinked mask stays"); + assert!(mval(&s, 44, 14) < 0.1); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.layerMask.linked", json!({"linked": true})).unwrap(); + s.execute("layer.translate", json!({"dx": 30, "dy": 0})).unwrap(); + assert!(mval(&s, 44, 14) > 0.9, "{depth}-bit: linked mask moves"); + } + } + + /// Aimed at the mask, a move shifts an unlinked mask alone and leaves a linked one with its layer. + #[test] + fn translate_with_the_mask_targeted_moves_an_unlinked_mask_alone() { + for depth in [8, 16] { + let mut s = session(depth); + let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + s.edit("mask", |doc, _| { + doc.layer_mut(a).unwrap().mask.as_mut().unwrap().surface.fill_rect(Rect::new(12, 12, 18, 18), &[1.0]); + Ok(()) + }) + .unwrap(); + let mval = |s: &Session, x, y| doc(s).layer(a).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0); + // Linked: the layer and its mask move together. + s.execute("layer.translate", json!({"dx": 30, "dy": 0, "target": "mask"})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit"); + assert!(mval(&s, 44, 14) > 0.9); + s.execute("layer.layerMask.linked", json!({"linked": false})).unwrap(); + s.execute("layer.translate", json!({"dx": -30, "dy": 0, "target": "mask"})).unwrap(); + assert_eq!(bounds(&s, a), Rect::new(40, 10, 50, 20), "{depth}-bit: the layer stays"); + assert!(mval(&s, 14, 14) > 0.9, "the mask moved"); + assert!(mval(&s, 44, 14) < 0.1); + s.execute("edit.undo", json!({})).unwrap(); + assert!(mval(&s, 44, 14) > 0.9, "undo puts the mask back"); + } + } + #[test] fn moves_and_layer_props_report_only_their_area() { let mut s = session(8); diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index c99c230384..5c2f2e6c60 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -114,6 +114,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option<(Arc { // Duotone documents display through their inks. let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d); @@ -298,6 +299,38 @@ mod tests { assert_eq!(r, Rect::new(0, 0, 64, 64)); } + /// Dragging with an unlinked mask targeted moves the mask alone, shown live and committed; + /// with the mask linked (or the pixels targeted) the layer moves. + #[test] + fn dragging_with_an_unlinked_mask_targeted_moves_the_mask_alone() { + for (linked, mask_target) in [(false, true), (false, false), (true, true)] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.sync_views(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 16, "height": 16})).unwrap(); + app.session.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.session.execute("select.deselect", json!({})).unwrap(); + app.session.execute("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.execute("layer.layerMask.linked", json!({"linked": linked})).unwrap(); + app.ui.mask_target = mask_target; + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 16.0, y: 16.0, pressure: 1.0 }, m); + crate::canvas::tool_event(&mut app, ToolEvent::Move { x: 36.0, y: 16.0, pressure: 1.0 }, m); + let shown = super::display_doc(&mut app, 0).unwrap().0; + let id = app.session.active().unwrap().active_layer.unwrap(); + let layer_moved = |d: &photocraft_doc::Document| d.layer(id).and_then(|l| l.surface()).is_some_and(|s| s.pixel(28, 16)[3] > 0.5); + let alone = !linked && mask_target; + assert_eq!(layer_moved(&shown), !alone, "preview, linked {linked} mask target {mask_target}"); + crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 36.0, y: 16.0 }, m); + let d = &app.session.active().unwrap().doc; + assert_eq!(layer_moved(d), !alone, "commit, linked {linked} mask target {mask_target}"); + } + } + /// Mid-drag on a painted layer: is the layer shown at the pointer? fn live(outline: bool) -> bool { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); From a2cdc6c2d70c17d0d5e51817a6e199344f5e2ecd Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:13:48 -0400 Subject: [PATCH 044/523] fix(ui): drag a document tab off the strip to float it in its own window (#3733) (#3785) Dragging a document tab more than 24 pt above or below the tab strip now tears it off into a separate OS window placed where it was dropped, so it can be moved anywhere, other screens included (as in Photoshop). Sideways drags still reorder. Both tab strips share the new tab_drag helper. window.arrange.floatInWindow takes optional `document` and `position` params (validated: bad values are errors, not panics); DocWindow gains a serde-default `position`. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 148 +++++++++++++++++++++----- crates/ui-egui/src/file_open/tests.rs | 3 +- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/view_cmds.rs | 37 ++++++- crates/ui-egui/src/view_cmds/tests.rs | 26 +++++ 6 files changed, 189 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 54e32bd862..179de51244 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1762,6 +1762,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let mut close = None; let mut tab_action = None; let mut tab_move = None; + let mut tear_off = None; let (mut focus_open, mut cancel_open) = (None, None); let focused_open = app.jobs.focus.is_some(); // Layers dragged over a tab show its document (`layer_transfer`). @@ -1851,18 +1852,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } else if resp.clicked() { activate = Some(i); } - // Drag a tab sideways to reorder it (#217); a line shows where it will land. - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() - { - tab_drop_line(ui, &placed, p.x, &t); - } - if resp.drag_stopped() { - let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x); - let to = tab_move_target(i, tab_drop_index(&placed, x)); - if to != i { - tab_move = Some((i, to)); - } + match tab_drag(ui, &resp, i, r, row, &placed, &t) { + Some(TabDrop::Move(to)) => tab_move = Some((i, to)), + Some(TabDrop::Float(at)) => tear_off = Some((i, at)), + None => {} } let has_path = st.path.is_some(); resp.context_menu(|ui| { @@ -1925,6 +1918,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { app.ui.status = e; app.ui.status_error = true; } + float_torn_off(app, ui.ctx(), tear_off); if let Some((id, params)) = tab_action && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) { @@ -1968,6 +1962,70 @@ fn tab_context_menu(ui: &mut egui::Ui, index: usize, count: usize, has_path: boo /// Where a tab dragged to `x` would land: before the first shown tab whose middle is right of /// `x`, else after the last shown tab (its document index + 1), like [`TabStrip::slot`]. +/// How far (points) above or below the tab strip a dragged tab must be to tear off (#3733). +const TEAR_OFF: f32 = 24.0; + +/// Whether a tab dragged to `p` has left the strip and floats into its own window, as in +/// Photoshop; sideways drags along the strip still reorder (#217). +fn torn_off(strip: Rect, p: egui::Pos2) -> bool { + p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF +} + +/// What a finished document-tab drag asks for. +#[derive(Clone, Copy, Debug, PartialEq)] +enum TabDrop { + /// Reorder the tab to this index. + Move(usize), + /// Float the document in its own OS window at this screen position (points), when known. + Float(Option<[f32; 2]>), +} + +/// Drag a document tab: along the strip it reorders (a line shows where it will land); off the +/// strip it tears off into a floating window that can go anywhere, other screens included +/// (an outline under the pointer shows it). Returns the drop once the drag ends. +fn tab_drag(ui: &egui::Ui, resp: &egui::Response, i: usize, r: Rect, strip: Rect, placed: &[(usize, Rect)], t: &crate::theme::Tokens) -> Option { + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos() + { + if torn_off(strip, p) { + let ghost = Rect::from_min_size(p - egui::vec2(40.0, 12.0), egui::vec2(240.0, 160.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.id().with("tab-tear-off"))); + painter.rect_filled(ghost, t.radius_sm, t.accent.gamma_multiply(0.12)); + painter.rect_stroke(ghost, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } else { + tab_drop_line(ui, placed, p.x, t); + } + } + if !resp.drag_stopped() { + return None; + } + let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())); + if let Some(p) = p.filter(|&p| torn_off(strip, p)) { + // The pointer is in this window's points; the OS wants screen coordinates. Keep the new + // window's title bar under the pointer, as the ghost showed. + let at = ui.ctx().input(|i| i.viewport().inner_rect).map(|inner| { + let s = inner.min + p.to_vec2() - egui::vec2(40.0, 12.0); + [s.x, s.y] + }); + return Some(TabDrop::Float(at)); + } + let to = tab_move_target(i, tab_drop_index(placed, p.map_or(r.center().x, |p| p.x))); + (to != i).then_some(TabDrop::Move(to)) +} + +/// Float a torn-off tab's document in its own window (`window.arrange.floatInWindow`). +fn float_torn_off(app: &mut PhotocraftApp, ctx: &egui::Context, tear_off: Option<(usize, Option<[f32; 2]>)>) { + let Some((doc, at)) = tear_off else { return }; + let params = match at { + Some(at) => json!({"document": doc, "position": at}), + None => json!({"document": doc}), + }; + if let Err(e) = crate::menus::invoke(app, ctx, "window.arrange.floatInWindow", params) { + app.ui.status = e; + app.ui.status_error = true; + } +} + fn tab_drop_index(placed: &[(usize, Rect)], x: f32) -> usize { match placed.iter().find(|(_, r)| r.center().x >= x) { Some(&(i, _)) => i, @@ -2051,6 +2109,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { let (mut activate, mut close) = (None, None); let mut tab_action = None; let mut tab_move = None; + let mut tear_off = None; let tab_count = app.session.documents().len(); let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx()); let mut drag_over = None; @@ -2111,18 +2170,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { } else if resp.clicked() { activate = Some(i); } - // Drag a tab sideways to reorder it (#217); a line shows where it will land. - if resp.dragged() - && let Some(p) = resp.interact_pointer_pos() - { - tab_drop_line(ui, &placed, p.x, &t); - } - if resp.drag_stopped() { - let x = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.interact_pos())).map_or(r.center().x, |p| p.x); - let to = tab_move_target(i, tab_drop_index(&placed, x)); - if to != i { - tab_move = Some((i, to)); - } + match tab_drag(ui, &resp, i, r, strip, &placed, &t) { + Some(TabDrop::Move(to)) => tab_move = Some((i, to)), + Some(TabDrop::Float(at)) => tear_off = Some((i, at)), + None => {} } let has_path = app.session.documents().get(i).is_some_and(|d| d.path.is_some()); resp.context_menu(|ui| { @@ -2179,6 +2230,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip { app.ui.status = e; app.ui.status_error = true; } + float_torn_off(app, ui.ctx(), tear_off); if let Some((id, params)) = tab_action && let Err(e) = crate::menus::invoke(app, ui.ctx(), id, params) { @@ -5047,7 +5099,10 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(st) = app.session.documents().get(w.document) else { continue }; let title = format!("{} — window {}", st.doc.name, w.id); let vid = egui::ViewportId::from_hash_of(("docwin", w.id)); - let builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]); + let mut builder = egui::ViewportBuilder::default().with_title(title).with_inner_size([800.0, 600.0]); + if let Some([x, y]) = w.position { + builder = builder.with_position([x, y]); + } let mut view = w.view.clone(); let mut close = false; ctx.show_viewport_immediate(vid, builder, |ui, _class| { @@ -5704,6 +5759,49 @@ mod tests { } } + /// Dragging a tab off the strip floats its document in its own OS window instead of + /// reordering (#3733), in both strips; a short wobble below the strip still reorders. + #[test] + fn dragging_a_tab_off_the_strip_floats_it_in_a_window() { + use egui_kittest::kittest::Queryable; + let strip = Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(700.0, 26.0)); + assert!(!torn_off(strip, egui::pos2(50.0, 13.0))); + assert!(!torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF - 1.0)), "a wobble below the strip still reorders"); + assert!(torn_off(strip, egui::pos2(50.0, 26.0 + TEAR_OFF + 1.0))); + assert!(torn_off(strip, egui::pos2(50.0, -TEAR_OFF - 1.0)), "dragged up, off the window's top"); + for kind in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::Pro] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for name in ["a", "b", "c"] { + app.run("file.new", json!({"width": 8, "height": 8, "name": name})).unwrap(); + } + app.sync_views(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(700.0, 300.0)).build_ui_state( + |ui, (app, ready): &mut (PhotocraftApp, bool)| { + if *ready { + tabs(app, ui); + } + }, + (app, false), + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.state_mut().1 = true; + h.run_steps(2); + let b = h.get_by_label_contains("b").rect(); + let target = egui::pos2(b.center().x, b.bottom() + 150.0); + h.drag_at(b.center()); + h.run_steps(1); + h.hover_at(target); + h.run_steps(1); + h.drop_at(target); + h.run_steps(2); + let app = &h.state().0; + let names: Vec<&str> = app.session.documents().iter().map(|d| d.doc.name.as_str()).collect(); + assert_eq!(names, ["a", "b", "c"], "{kind:?}: tearing off doesn't reorder"); + assert_eq!(app.ui.windows.iter().map(|w| w.document).collect::>(), [1], "{kind:?}: b floats in its own window"); + assert_eq!(app.session.active_index(), Some(1), "{kind:?}: the floated document is active"); + } + } + #[test] fn clone_overlay_is_circular_and_respects_visibility() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 3e6bde28b2..d49ab58206 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -616,7 +616,8 @@ fn views_and_windows_stay_with_their_documents() { for (i, v) in app.ui.views.iter_mut().enumerate() { v.zoom = i as f32 + 1.0; } - app.ui.windows = (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true }).collect(); + app.ui.windows = + (0..4).map(|document| crate::state::DocWindow { id: document as u64, document, view: Default::default(), open: true, position: None }).collect(); let zooms = |app: &PhotocraftApp| app.ui.views.iter().map(|v| v.zoom).collect::>(); let windows = |app: &PhotocraftApp| app.ui.windows.iter().map(|w| (w.id, w.document)).collect::>(); assert_eq!(app.run("document.move", json!({"document": 0, "to": 3})).unwrap(), json!({"document": 3})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 690bf6be5a..b5b72e93a5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -343,7 +343,7 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: let doc = app.session.active_index().ok_or("no document")?; let wid = app.ui.alloc_id(); let view = app.ui.views[doc].clone(); - app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true }); + app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None }); Ok(json!({"window": wid})) } "window.theme.toggle" => { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8d84b55157..3b7b02d8f5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -494,6 +494,10 @@ pub struct DocWindow { pub document: usize, pub view: View, pub open: bool, + /// Where the window opens on screen (outer top-left, in points); `None` lets the OS place it. + /// A tab dragged off the tab strip opens its window where it was dropped (#3733). + #[serde(default)] + pub position: Option<[f32; 2]>, } /// Options-bar state for tools (Photoshop keeps these per tool). diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 7f8d4024c9..f5b6f60bdd 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -577,7 +577,7 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res return Ok(json!(k)); } if let Some(k) = id.strip_prefix("window.arrange.") { - return arrange(app, k); + return arrange(app, k, p); } match id { "view.extras" => { @@ -643,15 +643,23 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result { Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]})) } +/// A finite `[x, y]` screen position within a sane range (window systems reject wild values). +fn screen_position(v: &Value) -> Option<[f32; 2]> { + const LIMIT: f64 = 1.0e6; + let a = v.as_array().filter(|a| a.len() == 2)?; + let n = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= LIMIT).map(|x| x as f32); + Some([n(0)?, n(1)?]) +} + pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 { let wid = app.ui.alloc_id(); let mut view = app.ui.views.get(doc).cloned().unwrap_or_default(); view.fit_pending = offset > 0 || view.fit_pending; - app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true }); + app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None }); wid } -fn arrange(app: &mut PhotocraftApp, k: &str) -> Result { +fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result { if LAYOUTS.contains(&k) { app.ui.view.arrange = k.to_string(); // Each document fits its new cell. @@ -664,10 +672,31 @@ fn arrange(app: &mut PhotocraftApp, k: &str) -> Result { app.ui.windows.clear(); Ok(json!({"arrange": "tabs"})) } - "newWindowForDocument" | "floatInWindow" => { + "newWindowForDocument" => { let doc = app.session.active_index().ok_or("no document")?; Ok(json!({"window": float_window(app, doc, 0)})) } + // Optional `document` (index; default the active one) and `position` (`[x, y]`, the + // window's screen position in points): dragging a tab off the tab strip uses both (#3733). + "floatInWindow" => { + let doc = match p.get("document") { + None | Some(Value::Null) => app.session.active_index().ok_or("no document")?, + Some(v) => { + let i = v.as_u64().ok_or("document must be a document index")?; + usize::try_from(i).ok().filter(|&i| i < app.session.documents().len()).ok_or(format!("no document {i}"))? + } + }; + let position = match p.get("position") { + None | Some(Value::Null) => None, + Some(v) => Some(screen_position(v).ok_or("position must be [x, y] in points")?), + }; + let wid = float_window(app, doc, 0); + if let Some(w) = app.ui.windows.iter_mut().find(|w| w.id == wid) { + w.position = position; + } + app.session.set_active(doc); + Ok(json!({"window": wid})) + } "floatAllInWindows" | "cascade" => { let n = app.session.documents().len(); let ids: Vec = (0..n).map(|d| float_window(app, d, d)).collect(); diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 4a957d3db6..34c93ccc25 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -292,6 +292,32 @@ fn lookup_export_dialog_uses_the_current_adjustment_selection() { assert!(direct["cube"].as_str().unwrap().contains("LUT_3D_SIZE 3")); } +/// Float in Window takes the document and the screen position a dragged-off tab was dropped at +/// (#3733); bad params are errors, not panics. +#[test] +fn float_in_window_takes_a_document_and_position() { + let (mut app, ctx) = app_with(3); + let r = menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({"document": 1, "position": [1950.0, 40.5]})).unwrap(); + let w = app.ui.windows.last().unwrap(); + assert_eq!(r, json!({"window": w.id})); + assert_eq!((w.document, w.position), (1, Some([1950.0, 40.5]))); + assert_eq!(app.session.active_index(), Some(1)); + menu(&mut app, &ctx, "window.arrange.floatInWindow", json!({})).unwrap(); + assert_eq!(app.ui.windows.last().map(|w| (w.document, w.position)), Some((1, None)), "defaults: the active document, placed by the OS"); + for bad in [ + json!({"document": 3}), + json!({"document": -1}), + json!({"document": "b"}), + json!({"position": [1.0]}), + json!({"position": [1.0, "x"]}), + json!({"position": [1.0e30, 0.0]}), + json!({"position": 5}), + ] { + assert!(menu(&mut app, &ctx, "window.arrange.floatInWindow", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(app.ui.windows.len(), 2, "rejected params open no window"); +} + #[test] fn save_selection_opens_its_dialog_and_saves_on_confirm() { // #3713: Select › Save Selection… asks for the channel, name and operation first. From ad5fb53f68ab87613c2aa29930507e287db4b05c Mon Sep 17 00:00:00 2001 From: zenjabba Date: Mon, 12 Oct 2026 05:14:07 -0400 Subject: [PATCH 045/523] Web: avoid shifting canvas during preset saves (#3805) Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com> --- apps/photocraft-web/src/web.rs | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 438e0799b5..6c9939607d 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -305,27 +305,22 @@ impl eframe::App for WebShell { } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { - let error = self.presets.error(); - if error.is_some() || self.presets.unsaved() { + if let Some(error) = self.presets.error() { egui::Panel::top("browser_preset_storage").show(ui, |ui| { - if let Some(error) = error { - let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme); - // Put retry first so floating brush windows cannot cover the control. - ui.horizontal_wrapped(|ui| { - if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() { - self.presets.retry(); - ui.ctx().request_repaint(); - } - ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)])); - }); - ui.label(if self.database.is_some() { - i18n::t("Keep this tab open to retain your brushes.") - } else { - i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.") - }); + let tokens = photocraft_ui_egui::theme::Tokens::for_kind(self.app.ui.theme); + // Put retry first so floating brush windows cannot cover the control. + ui.horizontal_wrapped(|ui| { + if self.database.is_some() && ui.button(i18n::t("Retry saving presets")).clicked() { + self.presets.retry(); + ui.ctx().request_repaint(); + } + ui.colored_label(tokens.warning, i18n::fmt(i18n::t("Brush preset changes are not saved: {error}"), &[("error", &error)])); + }); + ui.label(if self.database.is_some() { + i18n::t("Keep this tab open to retain your brushes.") } else { - ui.label(i18n::t("Saving brush presets… Keep this tab open until saving finishes.")); - } + i18n::t("Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them.") + }); }); } self.app.ui(ui, frame); From ae0e5eb731305e149f4fc8caa9e94c752d1c9356 Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:14:17 -0500 Subject: [PATCH 046/523] feat(ui): drag to reorder swatch groups and preset folders (#3658) (#3828) Swatch groups and the shared preset-browser folders could only be clicked, so there was no way to put them in the order the user wants. Dragging a folder header now mirrors the dock's per-group tab reorder (#2272): an accent insertion line shows where it will land, and the order is stored by name in PresetUi (UI state, groupOrder) so it saves and reloads with the rest of the state, reading as leniently as tabOrder. Because the folders share one browser, Gradients, Patterns, Styles and Shapes get it too. Tests: unit tests for the reorder and lenient/persisted order, plus an egui_kittest drag of a group header in the Swatches panel. --- crates/ui-egui/src/preset_panels.rs | 171 +++++++++++++++++++++++++++- crates/ui-egui/src/swatches_ui.rs | 36 ++++++ 2 files changed, 201 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 2528ec697b..e62979d878 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -37,15 +37,71 @@ pub struct PresetUi { pub current_tool_only: bool, /// Custom Shape tool: the shape it draws. pub custom_shape: String, + /// Folder/group order per panel, by name (#3658), like the dock's `tabOrder` (#2272). A panel + /// with no entry keeps the library's order; unknown names are ignored and groups missing from + /// a list follow it in library order. Saved with the UI state. + #[serde(rename = "groupOrder", alias = "group_order", deserialize_with = "lenient_group_order", skip_serializing_if = "BTreeMap::is_empty")] + pub group_order: BTreeMap>, /// In-place rename: (panel, preset or "group:", text). #[serde(skip)] pub renaming: Option<(String, String, String)>, } +/// Read `groupOrder` keeping what makes sense: non-object values, non-list entries and non-string +/// names are dropped instead of failing the whole saved UI state. +const MAX_GROUP_ORDER: usize = 10_000; +fn lenient_group_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result>, D::Error> { + let v = Value::deserialize(d)?; + let mut out = BTreeMap::new(); + for (panel, names) in v.as_object().into_iter().flatten() { + let Some(names) = names.as_array() else { continue }; + out.insert(panel.clone(), names.iter().filter_map(Value::as_str).take(MAX_GROUP_ORDER).map(str::to_owned).collect()); + } + Ok(out) +} + impl PresetUi { pub fn shape(&self) -> &str { if self.custom_shape.is_empty() { tl!("Heart") } else { &self.custom_shape } } + + /// Display order of the folders for `panel`, as library indices: the stored order first + /// (names that no longer exist are dropped), then groups missing from it in library order. + /// Mirrors the dock's per-group tab order (#2272, #3658). + pub(crate) fn display_order(&self, panel: &str, names: &[String]) -> Vec { + let mut out: Vec = Vec::with_capacity(names.len()); + for name in self.group_order.get(panel).into_iter().flatten() { + if let Some(i) = names.iter().position(|n| n == name) + && !out.contains(&i) + { + out.push(i); + } + } + for i in 0..names.len() { + if !out.contains(&i) { + out.push(i); + } + } + out + } + + /// Move folder `from` so it is drawn before `before` (`None` = last) for `panel`. The + /// library's own order is stored as no entry, as the dock does for its tabs. + pub(crate) fn move_group(&mut self, panel: &str, names: &[String], from: &str, before: Option<&str>) { + if before == Some(from) { + return; + } + let mut order: Vec = self.display_order(panel, names).into_iter().filter_map(|i| names.get(i).cloned()).collect(); + let Some(at) = order.iter().position(|n| n == from) else { return }; + let moved = order.remove(at); + let to = before.and_then(|b| order.iter().position(|n| n == b)).unwrap_or(order.len()); + order.insert(to, moved); + if order.as_slice() == names { + self.group_order.remove(panel); + } else { + self.group_order.insert(panel.to_string(), order); + } + } } const PANELS: [&str; 6] = @@ -319,15 +375,21 @@ pub(crate) fn browser( } } } - let mut headers: Vec<(Rect, String)> = Vec::new(); + let mut headers: Vec<(usize, Rect, String)> = Vec::new(); let mut dropped: Option<(String, Pos2)> = None; + // Folder drag (#3658): the order the folders are drawn in, and what the pointer is doing this + // frame (the insertion line and the drop are resolved once every header is known). + let mut drag: Option<(usize, Pos2)> = None; + let mut released: Option<(usize, Pos2)> = None; let group_names: Vec = groups.iter().map(|g| g.name.clone()).collect(); + let display = st.display_order(panel, &group_names); egui::ScrollArea::vertical().id_salt(("preset-scroll", panel)).max_height(max_h).auto_shrink([false, true]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 2.0); - for (gi, g) in groups.iter().enumerate() { + for &gi in &display { + let Some(g) = groups.get(gi) else { continue }; let open = group_open(st, panel, gi, &g.name); - let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if hresp.hovered() { + let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + if hresp.hovered() || hresp.dragged() { ui.painter().rect_filled(hr, 2.0, t.hover.gamma_multiply(0.5)); } let tri = Rect::from_center_size(pos2(hr.left() + 9.0, hr.center().y), vec2(12.0, 12.0)); @@ -351,7 +413,18 @@ pub(crate) fn browser( ui.close(); } }); - headers.push((hr, g.name.clone())); + if hresp.dragged() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + drag = Some((gi, p)); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + if hresp.drag_stopped() + && let Some(p) = ui.ctx().pointer_interact_pos() + { + released = Some((gi, p)); + } + headers.push((gi, hr, g.name.clone())); if !open { continue; } @@ -434,9 +507,32 @@ pub(crate) fn browser( }); } } + // Insertion line while a folder is dragged by its header. + if let Some((from, p)) = drag { + let before = group_drop_before(&headers, from, p.y); + let anchor = match before { + Some(i) => headers.iter().find(|(x, _, _)| *x == i).map(|(_, r, _)| *r), + None => headers.last().map(|(_, r, _)| *r), + }; + if let Some(r) = anchor { + let y = if before.is_some() { r.top() - 1.0 } else { r.bottom() + 1.0 }; + ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + } + } }); + // Apply the drop now every header rect is known; the next frame draws the new order. + if let Some((from, p)) = released { + let before = group_drop_before(&headers, from, p.y); + let from_name = groups.get(from).map(|g| g.name.clone()); + let before_name = before.and_then(|i| groups.get(i).map(|g| g.name.clone())); + if let Some(from_name) = from_name { + st.move_group(panel, &group_names, &from_name, before_name.as_deref()); + } + } + let recorded: Vec<(String, Rect)> = headers.iter().map(|(_, r, n)| (n.clone(), *r)).collect(); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("preset-group-headers", panel)), recorded)); if let Some((key, pos)) = dropped { - if let Some((_, g)) = headers.iter().find(|(r, _)| r.contains(pos)) { + if let Some((_, _, g)) = headers.iter().find(|(_, r, _)| r.contains(pos)) { ev.push(Ev::Move(key, g.clone())); } else if canvas.contains(pos) { ev.push(Ev::Drop(key, pos)); @@ -468,6 +564,25 @@ pub(crate) fn browser( ev } +/// The folder (by library index) a header dragged to `y` lands before (`None` = last). `headers` +/// is in display order; mirrors `dock::drop_before`. +fn group_drop_before(headers: &[(usize, Rect, String)], from: usize, y: f32) -> Option { + let hit = headers.iter().find(|(_, r, _)| y < r.center().y).map(|(i, _, _)| *i); + match hit { + Some(i) if i == from => Some(from), + Some(i) => { + let after_self = headers.iter().position(|(x, _, _)| *x == from).zip(headers.iter().position(|(x, _, _)| *x == i)).is_some_and(|(a, b)| b == a + 1); + if after_self { Some(from) } else { Some(i) } + } + None => None, + } +} + +/// Folder header rects drawn last frame (name → rect), in display order; for tests and automation. +pub fn last_group_headers(ctx: &egui::Context, panel: &str) -> Vec<(String, Rect)> { + ctx.data(|d| d.get_temp(egui::Id::new(("preset-group-headers", panel)))).unwrap_or_default() +} + /// Shared handling of the generic events through `.edit` / `.new`. fn generic(app: &mut PhotocraftApp, panel: &str, prefix: &str, e: &Ev, new_params: Value) -> bool { let edit = format!("{prefix}.edit"); @@ -1169,6 +1284,50 @@ mod tests { assert!(matches!(l.content, photocraft_doc::LayerContent::Shape(_))); } + /// #3658: a group/folder drag reorders by name, stores only a real reorder, and falls back to + /// the library's order when the saved order matches it or names a group that's gone. + #[test] + fn moving_a_folder_reorders_like_the_dock_tabs() { + let names: Vec = ["Grays", "Pastel", "Pure", "Dark"].iter().map(|s| s.to_string()).collect(); + let mut st = PresetUi::default(); + assert_eq!(st.display_order("swatches", &names), vec![0, 1, 2, 3]); + st.move_group("swatches", &names, "Pastel", None); + assert_eq!(st.group_order.get("swatches").unwrap(), &["Grays", "Pure", "Dark", "Pastel"]); + st.move_group("swatches", &names, "Pastel", Some("Grays")); + assert_eq!(st.group_order.get("swatches").unwrap(), &["Pastel", "Grays", "Pure", "Dark"]); + assert_eq!(st.display_order("swatches", &names), vec![1, 0, 2, 3]); + // Back to the library's own order: no entry, as the dock stores its default tab order. + st.move_group("swatches", &names, "Pastel", Some("Pure")); + assert!(!st.group_order.contains_key("swatches"), "a no-op order is not stored"); + // Out-of-range names, moves onto themselves and unknown `before` names change nothing bad. + for (from, before) in [("Nope", None), ("Grays", Some("Grays"))] { + let mut m = PresetUi::default(); + m.move_group("swatches", &names, from, before); + assert!(m.group_order.is_empty(), "{from} {before:?}"); + assert_eq!(m.display_order("swatches", &names), vec![0, 1, 2, 3]); + } + // A missing group in a saved order follows the rest in library order. + let mut m = PresetUi::default(); + m.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]); + assert_eq!(m.display_order("swatches", &names), vec![3, 0, 1, 2]); + } + + /// #3658: the folder order persists with the UI state and reads leniently (an old state has no + /// field, and garbage in one never fails the whole state). + #[test] + fn folder_order_persists_and_reads_leniently() { + let old: PresetUi = serde_json::from_value(json!({"sizes": {"swatches": 22.0}})).unwrap(); + assert!(old.group_order.is_empty(), "an older state without the field still loads"); + let mut st = PresetUi::default(); + st.group_order.insert("swatches".into(), vec!["Dark".into(), "Grays".into()]); + let back: PresetUi = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap(); + assert_eq!(back.group_order.get("swatches").unwrap(), &["Dark", "Grays"]); + for bad in [json!("x"), json!(7), json!({"swatches": "paths"}), json!({"swatches": [1, null, {"a": 1}]})] { + let back: PresetUi = serde_json::from_value(json!({"groupOrder": bad})).unwrap(); + assert!(back.group_order.get("swatches").is_none_or(Vec::is_empty), "{bad}"); + } + } + /// Regression: preset drops mapped the pointer through the raw widget rect, ignoring the /// rulers' 16 px inset — a dropped shape landed 8 screen px from the pointer, and a drop on /// the ruler strip counted as a canvas drop. Drops now map through, and target, the same diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index d8fd9caa47..3215f187b1 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -286,6 +286,20 @@ mod tests { h.run_steps(1); } + /// Drag from `from` to `to` in steps, as when a user drags a group header. + fn drag(h: &mut Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=6 { + h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 6.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + /// The first swatch: below the first group header (22 px), after the 8 px grid inset. fn first_cell(h: &Harness<'_, PhotocraftApp>) -> egui::Pos2 { h.ctx.input(|i| i.viewport_rect()).min + vec2(8.0 + 12.0 + 8.0, 22.0 + 2.0 + 12.0 + 8.0) @@ -397,4 +411,26 @@ mod tests { assert_eq!(out[0].0, "out/Swatches.ase"); assert!(out[0].1.starts_with(b"ASEF")); } + + /// #3658: dragging a group header reorders the folders, and the new order sticks in the UI + /// state (UI state is serde, so it saves and reloads with the rest of the panel state). + #[test] + fn dragging_a_group_header_reorders_and_persists() { + let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut h = Harness::builder().with_size(vec2(300.0, 4000.0)).build_ui_state(|ui, app: &mut PhotocraftApp| panel(app, ui), app); + h.run_steps(2); + let headers = crate::preset_panels::last_group_headers(&h.ctx, PANEL); + assert!(headers.len() >= 2, "the default library has folders: {headers:?}"); + let names: Vec = headers.iter().map(|(n, _)| n.clone()).collect(); + let (first, first_r) = &headers[0]; + let (last, last_r) = headers.last().unwrap(); + // Drag the last folder's header above the first; the insertion lands before it. + drag(&mut h, last_r.center(), egui::pos2(first_r.center().x, first_r.top() + 1.0)); + let order = h.state().ui.presets_ui.display_order(PANEL, &names); + assert_eq!(order.len(), names.len()); + assert_eq!(names.get(order[0]).map(String::as_str), Some(last.as_str()), "the dragged folder moved to the front: {order:?}"); + assert_eq!(names.get(order[1]).map(String::as_str), Some(first.as_str())); + // The reorder is remembered by name (not by the positions it happened to have). + assert_eq!(h.state().ui.presets_ui.group_order.get(PANEL).and_then(|o| o.first()), Some(last)); + } } From 5bdb12ec51f73acec759aeea21d60f1a3bf0494d Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Mon, 12 Oct 2026 11:14:27 +0200 Subject: [PATCH 047/523] fix(canvas): keep transform preview visible while panning (#3610) (#3843) --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/src/transform_undo_tests.rs | 26 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 179de51244..e6c187cae4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3569,6 +3569,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::stroke_constraint::draw_line_preview(app, &painter, &xf, p, tool, held.shift); } } + if primary && hand_pan && response.dragged() { + crate::transform_tool::draw_overlay(app, &painter, &xf); + } app.tool_override = None; if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); diff --git a/crates/ui-egui/src/transform_undo_tests.rs b/crates/ui-egui/src/transform_undo_tests.rs index d9ffdf9c66..7db8cce561 100644 --- a/crates/ui-egui/src/transform_undo_tests.rs +++ b/crates/ui-egui/src/transform_undo_tests.rs @@ -279,6 +279,32 @@ fn holding_space_still_pans_while_transforming() { assert_ne!(h.state().ui.views[0].center, center0, "the view panned instead"); } +#[test] +fn transform_preview_stays_visible_while_panning() { + let mut h = harness(); + begin(&mut h); + let texture = h.state().transform_preview.as_ref().unwrap().texture.pick(&h.ctx, 1.0, 1.0, false).0; + let has_preview = |h: &Harness<'static, PhotocraftApp>| { + h.output().shapes.iter().any(|clipped| matches!(&clipped.shape, egui::epaint::Shape::Mesh(mesh) if mesh.texture_id == texture)) + }; + assert!(has_preview(&h), "the transformed pixels are visible before panning"); + + let center0 = h.state().ui.views[0].center; + let from = screen(&h, [200.0, 160.0]); + let to = from + vec2(50.0, 40.0); + h.event(egui::Event::Key { key: Key::Space, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(from)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerMoved(to)); + h.run_steps(1); + + assert_ne!(h.state().ui.views[0].center, center0, "the held Hand panned the view"); + assert!(has_preview(&h), "the transformed layer stays visible during the pan"); +} + /// A transform started by dragging a Move-tool control: that first drag is undoable too. #[test] fn the_first_drag_from_the_move_tools_controls_undoes() { From 6d03075ed79353361cf43c9c116811e951a8fea3 Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:14:41 +0200 Subject: [PATCH 048/523] perf: draw marching ants as one mesh instead of a line shape per dash (#3846) Every ants segment was a white line shape plus a black line shape per 4 px dash, and egui tessellates each. A selection with many edges seen whole (a speckled selection at 30% zoom: 94,666 segments) cost 45.6 ms per idle frame, ten frames a second while the selection shows. Horizontal and vertical segments now go into one egui::Mesh of 1 px colored quads, with the same pixel-centre rounding, dash length and phase; the white base runs half a pixel past both ends so corners join as the line caps did. Slanted segments (Content-Aware Move's transformed outline) stay line shapes, which egui antialiases. A textured, repeating dash isn't possible: egui-wgpu clamps texture coordinates. Speckled selection, idle frame: 45.6 -> 12.0 ms at 30% zoom, 6.6 -> 4.5 ms at 125% (RTX 3090, Vulkan; an idle frame with no selection is about 3 ms). Screenshots at 100% and 800% zoom are pixel-identical to the line shapes. --- crates/ui-egui/src/canvas.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e6c187cae4..c982dd9afa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3734,9 +3734,21 @@ fn marching_ants_segments(painter: &egui::Painter, xf: &ViewXform, segs: &[crate pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outline::Segment], time: f64, to_screen: impl Fn([i32; 2]) -> Pos2) { let dash = 4.0f32; let phase = ((time * 10.0) % (dash as f64 * 2.0)) as f32; - let white = Stroke::new(1.0, Color32::WHITE); - let black = Stroke::new(1.0, Color32::BLACK); let clip = painter.clip_rect(); + // Horizontal and vertical segments go in one mesh of 1 px quads, which egui draws as it is: a + // line shape per dash took egui ~35 ms a frame to tessellate for a speckled selection seen + // whole (about 10⁵ segments), ten frames a second while the selection is shown. A slanted + // segment (a transformed outline) stays a line shape, which egui antialiases. + let mut mesh = egui::Mesh::default(); + // A 1 px wide quad from `a` to `b` (`side` is half a pixel across the line). + let mut quad = |a: egui::Pos2, b: egui::Pos2, side: egui::Vec2, color: Color32| { + let i = mesh.vertices.len() as u32; + for p in [a + side, a - side, b - side, b + side] { + mesh.colored_vertex(p, color); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i, i + 2, i + 3); + }; for (a, b) in segs { let pa = to_screen(*a); let pb = to_screen(*b); @@ -3745,12 +3757,19 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli } let pa = pos2(pa.x.round() + 0.5, pa.y.round() + 0.5); let pb = pos2(pb.x.round() + 0.5, pb.y.round() + 0.5); - painter.line_segment([pa, pb], white); let len = pa.distance(pb); if len < 0.5 { continue; } let dir = (pb - pa) / len; + let side = dir.rot90() * 0.5; + let straight = pa.x == pb.x || pa.y == pb.y; + if straight { + // The white base runs half a pixel past both ends, so joined segments meet at corners. + quad(pa - dir * 0.5, pb + dir * 0.5, side, Color32::WHITE); + } else { + painter.line_segment([pa, pb], Stroke::new(1.0, Color32::WHITE)); + } // Phase by screen position so dashes line up across joined segments. let start = (pa.x + pa.y + phase).rem_euclid(dash * 2.0); let mut t = -start; @@ -3758,11 +3777,18 @@ pub(crate) fn marching_ants_mapped(painter: &egui::Painter, segs: &[crate::outli let s0 = t.max(0.0); let s1 = (t + dash).min(len); if s1 > s0 { - painter.line_segment([pa + dir * s0, pa + dir * s1], black); + if straight { + quad(pa + dir * s0, pa + dir * s1, side, Color32::BLACK); + } else { + painter.line_segment([pa + dir * s0, pa + dir * s1], Stroke::new(1.0, Color32::BLACK)); + } } t += dash * 2.0; } } + if !mesh.is_empty() { + painter.add(egui::Shape::mesh(mesh)); + } } #[allow(dead_code)] From bade115202952b811fcbef1f37900645541ff2c9 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 17:14:54 +0800 Subject: [PATCH 049/523] Fail CLI run when recorded action playback fails (#3861) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- apps/photocraft-cli/src/lib.rs | 3 +++ apps/photocraft-cli/tests/cli.rs | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index fe8c62717a..1743665d90 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -311,6 +311,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let mut reported_fonts = std::collections::HashSet::::new(); for (id, params) in cmds { let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; + if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { + return Err(format!("`{id}`: {e}")); + } let new_warnings: Vec = h .session .active() diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 5baa83fde3..ea807e9882 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -203,6 +203,50 @@ fn run_commands_and_save() { std::fs::remove_dir_all(d).unwrap(); } +#[test] +fn run_stops_after_failed_action_without_exporting() { + let d = tmp("run-failed-action"); + let out = d.join("result.png"); + let result = bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "layer.new.layer"]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Delete"}"#]) + .args(["--cmd", "layer.delete", "--cmd", "actions.stop"]) + // The recorded deletion succeeded with two layers; replay cannot delete the last one. + .args(["--cmd", "actions.play", "--params", r#"{"action":"Delete"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out) + .output() + .unwrap(); + let error = String::from_utf8_lossy(&result.stderr); + assert_eq!(result.status.code(), Some(1), "stdout: {}\nstderr: {error}", String::from_utf8_lossy(&result.stdout)); + assert!(error.contains("actions.play") && error.contains("Delete") && error.contains("step 1"), "{error}"); + let rows: Vec = String::from_utf8_lossy(&result.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert!(rows.iter().all(|row| row["command"] != "document.inspect"), "later commands must not run"); + assert!(!out.exists(), "a failed action must not export a partial result"); + std::fs::remove_dir_all(d).unwrap(); +} + +#[test] +fn run_continues_after_successful_action_and_exports() { + let d = tmp("run-successful-action"); + let out = d.join("result.png"); + let (stdout, _) = ok(bin() + .args(["run", "--new", r#"{"width":8,"height":8}"#]) + .args(["--cmd", "actions.record", "--params", r#"{"name":"Add"}"#]) + .args(["--cmd", "layer.new.layer", "--cmd", "actions.stop"]) + .args(["--cmd", "actions.play", "--params", r#"{"action":"Add"}"#]) + .args(["--cmd", "document.inspect", "--out"]) + .arg(&out)); + let rows: Vec = stdout.lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + let playback = rows.iter().find(|row| row["command"] == "actions.play").unwrap(); + assert_eq!(playback["result"]["ran"], 1); + assert!(playback["result"].get("failed").is_none()); + assert!(rows.iter().any(|row| row["command"] == "document.inspect")); + assert!(out.is_file()); + std::fs::remove_dir_all(d).unwrap(); +} + #[test] fn run_errors() { let o = bin().args(["run", "--new", "{}", "--cmd", "no.such"]).output().unwrap(); From 79cfa2b65aeefc5164558ea359c2ec20a0470403 Mon Sep 17 00:00:00 2001 From: n-delic Date: Mon, 12 Oct 2026 11:22:58 +0200 Subject: [PATCH 050/523] Guides: Shift-drag out of a ruler lands on the nearest ruler tick (#3770) (#3796) As in Photoshop, holding Shift while dragging a new guide out of a ruler snaps it to the tick marks the ruler draws (tenths of the labelled step), in the current ruler unit and zoom. Without Shift the guide snaps to the document, layers, selection and grid as before. The tick spacing draw_rulers computed inline moves to ruler_scale, shared by the renderer and the new snap_to_tick, so the snap always matches the ticks on screen. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/rulers.rs | 79 +++++++++++++++++++++++++++++++----- docs/ui-parity.md | 4 +- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/rulers.rs b/crates/ui-egui/src/rulers.rs index 58bcf04cf5..cc427758fe 100644 --- a/crates/ui-egui/src/rulers.rs +++ b/crates/ui-egui/src/rulers.rs @@ -268,6 +268,29 @@ fn ruler_pos(xf: &ViewXform, vertical: bool, u: f64, px_per_unit: f64) -> f32 { if vertical { rxf.to_screen(0.0, px).y } else { rxf.to_screen(px, 0.0).x } } +/// Document pixels per ruler unit and the labelled tick step (in ruler units) of the top ruler +/// (`y_axis` false) or the left one, from Preferences › Units & Rulers and the zoom. Minor ticks +/// sit at tenths of the step. +fn ruler_scale(app: &PhotocraftApp, xf: &ViewXform, y_axis: bool) -> (f64, f64) { + let ur = &app.session.prefs().units_and_rulers; + let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); + let (dpi, size) = + app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); + let px_per_unit = unit.to_px(1.0, dpi, if y_axis { size[1] } else { size[0] }, ppi).max(1e-9); + // The top ruler runs along document x, which pixel aspect correction stretches. + let zoom = if y_axis { xf.zoom } else { xf.zoom_x() }; + (px_per_unit, nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), unit == Unit::Pixels)) +} + +/// `pos` (document px) moved to the nearest tick mark the ruler draws: where a guide lands when +/// Shift is held while dragging it out of a ruler, as in Photoshop (#3770). +fn snap_to_tick(app: &PhotocraftApp, xf: &ViewXform, vertical: bool, pos: f64) -> f64 { + // A vertical guide sits at an x position, which the top ruler measures. + let (px_per_unit, step) = ruler_scale(app, xf, !vertical); + let tick = step / 10.0 * px_per_unit; + if tick.is_finite() && tick > 0.0 { (pos / tick).round() * tick } else { pos } +} + /// The number a ruler prints at the tick `v` (in ruler units). Like Photoshop, rulers show the /// distance from the origin, so values left of / above it read without a minus sign. fn ruler_label(v: f64, step: f64, whole: bool) -> String { @@ -292,17 +315,11 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & let font = egui::FontId::proportional(9.0); let tick = Stroke::new(1.0, t.text_faint); // Preferences › Units & Rulers: labels in the ruler unit (percent of the document's side). - let ur = &app.session.prefs().units_and_rulers; - let (unit, ppi) = (ur.rulers, ur.point_size.per_inch()); - let (dpi, size) = - app.session.active().map_or((72.0, [1.0, 1.0]), |d| (d.doc.resolution_dpi.max(1.0) as f64, [d.doc.size.width as f64, d.doc.size.height as f64])); - let whole = unit == photocraft_engine::prefs::Unit::Pixels; - for (vertical, extent) in [(false, size[0]), (true, size[1])] { + let unit = app.session.prefs().units_and_rulers.rulers; + let whole = unit == Unit::Pixels; + for vertical in [false, true] { let p = p.with_clip_rect(if vertical { left } else { top }); - let px_per_unit = unit.to_px(1.0, dpi, extent, ppi).max(1e-9); - // The top ruler runs along document x, which pixel aspect correction stretches. - let zoom = if vertical { xf.zoom } else { xf.zoom_x() }; - let step = nice_step(60.0 / (zoom as f64 * px_per_unit).max(1e-6), whole); + let (px_per_unit, step) = ruler_scale(app, xf, vertical); let (u0, u1) = if vertical { ruler_range(xf, left.left_top(), left.left_bottom(), 1, px_per_unit) } else { @@ -352,7 +369,8 @@ pub fn draw_rulers(app: &mut PhotocraftApp, ui: &mut egui::Ui, full: Rect, xf: & } if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.drag_started()) { let d = xf.to_doc(pos); - let pos = crate::snap_ui::snap_guide(app, vertical, if vertical { d[0] } else { d[1] }); + let along = if vertical { d[0] } else { d[1] }; + let pos = if ui.input(|i| i.modifiers.shift) { snap_to_tick(app, xf, vertical, along) } else { crate::snap_ui::snap_guide(app, vertical, along) }; app.guide_drag = Some(GuideDrag { vertical, index: None, pos }); } if resp.drag_stopped() @@ -661,4 +679,43 @@ mod tests { assert!(u1 - u0 > 0.0, "flip={flip}: range is empty"); } } + + /// #3770: a guide Shift-dragged out of a ruler lands on the nearest tick the ruler draws + /// (tenths of the labelled step, in the ruler unit); without Shift it lands where it's dropped. + #[test] + fn shift_dragged_guides_land_on_ruler_ticks() { + let full = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let rect = Rect::from_min_max(full.min + vec2(RULER, RULER), full.max); + let xf = ViewXform { rect, zoom: 1.0, center: [128.0, 64.0], flip: false, rotation: 0.0, aspect: 1.0 }; + let new_app = || { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 256, "height": 128})).unwrap(); + app + }; + let drag_guide = |modifiers: egui::Modifiers| { + let builder = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)); + let mut h = builder.build_ui_state(move |ui, app| draw_rulers(app, ui, full, &xf), new_app()); + // Out of the left ruler: a vertical guide, dropped at document x = 37. + let (from, to) = (pos2(RULER / 2.0, 100.0), xf.to_screen(37.0, 64.0)); + h.event_modifiers(egui::Event::PointerMoved(from), modifiers); + h.event_modifiers(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }, modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerMoved(to), modifiers); + h.run_steps(1); + h.event_modifiers(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }, modifiers); + h.run_steps(2); + h.state().session.active().unwrap().doc.guides.vertical.clone() + }; + assert_eq!(drag_guide(egui::Modifiers::NONE), vec![37.0]); + // Pixels at 100%: labels every 100 px, ticks every 10 px. + assert_eq!(drag_guide(egui::Modifiers::SHIFT), vec![40.0]); + + let mut app = new_app(); + // At 200% the labels are every 50 px, so the ticks every 5 px. + assert_eq!(snap_to_tick(&app, &ViewXform { zoom: 2.0, ..xf }, true, 37.0), 35.0); + // Percent of each side: ticks every 2.5% of 256 px across, every 5% of 128 px down. + app.run("prefs.set", json!({"path": "unitsAndRulers.rulers", "value": "percent"})).unwrap(); + assert!((snap_to_tick(&app, &xf, true, 37.0) - 38.4).abs() < 1e-9); + assert!((snap_to_tick(&app, &xf, false, 37.0) - 38.4).abs() < 1e-9); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1471255196..d168db4dff 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (History snapshots) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -149,6 +149,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Smart guides (magenta alignment lines) | done | | | Smart guide distance and spacing labels | missing | | | Ruler units px/in/cm/mm/pt/pica/% | done | | +| ⇧-drag a guide out of a ruler: it lands on the nearest ruler tick | done | `rulers.rs` `snap_to_tick` (#3770) | | Status bar follows the ruler unit | partial | #2602 | | Ruler corner and gaps drawn as Photoshop | partial | #2600, #2601, #2551 | | Guides stored as f64 | missing | `Vec` (UI-217-20) | @@ -213,3 +214,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-10 | minor | Fixed Crop live resize snapping feedback and outdated-camera rendering, applied ratios immediately to centred inscribed previews, and removed two-point options-bar overflow; added pointer, rendering, dropdown/Enter, Swap/X and scrolling regressions | | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | +| 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | From a1a7b17ee0a2f20db2579616a1b12092ad863101 Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:32:03 -0500 Subject: [PATCH 051/523] fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564) * fix(engine): a PSD type layer names the installed font, not an invented one (#3556) A type layer created with a font by family name stored no PostScript name, so PSD export invented one ("-Regular", spaces removed). Photoshop looks type up by PostScript name, so it showed the font as missing and substituted Myriad Pro as soon as the text was edited. Resolve the family's installed face (matching weight/italic) from the font database when the family, style, weight or italic is set, and store its real PostScript name; an explicit postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls back as before. Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects the resolved name. * Type tool test: a family change names the installed face (#3556) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/type_cmds.rs | 44 +++++++++++++++++++++++++-- crates/ui-egui/src/type_tool_tests.rs | 11 ++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 962504977b..e59ce9258e 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -121,10 +121,23 @@ pub fn check_size_tracking(p: &Value) -> std::result::Result<(), String> { Ok(()) } +/// The PostScript name of the installed face of `family` at exactly `weight`/`italic`. A PSD names +/// a type layer by PostScript name, and Photoshop treats a name no font has as missing (#3556). +/// Only an exact face is named: layout picks a named face before weight and italic, so naming a +/// nearby face would override the requested weight (a variable font's axis, a weight between two +/// faces) or italic. Without an exact face the style keeps no name and layout matches as before. +fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option { + let mut shared = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + let faces = shared.fonts.faces(family); + let face = faces.iter().find(|f| f.italic == italic && f.weight.round() as u16 == weight)?; + face.postscript_name.clone() +} + /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; let mut hit = |b: bool| any |= b; + let font_key = ["font", "family", "fontStyle", "weight", "italic"].iter().any(|k| p.get(k).is_some()); if let Some(v) = p.get("font").or_else(|| p.get("family")).and_then(Value::as_str) { s.font_family = v.to_string(); s.postscript_name = None; @@ -169,6 +182,11 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { s.postscript_name = Some(v.to_string()); hit(true); } + // A font named by family (or a style that matched no face) has no PostScript name yet: fill in + // the installed face's real name, so the PSD does not invent "-Regular" (#3556). + if font_key && s.postscript_name.is_none() { + s.postscript_name = installed_postscript(&s.font_family, s.weight, s.italic); + } if let Some(v) = f32p(p, "size") { s.size_pt = v.max(0.01); hit(true); @@ -989,14 +1007,36 @@ mod tests { assert_eq!(style.weight, 600); assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold")); apply_char_props(&mut style, &json!({"weight": 400})); - assert!(style.postscript_name.is_none()); + // #3556: a style change re-resolves to the installed face instead of leaving it nameless. + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); assert!(style.font_style.is_empty()); apply_char_props(&mut style, &json!({"fontStyle": "unknown-style"})); - assert!(style.postscript_name.is_none()); + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular")); apply_char_props(&mut style, &json!({"fontStyle": "Regular", "postscriptName": "ExplicitFace"})); assert_eq!(style.postscript_name.as_deref(), Some("ExplicitFace")); } + /// #3556: a font named by family stores the installed face's PostScript name, so PSD export + /// names a real font instead of inventing "-Regular" that Photoshop calls missing. + #[test] + fn a_family_font_gets_the_installed_postscript_name() { + let mut style = CharStyle { font_family: "Inter".into(), ..Default::default() }; + apply_char_props(&mut style, &json!({"font": "Inter"})); + assert_eq!(style.font_family, "Inter"); + assert_eq!(style.postscript_name.as_deref(), Some("Inter-Regular"), "resolved from the font database"); + // A weight picks that face's name. + apply_char_props(&mut style, &json!({"font": "Inter", "weight": 600})); + assert_eq!(style.postscript_name.as_deref(), Some("Inter-SemiBold")); + // A weight no face has (a variable font's axis, or between two faces) names no face, so + // layout still matches the requested weight instead of a nearby face's. + apply_char_props(&mut style, &json!({"weight": 650})); + assert!(style.postscript_name.is_none(), "{:?}", style.postscript_name); + // A family PhotoCraft does not have keeps no name; the export falls back as before. + let mut absent = CharStyle { font_family: "No Such Family 123".into(), ..Default::default() }; + apply_char_props(&mut absent, &json!({"font": "No Such Family 123"})); + assert!(absent.postscript_name.is_none()); + } + /// Picking a style or weight resets the weight axis to it; other axes keep their values (#2985). #[test] fn a_style_or_weight_resets_the_weight_axis() { diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index a1c6415b95..a6b045bc1b 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1371,7 +1371,16 @@ mod multi_layer_formatting { for i in 0..2 { let mut expected = before[i].clone(); expected.font_family = "JetBrains Mono".into(); - expected.postscript_name = None; + // #3556: the new family's installed face at the peer's weight and italic is named (no + // name without such a face), so a PSD names a real font. + expected.postscript_name = photocraft_text::shared() + .lock() + .unwrap() + .fonts + .faces("JetBrains Mono") + .into_iter() + .find(|f| f.italic == expected.italic && f.weight.round() as u16 == expected.weight) + .and_then(|f| f.postscript_name); assert_eq!(style(h.state(), ids[i], 0), expected, "family changes preserve each peer's face and metrics"); } assert_eq!(style(h.state(), ids[2], 0), before[2]); From e79d6571ba7a88d4ec006486774ece75e102b26f Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 17:36:39 +0800 Subject: [PATCH 052/523] Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566) * test(engine): a recorded edit run replays on a second document * docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families --- crates/engine/src/actions_cmds.rs | 40 +++++++++++++++++++++++++++++++ docs/scorecard.md | 4 ++-- scorecard/automation.toml | 4 ++-- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 9b4a758d9a..f6707c1b9f 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -1224,4 +1224,44 @@ mod tests { assert!(bad.actions.list.is_empty()); let _ = std::fs::remove_dir_all(&dir); } + + /// AUTO-219-4: a recording made on one document replays on a second one — a crop, a filter, + /// a selection fill and styled text all act on the document the replay runs in. + #[test] + fn a_recorded_edit_run_replays_on_a_second_document() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + s.execute("actions.record", json!({"name": "Studio"})).unwrap(); + s.execute("image.crop", json!({"x": 8, "y": 8, "width": 48, "height": 48})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 12, "height": 12})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("type.create", json!({"x": 10, "y": 36, "text": "Hi", "size": 12, "color": "#000000"})).unwrap(); + s.execute("type.setStyle", json!({"fauxBold": true})).unwrap(); + let stopped = s.execute("actions.stop", json!({})).unwrap(); + let steps = stopped["steps"].as_u64().unwrap(); + assert!(steps >= 7, "the recording keeps every family: {stopped}"); + + // The second document: a different size, background and name. The replay must act here. + s.execute("file.new", json!({"width": 100, "height": 80, "background": "black", "name": "Second"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Studio"})).unwrap(); + assert!(played.get("failed").is_none(), "replay: {played}"); + assert_eq!(played["ran"].as_u64(), Some(steps), "every step ran: {played}"); + let d = &s.active().unwrap().doc; + assert_eq!((d.size.width, d.size.height), (48, 48), "the crop replayed onto the second document"); + assert_eq!(pixel(&s, 0, 16, 16), vec![1.0, 0.0, 0.0, 1.0], "the selection fill replayed"); + assert_eq!(pixel(&s, 0, 2, 2), vec![0.0, 0.0, 0.0, 1.0], "outside the selection stays black"); + let last = d.layers.last().unwrap(); + match &last.content { + photocraft_doc::LayerContent::Text(t) => { + assert_eq!(t.text, "Hi", "the styled text replayed"); + assert!(t.char_runs().iter().any(|r| r.style.faux_bold), "the bold run replayed"); + } + other => panic!("expected the recorded type layer, got {other:?}"), + } + // The first document is untouched. + let first = &s.documents()[0].doc; + assert_eq!((first.size.width, first.size.height), (48, 48), "the recording document keeps its own crop"); + } } diff --git a/docs/scorecard.md b/docs/scorecard.md index 8e8aa3080a..80cb52faae 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -230,7 +230,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T | AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | | AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | -| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | missing | [#219](https://github.com/storytold/photocraft/issues/219) | One replay test (actions.rs:171: new layer, rect select, fill) | +| AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | | AUTO-219-6 | Plug-in ABI v2: panel kind, progress/cancel calls, read other layers, Last Filter repeats | missing | [#219](https://github.com/storytold/photocraft/issues/219) | runtime.rs:15 ABI_VERSION = 1; manifest.rs:18 Kind::Filter only | | AUTO-219-7 | Local ONNX models behind a flag, managed in Preferences; subject select, background removal | missing | [#219](https://github.com/storytold/photocraft/issues/219) | No ONNX runtime dependency | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 002adc76db..000e13f944 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -35,9 +35,9 @@ note = "actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, [[item]] id = "AUTO-219-4" target = "Replay tests for crop, filters, selections and styled text on a second document" -status = "missing" +status = "done" issue = 219 -note = "One replay test (actions.rs:171: new layer, rect select, fill)" +note = "actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay)" [[item]] id = "AUTO-219-5" From dd708f4ce4e00f29708e9887a25c8ea1f483f05c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:43:27 -0400 Subject: [PATCH 053/523] feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip (#4033) Photoshop writes a Gradient Map's colour stops in the document's own colour space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such layers imported as unsupported and did nothing. - Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in `grdm` stops; CMYK converts through the document's own CMYK profile (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay unsupported (kept verbatim). - Saving keeps the file's own `grdm` block, so stops keep their colour space; toggling Reverse or Dither patches just those flags in place. Co-authored-by: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 113 +++++++++++++++++++++++++++++++++-- crates/io/src/channel_map.rs | 45 +++++++++++++- crates/io/src/psd_export.rs | 6 ++ crates/io/src/psd_import.rs | 5 +- crates/io/tests/color.rs | 100 +++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 9 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 03f3180b0c..2489b669c5 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option { /// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour /// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among /// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like -/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported. +/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab, +/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB +/// through the colour-managed conversions (the document's CMYK profile when one is in scope). +/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported. fn parse_gradient_map(d: &[u8]) -> Option { let version = be16(d, 0)?; let reverse = *d.get(2)? != 0; @@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option { for _ in 0..n { let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0); let mid = be32(d, at + 4)? as f32 / 100.0; - if be16(d, at + 8)? != 0 { - return None; - } - let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0); - stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?))); + let space = be16(d, at + 8)?; + let c = |k: usize| be16(d, at + 10 + 2 * k); + stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?)); mids.push(mid); at += 20; } @@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option { Some(Adjustment::GradientMap { stops, reverse, dither }) } +/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the +/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops +/// (in whatever colour space it stored them), smoothness and method survive the save. `false` +/// leaves `data` untouched. +pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool { + let Adjustment::GradientMap { stops, reverse, dither } = target else { return false }; + let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops); + match (same_stops, data.get_mut(2..4)) { + (true, Some(flags)) => { + flags[0] = u8::from(*reverse); + flags[1] = u8::from(*dither); + true + } + _ => false, + } +} + /// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so /// the stops are reproduced exactly). fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec { @@ -824,6 +842,89 @@ mod tests { assert_eq!(write(&a)[0].1, block); } + /// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`. + fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { + let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false); + // Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop. + for (i, (space, c)) in [a, b].into_iter().enumerate() { + let at = 24 + 20 * i + 8; + v[at..at + 2].copy_from_slice(&space.to_be_bytes()); + for (k, x) in c.iter().enumerate() { + v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes()); + } + } + v + } + + fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) { + match a { + Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1), + other => panic!("{other:?}"), + } + } + + #[test] + fn gradient_map_stops_in_every_colour_space() { + use photocraft_color::{Color, ColorMode}; + let white_rgb = (0, [65535, 65535, 65535, 0]); + // CMYK, stored inverted: 65535 = no ink. Full K only. + let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb); + let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb(); + for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] { + let a = parse(b"grdm", &k_only, None, channels); + let (first, last) = ends(&a); + assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}"); + assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}"); + assert_eq!(last, [1.0; 3]); + } + // Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white. + let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab); + let (first, last) = ends(&lab); + assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}"); + assert!(last.iter().all(|v| *v > 0.99), "{last:?}"); + // Lab with chroma: a = +60 is reddish (a and b are signed hundredths). + let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab); + let (first, _) = ends(&red); + assert!(first[0] > first[1] + 0.2, "{first:?}"); + // Grayscale: 0..10000 is ink coverage (10000 = black). + let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray); + assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3])); + // HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue. + let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb); + let (first, _) = ends(&hsb); + assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}"); + // Book colours and unknown ids are not guessed: the block stays raw. + for space in [3u16, 9, 0xFFFF] { + let d = grdm_with((space, [1, 2, 3, 4]), white_rgb); + assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}"); + } + // Truncated stops never panic. + for n in 0..k_only.len() { + let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk); + } + } + + #[test] + fn gradient_map_reverse_and_dither_keep_the_stops_block() { + let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0])); + let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() }; + let mut data = original.clone(); + let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true }; + assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk)); + assert_eq!(&data[2..4], &[1, 1]); + assert_eq!(data[4..], original[4..], "the stops keep their colour spaces"); + assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target); + // Edited stops are not patched in. + let mut edited = stops; + edited[0].1 = [0.5; 3]; + let mut data2 = original.clone(); + let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false }; + assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk)); + assert_eq!(data2, original); + assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk)); + assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk)); + } + fn rt(a: Adjustment) { let blocks = write(&a); assert!(!blocks.is_empty(), "{a:?}"); diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index a237759002..d3630bf35a 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -15,6 +15,7 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; pub(crate) const QUICK_MASK_INFO: u16 = 1022; const SPACE_RGB: u16 = 0; +const SPACE_HSB: u16 = 1; const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; @@ -66,9 +67,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan } } +/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces +/// (book colours…) read the first three components as RGB. See [`decode_known_color`]. pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color { let n = |v: u16| f32::from(v) / 65535.0; - match space { + decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2]))) +} + +/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB, +/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id. +/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see +/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion. +pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option { + let n = |v: u16| f32::from(v) / 65535.0; + Some(match space { + SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])), + SPACE_HSB => { + let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2])); + Color::rgb(rgb[0], rgb[1], rgb[2]) + } // Photoshop colour structures store CMYK inverted (65535 = no ink). SPACE_CMYK => { let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 }; @@ -87,7 +104,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color { let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0; Color::rgb(v, v, v) } - _ => Color::rgb(n(c[0]), n(c[1]), n(c[2])), + _ => return None, + }) +} + +/// HSB (hue in degrees, saturation and brightness 0..1) → RGB. +fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 }; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = h - h.floor(); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + match h.floor() as u8 { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], } } @@ -133,5 +166,13 @@ mod tests { assert_eq!(g.to_rgb(), [0.0; 3]); let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb(); assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}"); + // HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green. + let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb(); + assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}"); + let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb(); + assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}"); + // Unknown spaces (book colours) are not guessed. + assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None); + assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0))); } } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 5fdf837429..e8784e111d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -529,6 +529,12 @@ impl Ex { let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0; !legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a }); + // A Gradient Map whose stops are unchanged keeps the file's own block (stops in + // the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither + // updated in place. + let keep = keep + || (matches!(a, photocraft_doc::Adjustment::GradientMap { .. }) + && raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels))); if !keep { raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); let w = adjust_map::write(a); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 24beaa9044..137381fc54 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -702,7 +702,10 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // Capped like `build` (#2940): a group past the document's nesting cap keeps no children, // so no file can make this tree, or anything walking or dropping it, recurse deeper. let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH); - doc.layers = cx.build(&tree, 0); + // Colours stored in the document's CMYK space (Gradient Map stops…) convert through + // its own profile, as the compositor and the PSD writer read them. + let space = photocraft_compose::cmyk_space(&doc); + doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); // Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked). if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) diff --git a/crates/io/tests/color.rs b/crates/io/tests/color.rs index cb8acbd2e7..c5d4479904 100644 --- a/crates/io/tests/color.rs +++ b/crates/io/tests/color.rs @@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() { } } } + +/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`. +fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { + let mut v = vec![0, 1, 0, 0]; + v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&2u16.to_be_bytes()); + for (t, (space, c)) in [(0u32, a), (4096, b)] { + v.extend_from_slice(&t.to_be_bytes()); + v.extend_from_slice(&50u32.to_be_bytes()); + v.extend_from_slice(&space.to_be_bytes()); + for x in c { + v.extend_from_slice(&x.to_be_bytes()); + } + v.extend_from_slice(&[0, 0]); + } + v.extend_from_slice(&2u16.to_be_bytes()); + for t in [0u32, 4096] { + v.extend_from_slice(&t.to_be_bytes()); + v.extend_from_slice(&50u32.to_be_bytes()); + v.extend_from_slice(&255u16.to_be_bytes()); + } + v.extend_from_slice(&2u16.to_be_bytes()); // expansion count + v.extend_from_slice(&0u16.to_be_bytes()); // smoothness + v +} + +fn saved_grdm(bytes: &[u8]) -> Vec { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block") +} + +#[test] +fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() { + use photocraft_cms::{Intent, Transform, synth}; + use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; + let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }); + let profile = Profile::parse(&profile.to_bytes()).unwrap(); + let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap(); + // First stop 60 % cyan + 40 % black (stored inverted), second paper white. + let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]); + let paper = (2u16, [65535; 4]); + let lab_dark = (7u16, [2000, 0, 0, 0]); + let lab_red = (7u16, [5000, 6000u16, 2000, 0]); + let gray_black = (8u16, [10000, 0, 0, 0]); + let gray_white = (8u16, [0, 0, 0, 0]); + for depth in SampleType::ALL { + for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] { + let block = grdm_block(stops.0, stops.1); + let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth); + if mode == ColorMode::Cmyk { + doc.icc_profile = Some(profile.to_bytes()); + } + let fmt = doc.pixel_format(); + doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false)); + doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() }))); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let mut back = import("x.psd", &bytes).unwrap().document; + let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else { + panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content); + }; + let (first, last) = (got[0].1, got[got.len() - 1].1); + match mode { + ColorMode::Cmyk => { + // Through the document's own profile, not the built-in coated CMYK. + let mut expected = [0.0f32; 3]; + to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected); + assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}"); + let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]); + assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used"); + } + ColorMode::Lab => { + assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}"); + assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}"); + } + // An inverting map, white to black. + _ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])), + } + // The layer now renders: the composite differs from the background alone. + let with_map = photocraft_compose::flatten(&back).px; + let mut hidden = back.clone(); + hidden.layers[1].visible = false; + assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}"); + // Saving again keeps the block (and so the stops' colour space) byte for byte... + let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}"); + // ...and so does toggling Reverse and Dither, which only flip their flags. + if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content { + *reverse = true; + *dither = true; + } + let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + let flipped = saved_grdm(&flipped_file); + assert_eq!(&flipped[2..4], &[1, 1]); + assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space"); + let reopened = import("x.psd", &flipped_file).unwrap().document; + assert_eq!(reopened.layers[1].content, back.layers[1].content); + } + } +} From 3d0c819d464fa31f82266500b370f689c0c02076 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:44:36 -0400 Subject: [PATCH 054/523] Release prep: 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 58 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dceb5602e1..160403c482 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "async-io", @@ -4224,7 +4224,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.7.0" +version = "0.8.0" dependencies = [ "crc32fast", "flate2", @@ -4234,7 +4234,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4247,7 +4247,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64", "cap-std", @@ -4269,7 +4269,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.7.0" +version = "0.8.0" dependencies = [ "moxcms", "rayon", @@ -4291,7 +4291,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.7.0" +version = "0.8.0" dependencies = [ "exr", "flate2", @@ -4312,7 +4312,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-cms", "serde", @@ -4320,7 +4320,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.7.0" +version = "0.8.0" dependencies = [ "criterion", "photocraft-color", @@ -4335,7 +4335,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "blake3", "exr", @@ -4380,7 +4380,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.7.0" +version = "0.8.0" dependencies = [ "blake3", "crc32fast", @@ -4400,14 +4400,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.7.0" +version = "0.8.0" dependencies = [ "half", "log", @@ -4424,14 +4424,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.7.0" +version = "0.8.0" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.7.0" +version = "0.8.0" dependencies = [ "blake3", "crc32fast", @@ -4460,14 +4460,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4478,7 +4478,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4493,7 +4493,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "photocraft-psd", @@ -4504,7 +4504,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4514,7 +4514,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "image", @@ -4529,7 +4529,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.7.0" +version = "0.8.0" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4541,7 +4541,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.7.0" +version = "0.8.0" dependencies = [ "memory-stats", "photocraft-color", @@ -4552,7 +4552,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.7.0" +version = "0.8.0" dependencies = [ "flate2", "parley", @@ -4566,7 +4566,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64", "craft-ui", @@ -4605,7 +4605,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.7.0" +version = "0.8.0" dependencies = [ "photocraft-color", "photocraft-doc", @@ -4616,7 +4616,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -7449,7 +7449,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 856d9372b6..87e0a378ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 62346f7fb60b96f4c7020135f3bec73f9ca5f8b1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:45:50 -0400 Subject: [PATCH 055/523] =?UTF-8?q?feat(save):=20File=20=E2=80=BA=20Save?= =?UTF-8?q?=20keeps=20a=20JPEG's=20quality=20and=20a=20WebP's=20lossy/loss?= =?UTF-8?q?less=20encoding=20(#2399)=20(#4032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a JPEG now estimates the quality it was saved at from its quantisation tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact for such tables, the closest fit otherwise). Opening a WebP records whether it is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base quantiser index. The settings live on the document (`Document::file_encoding`, saved in .pcraft) and a save that asks for no quality writes the file back the same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start there instead of the fixed codec defaults (quality 90, WebP always lossless). An explicit quality still wins; new documents and other formats keep the defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic. Refs #2399 Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/lib.rs | 2 + crates/codecs/src/source_settings.rs | 355 +++++++++++++++++++++ crates/codecs/tests/source_settings.rs | 111 +++++++ crates/doc/src/lib.rs | 27 ++ crates/engine/src/file_cmds.rs | 3 + crates/engine/src/layer_multi_cmds.rs | 1 + crates/engine/tests/save_keeps_encoding.rs | 89 ++++++ crates/format/src/convert.rs | 2 + crates/format/src/manifest.rs | 3 + crates/io/src/flat.rs | 40 +++ crates/io/src/lib.rs | 5 +- crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/src/save_identity_tests.rs | 27 ++ docs/file-format-parity.md | 7 +- 14 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 crates/codecs/src/source_settings.rs create mode 100644 crates/codecs/tests/source_settings.rs create mode 100644 crates/engine/tests/save_keeps_encoding.rs diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index f107732555..424ec0b8aa 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -22,6 +22,7 @@ mod image; mod options; pub mod orientation; pub mod resolution; +pub mod source_settings; pub mod web; pub use crate::codecs::exr_cryptomatte::{cryptomatte_id, cryptomatte_key, cryptomatte_preview_color}; @@ -39,6 +40,7 @@ pub use crate::image::{ pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; +pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp}; diff --git a/crates/codecs/src/source_settings.rs b/crates/codecs/src/source_settings.rs new file mode 100644 index 0000000000..ebd0421c39 --- /dev/null +++ b/crates/codecs/src/source_settings.rs @@ -0,0 +1,355 @@ +//! The encoder settings a lossy file was written with, read back from its own data, so a save +//! can write it again the way it came (File › Save keeps a JPEG's quality and a WebP's lossy or +//! lossless encoding). +//! +//! * **JPEG**: the quality is estimated from the quantisation tables (DQT). Most encoders scale +//! the example tables of ITU-T T.81 Annex K (K.1 luminance, K.2 chrominance) by the +//! Independent JPEG Group's quality curve, so the quality whose scaled tables match the file's +//! best is the one it was saved at (exact for such tables; the closest fit for others). +//! * **WebP**: a `VP8L` image is lossless; a `VP8 ` image is lossy, and its base quantiser index +//! (RFC 6386 §9.6) maps back to a quality on the scale our encoder uses. +//! +//! Everything here reads hostile bytes: every index is checked and a malformed file gives `None`. + +use crate::codecs::vp8; + +/// ITU-T T.81 Annex K.1, the example luminance quantisation table, in natural (row) order. +const ANNEX_K_LUMA: [u16; 64] = [ + 16, 11, 10, 16, 24, 40, 51, 61, // + 12, 12, 14, 19, 26, 58, 60, 55, // + 14, 13, 16, 24, 40, 57, 69, 56, // + 14, 17, 22, 29, 51, 87, 80, 62, // + 18, 22, 37, 56, 68, 109, 103, 77, // + 24, 35, 55, 64, 81, 104, 113, 92, // + 49, 64, 78, 87, 103, 121, 120, 101, // + 72, 92, 95, 98, 112, 100, 103, 99, +]; + +/// ITU-T T.81 Annex K.2, the example chrominance quantisation table, in natural order. +const ANNEX_K_CHROMA: [u16; 64] = [ + 17, 18, 24, 47, 99, 99, 99, 99, // + 18, 21, 26, 66, 99, 99, 99, 99, // + 24, 26, 56, 99, 99, 99, 99, 99, // + 47, 66, 99, 99, 99, 99, 99, 99, // + 99, 99, 99, 99, 99, 99, 99, 99, // + 99, 99, 99, 99, 99, 99, 99, 99, // + 99, 99, 99, 99, 99, 99, 99, 99, // + 99, 99, 99, 99, 99, 99, 99, 99, +]; + +/// The natural-order index of the k-th coefficient in zig-zag order (T.81 Figure A.6): DQT +/// stores its 64 values in this order. +const ZIGZAG: [usize; 64] = [ + 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, + 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, +]; + +/// One quantisation table from a DQT segment, in natural order, with its precision. +#[derive(Debug, Clone, Copy)] +struct Table { + values: [u16; 64], + /// 16-bit entries (Pq = 1) rather than 8-bit. + wide: bool, +} + +/// The quality (1–100, the Independent JPEG Group's scale that Save As and Export As use) a +/// JPEG was most likely saved at, from its quantisation tables; `None` when it has no usable +/// luminance table (or isn't a JPEG). +pub fn jpeg_quality(bytes: &[u8]) -> Option { + let tables = jpeg_tables(bytes); + let [luma, chroma, ..] = tables; + let luma = luma?; + let mut best: Option<(u64, u8)> = None; + for q in 1..=100u8 { + let mut err = table_error(&luma, &ANNEX_K_LUMA, q); + if let Some(c) = chroma { + err = err.saturating_add(table_error(&c, &ANNEX_K_CHROMA, q)); + } + if best.is_none_or(|(e, _)| err < e) { + best = Some((err, q)); + } + } + best.map(|(_, q)| q) +} + +/// The Independent JPEG Group's quality scaling of `base` (libjpeg's `jpeg_quality_scaling` and +/// `jpeg_add_quant_table`, as published with its source): a percentage scale of 5000 / q below +/// quality 50 and 200 − 2q from it, rounded, and kept within 1..=255 (8-bit) or 1..=32767. +fn scaled(base: u16, q: u8, wide: bool) -> u32 { + let q = u32::from(q.clamp(1, 100)); + let scale = if q < 50 { 5000 / q } else { 200 - 2 * q }; + let v = (u32::from(base) * scale + 50) / 100; + v.clamp(1, if wide { 32767 } else { 255 }) +} + +/// How far `t` is from `base` scaled to quality `q`: the summed absolute difference. +fn table_error(t: &Table, base: &[u16; 64], q: u8) -> u64 { + t.values.iter().zip(base).map(|(&v, &b)| u64::from(u32::from(v).abs_diff(scaled(b, q, t.wide)))).sum() +} + +/// The quantisation tables 0..=3 defined before the first scan (a later definition of the same +/// slot replaces an earlier one). +fn jpeg_tables(b: &[u8]) -> [Option; 4] { + let mut tables = [None; 4]; + if b.get(..2) != Some(&[0xFF, 0xD8]) { + return tables; + } + let mut i = 2usize; + while let (Some(&0xFF), Some(&marker)) = (b.get(i), b.get(i.saturating_add(1))) { + if marker == 0xFF { + i = i.saturating_add(1); + continue; + } + if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 { + i = i.saturating_add(2); + continue; + } + if marker == 0xDA || marker == 0xD9 { + break; + } + let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))) else { break }; + let Some(seg) = len.checked_sub(2).and_then(|n| b.get(i + 4..i + 4 + n)) else { break }; + if marker == 0xDB { + read_dqt(seg, &mut tables); + } + i = i.saturating_add(2).saturating_add(len); + } + tables +} + +/// The tables of one DQT segment; a truncated or malformed table ends the segment. +fn read_dqt(mut seg: &[u8], tables: &mut [Option
; 4]) { + while let Some((&pq_tq, rest)) = seg.split_first() { + let wide = pq_tq >> 4 == 1; + if pq_tq >> 4 > 1 { + return; + } + let slot = usize::from(pq_tq & 0x0F); + let n = if wide { 128 } else { 64 }; + let Some(data) = rest.get(..n) else { return }; + let mut values = [0u16; 64]; + for (k, &natural) in ZIGZAG.iter().enumerate() { + let v = if wide { data.get(2 * k..2 * k + 2).map_or(0, |p| u16::from_be_bytes([p[0], p[1]])) } else { data.get(k).map_or(0, |&p| u16::from(p)) }; + if let Some(dst) = values.get_mut(natural) { + *dst = v; + } + } + if let Some(dst) = tables.get_mut(slot) { + *dst = Some(Table { values, wide }); + } + seg = rest.get(n..).unwrap_or(&[]); + } +} + +/// How a WebP file's image is compressed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WebpEncoding { + /// VP8L. + Lossless, + /// VP8, with the quality (0–100, our encoder's scale) its quantiser maps back to, when the + /// frame header could be read. + Lossy { quality: Option }, +} + +/// The compression of a WebP file's (first) image, or `None` when it isn't a WebP or holds no +/// image chunk. +pub fn webp_encoding(bytes: &[u8]) -> Option { + if bytes.get(..4) != Some(b"RIFF") || bytes.get(8..12) != Some(b"WEBP") { + return None; + } + find_image_chunk(bytes.get(12..)?, 0) +} + +/// The first `VP8 ` or `VP8L` chunk among `chunks`, looking inside animation frames. +fn find_image_chunk(mut chunks: &[u8], depth: u8) -> Option { + while let Some(head) = chunks.get(..8) { + let fourcc = head.get(..4)?; + let size = usize::try_from(u32::from_le_bytes([head[4], head[5], head[6], head[7]])).ok()?; + let end = 8usize.checked_add(size)?; + // A truncated last chunk still holds its header. + let payload = chunks.get(8..end.min(chunks.len()))?; + match fourcc { + b"VP8L" => return Some(WebpEncoding::Lossless), + b"VP8 " => return Some(WebpEncoding::Lossy { quality: vp8_quality(payload) }), + // An animation frame: a 16-byte header, then the frame's own chunks. + b"ANMF" if depth == 0 => { + if let Some(found) = payload.get(16..).and_then(|c| find_image_chunk(c, 1)) { + return Some(found); + } + } + _ => {} + } + chunks = chunks.get(end.checked_add(size % 2)?..).unwrap_or(&[]); + } + None +} + +/// The quality a VP8 key frame's base quantiser index maps back to on our encoder's scale (the +/// quality whose index is nearest; the middle one when several share it). +fn vp8_quality(frame: &[u8]) -> Option { + let qi = vp8_base_quantizer(frame)?; + let distance = |q: u8| vp8::quantizer_index(q).abs_diff(qi); + let best = (0..=100u8).map(distance).min()?; + let matches: Vec = (0..=100u8).filter(|&q| distance(q) == best).collect(); + let mid = matches.get(matches.len() / 2)?; + Some(*mid) +} + +/// The base quantiser index of a VP8 key frame: `y_ac_qi` from its frame header (RFC 6386 +/// §9.2–9.6, §19.2), averaged over the segments when segmentation sets absolute quantisers or +/// adjusted by their mean delta otherwise. +fn vp8_base_quantizer(frame: &[u8]) -> Option { + let tag = frame.get(..3)?; + // Bit 0 clear: a key frame, which carries the start code and dimensions. + if tag[0] & 1 != 0 || frame.get(3..6)? != [0x9D, 0x01, 0x2A] { + return None; + } + let mut d = BoolDecoder::new(frame.get(10..)?); + d.literal(1); // color_space + d.literal(1); // clamping_type + let mut segments: Option<(bool, [i32; 4])> = None; + if d.flag() { + let update_map = d.flag(); + if d.flag() { + let absolute = d.flag(); + let mut q = [0i32; 4]; + for v in &mut q { + *v = if d.flag() { d.signed(7) } else { 0 }; + } + for _ in 0..4 { + if d.flag() { + d.signed(6); + } + } + segments = Some((absolute, q)); + } + if update_map { + for _ in 0..3 { + if d.flag() { + d.literal(8); + } + } + } + } + d.literal(1); // filter_type + d.literal(6); // loop_filter_level + d.literal(3); // sharpness_level + if d.flag() && d.flag() { + // loop_filter_adj_enable, mode_ref_lf_delta_update: 4 reference and 4 mode deltas. + for _ in 0..8 { + if d.flag() { + d.signed(6); + } + } + } + d.literal(2); // log2_nbr_of_dct_partitions + if d.exhausted() { + return None; + } + let base = d.literal(7) as i32; + let qi = match segments { + Some((true, q)) => q.iter().sum::() / 4, + Some((false, q)) => base + q.iter().sum::() / 4, + None => base, + }; + u8::try_from(qi.clamp(0, 127)).ok() +} + +/// RFC 6386 §7's boolean decoder, reading zeros past the end of its data. +struct BoolDecoder<'a> { + data: &'a [u8], + pos: usize, + value: u32, + range: u32, + bit_count: u32, + /// Bytes read past the end of the data. + overrun: usize, +} + +impl<'a> BoolDecoder<'a> { + fn new(data: &'a [u8]) -> Self { + let mut d = BoolDecoder { data, pos: 0, value: 0, range: 255, bit_count: 0, overrun: 0 }; + d.value = (d.next_byte() << 8) | d.next_byte(); + d + } + + fn next_byte(&mut self) -> u32 { + match self.data.get(self.pos) { + Some(&b) => { + self.pos += 1; + u32::from(b) + } + None => { + self.overrun = self.overrun.saturating_add(1); + 0 + } + } + } + + /// The header was read from beyond the end of the data (a couple of bytes of look-ahead + /// are normal). + fn exhausted(&self) -> bool { + self.overrun > 2 + } + + fn bool(&mut self, prob: u32) -> bool { + let split = 1 + (((self.range - 1) * prob) >> 8); + let big_split = split << 8; + let bit = if self.value >= big_split { + self.range -= split; + self.value -= big_split; + true + } else { + self.range = split; + false + }; + while self.range < 128 { + self.value <<= 1; + self.range <<= 1; + self.bit_count += 1; + if self.bit_count == 8 { + self.bit_count = 0; + self.value |= self.next_byte(); + } + } + bit + } + + fn flag(&mut self) -> bool { + self.bool(128) + } + + fn literal(&mut self, bits: u32) -> u32 { + (0..bits).fold(0, |v, _| (v << 1) | u32::from(self.flag())) + } + + /// A magnitude of `bits` bits followed by a sign bit. + fn signed(&mut self, bits: u32) -> i32 { + let v = self.literal(bits) as i32; + if self.flag() { -v } else { v } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaling_matches_the_published_curve() { + // Quality 50 is the Annex K table itself; 100 is all ones; 75 halves it. + assert_eq!(scaled(16, 50, false), 16); + assert_eq!(scaled(121, 100, false), 1); + assert_eq!(scaled(16, 75, false), 8); + assert_eq!(scaled(121, 1, false), 255); + assert_eq!(scaled(121, 1, true), 6050); + } + + #[test] + fn zigzag_is_a_permutation() { + let mut seen = [false; 64]; + for &n in &ZIGZAG { + seen[n] = true; + } + assert!(seen.iter().all(|&s| s)); + } +} diff --git a/crates/codecs/tests/source_settings.rs b/crates/codecs/tests/source_settings.rs new file mode 100644 index 0000000000..9da82dbe79 --- /dev/null +++ b/crates/codecs/tests/source_settings.rs @@ -0,0 +1,111 @@ +//! Reading back the settings a lossy file was saved with: a JPEG's quality from its +//! quantisation tables, a WebP's lossy or lossless encoding (and its quality). + +mod common; +use common::*; +use photocraft_codecs::*; + +fn photo(w: u32, h: u32, layout: ChannelLayout) -> Image { + synth(w, h, layout, SampleType::U8, 5, 0.03) +} + +#[test] +fn jpeg_quality_is_estimated_within_two() { + for layout in [ChannelLayout::Rgb, ChannelLayout::Gray] { + let img = photo(40, 24, layout); + for q in [1u8, 5, 10, 25, 40, 50, 60, 70, 75, 80, 85, 90, 95, 98, 100] { + let bytes = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap(); + let est = jpeg_quality(&bytes).unwrap_or_else(|| panic!("{layout:?} q{q}: no estimate")); + assert!(est.abs_diff(q) <= 2, "{layout:?}: saved at {q}, estimated {est}"); + } + } +} + +#[test] +fn jpeg_quality_of_hand_built_tables() { + // A minimal JPEG prefix: SOI, one DQT holding the Annex K luminance table (quality 50) in + // zig-zag order, then SOS. + let luma: [u8; 64] = [ + 16, 11, 12, 14, 12, 10, 16, 14, 13, 14, 18, 17, 16, 19, 24, 40, 26, 24, 22, 22, 24, 49, 35, 37, 29, 40, 58, 51, 61, 60, 57, 51, 56, 55, 64, 72, 92, 78, + 64, 68, 87, 69, 55, 56, 80, 109, 81, 87, 95, 98, 103, 104, 103, 62, 77, 113, 121, 112, 100, 120, 92, 101, 103, 99, + ]; + let mut b = vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00]; + b.extend_from_slice(&luma); + b.extend_from_slice(&[0xFF, 0xDA, 0, 2]); + assert_eq!(jpeg_quality(&b), Some(50)); +} + +#[test] +fn hostile_jpeg_tables_give_no_estimate_and_never_panic() { + let img = photo(16, 16, ChannelLayout::Rgb); + let good = encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap(); + // Every truncation of a real file. + for n in 0..good.len().min(800) { + let _ = jpeg_quality(&good[..n]); + } + let cases: Vec> = vec![ + vec![], + vec![0xFF], + vec![0xFF, 0xD8], + // A DQT whose length runs past the end. + vec![0xFF, 0xD8, 0xFF, 0xDB, 0xFF, 0xFF, 0x00, 1, 2, 3], + // A DQT of length 0 and 1 (less than its own length field). + vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00], + vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01], + // A table cut short, a bad precision, a slot past 3, a 16-bit table cut mid-entry. + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 12, 0x00], vec![9; 9]].concat(), + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x70], vec![9; 64]].concat(), + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x0F], vec![9; 64]].concat(), + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 68, 0x10], vec![0xFF; 65]].concat(), + // All-zero and all-0xFF tables. + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 67, 0x00], vec![0; 64]].concat(), + [vec![0xFF, 0xD8, 0xFF, 0xDB, 0x00, 131, 0x10], vec![0xFF; 128]].concat(), + vec![0xFF; 64], + ]; + for c in &cases { + let _ = jpeg_quality(c); + } + assert_eq!(jpeg_quality(&[0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x00]), None); + assert_eq!(jpeg_quality(b"not a jpeg at all"), None); + let png = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(jpeg_quality(&png), None); +} + +#[test] +fn webp_lossless_and_lossy_are_told_apart() { + let img = photo(33, 21, ChannelLayout::Rgb); + let lossless = encode(&img, Format::WebP, &EncodeOptions::default()).unwrap(); + assert_eq!(webp_encoding(&lossless), Some(WebpEncoding::Lossless)); + for q in [10u8, 30, 50, 75, 80, 90, 100] { + let bytes = encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: q, ..Default::default() }).unwrap(); + let Some(WebpEncoding::Lossy { quality: Some(est) }) = webp_encoding(&bytes) else { panic!("q{q}: {:?}", webp_encoding(&bytes)) }; + assert!(est.abs_diff(q) <= 2, "lossy WebP saved at {q}, estimated {est}"); + } + // With alpha and metadata (the extended container). + let rgba = photo(20, 20, ChannelLayout::Rgba); + let bytes = encode(&rgba, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap(); + assert!(matches!(webp_encoding(&bytes), Some(WebpEncoding::Lossy { quality: Some(58..=62) })), "{:?}", webp_encoding(&bytes)); +} + +#[test] +fn hostile_webp_never_panics() { + let img = photo(24, 24, ChannelLayout::Rgb); + for opts in [EncodeOptions::default(), EncodeOptions { webp_lossless: false, ..Default::default() }] { + let good = encode(&img, Format::WebP, &opts).unwrap(); + for n in 0..good.len().min(400) { + let _ = webp_encoding(&good[..n]); + } + let mut flipped = good.clone(); + for i in 12..flipped.len().min(64) { + flipped[i] ^= 0xA5; + let _ = webp_encoding(&flipped); + } + } + let mut huge = b"RIFF\0\0\0\0WEBPVP8 ".to_vec(); + huge.extend_from_slice(&u32::MAX.to_le_bytes()); + let _ = webp_encoding(&huge); + let mut anmf = b"RIFF\0\0\0\0WEBPANMF".to_vec(); + anmf.extend_from_slice(&3u32.to_le_bytes()); + let _ = webp_encoding(&anmf); + assert_eq!(webp_encoding(b"RIFF\0\0\0\0WAVE"), None); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c92b6db735..5ff69e581c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -875,6 +875,32 @@ pub struct Document { pub variables: Variables, /// Window › Timeline (None until a video timeline is created). pub timeline: Option, + /// How the document's lossy file was encoded (its JPEG quality, WebP lossy or lossless), so + /// a save writes it back the same way rather than at fixed defaults. + pub file_encoding: FileEncoding, +} + +/// The encoder settings a document's file was opened or saved with: what File › Save and the +/// Save As defaults reuse for that format. Empty for a new document or a file without lossy +/// options. Qualities are on the codecs' 1–100 scale. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FileEncoding { + /// JPEG quality (estimated from the file's quantisation tables on open). + #[serde(skip_serializing_if = "Option::is_none")] + pub jpeg_quality: Option, + /// WebP: `Some(true)` lossless (VP8L), `Some(false)` lossy (VP8 at [`Self::webp_quality`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub webp_lossless: Option, + /// Lossy WebP quality. + #[serde(skip_serializing_if = "Option::is_none")] + pub webp_quality: Option, +} + +impl FileEncoding { + pub fn is_empty(&self) -> bool { + *self == FileEncoding::default() + } } /// Where a layer lives in the tree: indices from the root down. @@ -913,6 +939,7 @@ impl Document { slices: Slices::default(), variables: Variables::default(), timeline: None, + file_encoding: FileEncoding::default(), } } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 6e4bf49f11..d175473370 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -285,6 +285,9 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R let save = save.into(); let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() }; + // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP + // lossy or lossless as it was), as Photoshop's Save does. + photocraft_io::apply_file_encoding(doc, &mut opts.encode); if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 7f3cc09616..62006d0832 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -628,6 +628,7 @@ fn doc_pixels_equal(a: &Document, b: &Document) -> bool { slices: _, variables: _, timeline: _, + file_encoding: _, } = a; *size == b.size && resolution_dpi.to_bits() == b.resolution_dpi.to_bits() diff --git a/crates/engine/tests/save_keeps_encoding.rs b/crates/engine/tests/save_keeps_encoding.rs new file mode 100644 index 0000000000..b10237fd81 --- /dev/null +++ b/crates/engine/tests/save_keeps_encoding.rs @@ -0,0 +1,89 @@ +//! #2399: a save writes a JPEG or WebP back the way it was opened. A JPEG keeps the quality +//! estimated from its quantisation tables, a lossy WebP stays lossy and a lossless one lossless, +//! unless the save asks for a quality; the remembered settings survive a .pcraft round trip. + +use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, WebpEncoding, jpeg_quality, webp_encoding}; +use photocraft_engine::Session; +use photocraft_engine::file_cmds::open_bytes_as; +use serde_json::json; + +fn photo() -> Image { + let (w, h) = (48u32, 32u32); + let px = (0..w * h).flat_map(|i| { + let (x, y) = (i % w, i / w); + [(x * 5) as u8, (y * 7) as u8, ((x * y) % 251) as u8] + }); + Image::from_raw(w, h, ChannelLayout::Rgb, SampleType::U8, px.collect()).unwrap() +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("photocraft-2399-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +/// Opens `bytes` as `name`, saves a copy to `out` with `params`, and returns the saved file. +fn open_and_save(name: &str, bytes: &[u8], out: &str, params: serde_json::Value) -> (Session, Vec) { + let mut s = Session::new(); + open_bytes_as(&mut s, name, bytes, None, None).unwrap(); + let path = tmp(out); + let mut p = params; + p["path"] = json!(path.to_string_lossy()); + s.execute("file.saveACopy", p).unwrap(); + let saved = std::fs::read(&path).unwrap(); + (s, saved) +} + +#[test] +fn a_jpeg_saves_at_its_own_quality() { + for q in [35u8, 60, 77, 95] { + let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: q, ..Default::default() }).unwrap(); + let (s, saved) = open_and_save("in.jpg", &src, &format!("q{q}.jpg"), json!({})); + let remembered = s.active().unwrap().doc.file_encoding.jpeg_quality.unwrap(); + assert!(remembered.abs_diff(q) <= 2, "opened at {q}, remembered {remembered}"); + let est = jpeg_quality(&saved).unwrap(); + assert!(est.abs_diff(q) <= 2, "opened at {q}, saved at {est}"); + } + // A quality the save asks for still wins (Photoshop's 0–12 scale: 12 is the top). + let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 40, ..Default::default() }).unwrap(); + let (_, saved) = open_and_save("in.jpg", &src, "explicit.jpg", json!({"quality": 12})); + assert_eq!(jpeg_quality(&saved), Some(100)); +} + +#[test] +fn a_webp_stays_lossy_or_lossless() { + let lossy = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 60, ..Default::default() }).unwrap(); + let (s, saved) = open_and_save("in.webp", &lossy, "lossy.webp", json!({})); + let e = s.active().unwrap().doc.file_encoding; + assert_eq!(e.webp_lossless, Some(false)); + let Some(WebpEncoding::Lossy { quality: Some(q) }) = webp_encoding(&saved) else { panic!("saved {:?}", webp_encoding(&saved)) }; + assert!(q.abs_diff(60) <= 2, "lossy WebP at 60 saved at {q}"); + + let lossless = photocraft_codecs::encode(&photo(), Format::WebP, &EncodeOptions::default()).unwrap(); + let (s, saved) = open_and_save("in.webp", &lossless, "lossless.webp", json!({})); + assert_eq!(s.active().unwrap().doc.file_encoding.webp_lossless, Some(true)); + assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless)); + assert_eq!(photocraft_codecs::decode(&saved).unwrap().data(), photocraft_codecs::decode(&lossless).unwrap().data(), "lossless round trip"); +} + +#[test] +fn a_new_document_and_a_png_save_at_the_defaults() { + let png = photocraft_codecs::encode(&photo(), Format::Png, &EncodeOptions::default()).unwrap(); + let (s, saved) = open_and_save("in.png", &png, "from-png.jpg", json!({})); + assert!(s.active().unwrap().doc.file_encoding.is_empty()); + assert_eq!(jpeg_quality(&saved), Some(EncodeOptions::default().jpeg_quality)); + let (_, saved) = open_and_save("in.png", &png, "from-png.webp", json!({})); + assert_eq!(webp_encoding(&saved), Some(WebpEncoding::Lossless)); +} + +#[test] +fn the_remembered_encoding_survives_pcraft() { + let src = photocraft_codecs::encode(&photo(), Format::Jpeg, &EncodeOptions { jpeg_quality: 70, ..Default::default() }).unwrap(); + let mut s = Session::new(); + open_bytes_as(&mut s, "in.jpg", &src, None, None).unwrap(); + let doc = &s.active().unwrap().doc; + let bytes = photocraft_format::save_to_bytes(doc, &photocraft_format::SaveOptions::default()).unwrap(); + let back = photocraft_format::load_from_bytes(&bytes).unwrap(); + assert_eq!(back.file_encoding, doc.file_encoding); + assert!(back.file_encoding.jpeg_quality.is_some()); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index d319c85a9d..4dbbb6aa38 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -253,6 +253,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { duotone: d.duotone.clone(), variables: d.variables.clone(), timeline: d.timeline.clone(), + file_encoding: d.file_encoding, layer_comps: d.layer_comps.iter().map(|c| comp_m(c, sink)).collect(), last_applied_comp: d.last_applied_comp, last_document_state: d.last_document_state.as_ref().map(|c| comp_m(c, sink)), @@ -631,6 +632,7 @@ impl Loader<'_> { duotone: m.duotone.clone(), variables: m.variables.clone(), timeline: m.timeline.clone(), + file_encoding: m.file_encoding, layer_comps, last_applied_comp: m.last_applied_comp, last_document_state, diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 6524bfdfc8..d76a496fb0 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -84,6 +84,9 @@ pub struct DocM { /// Window › Timeline. #[serde(default)] pub timeline: Option, + /// The JPEG quality / WebP encoding File › Save reuses. + #[serde(default, skip_serializing_if = "photocraft_doc::FileEncoding::is_empty")] + pub file_encoding: photocraft_doc::FileEncoding, /// Image › Analysis: measurement scale, count groups, ruler. #[serde(default)] pub measurement: photocraft_doc::Measurement, diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 3659666f8e..450969e827 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -21,6 +21,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { } let img = codecs::decode(bytes)?; let mut r = image_to_document(name, &img)?; + r.document.file_encoding = file_encoding(bytes); if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; } @@ -50,6 +51,45 @@ pub fn import_gif_frames(name: &str, bytes: &[u8], each: &mut dyn FnMut(Document Ok(n) } +/// The lossy settings `bytes` were encoded with, for File › Save to keep: a JPEG's quality +/// (estimated from its quantisation tables) and a WebP's lossless or lossy encoding with its +/// quality. A lossy WebP whose quality can't be read gets [`DEFAULT_LOSSY_WEBP_QUALITY`]. +pub fn file_encoding(bytes: &[u8]) -> photocraft_doc::FileEncoding { + let mut e = photocraft_doc::FileEncoding::default(); + match codecs::detect(bytes) { + Some(Format::Jpeg) => e.jpeg_quality = codecs::jpeg_quality(bytes), + Some(Format::WebP) => match codecs::webp_encoding(bytes) { + Some(codecs::WebpEncoding::Lossless) => e.webp_lossless = Some(true), + Some(codecs::WebpEncoding::Lossy { quality }) => { + e.webp_lossless = Some(false); + e.webp_quality = Some(quality.unwrap_or(DEFAULT_LOSSY_WEBP_QUALITY)); + } + None => {} + }, + _ => {} + } + e +} + +/// The quality a lossy WebP is saved at when its own can't be read. +pub const DEFAULT_LOSSY_WEBP_QUALITY: u8 = 75; + +/// Applies the settings `doc`'s file was encoded with (see [`file_encoding`]) to `opts`: what a +/// save that chose no quality writes, so a JPEG keeps its quality and a WebP stays lossy or +/// lossless. +pub fn apply_file_encoding(doc: &Document, opts: &mut codecs::EncodeOptions) { + let e = &doc.file_encoding; + if let Some(q) = e.jpeg_quality { + opts.jpeg_quality = q.clamp(1, 100); + } + if let Some(lossless) = e.webp_lossless { + opts.webp_lossless = lossless; + } + if let Some(q) = e.webp_quality { + opts.webp_quality = q.min(100); + } +} + /// Photoshop opens a 32-bit Targa as an opaque Background plus an "Alpha 1" channel holding the /// fourth byte, even when the header declares no alpha bits, and keeps the stored colours (#2225; /// measured on Photoshop 27.11). Layer transparency would hide those colours and lose the channel diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 4a395e1630..9ea361994b 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -64,7 +64,10 @@ use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; pub use adjust_map::ADJUSTMENT_KEYS; -pub use flat::{document_to_image, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, tga_default_bits}; +pub use flat::{ + DEFAULT_LOSSY_WEBP_QUALITY, apply_file_encoding, document_to_image, file_encoding, import_gif_frames, import_tiff_page, tga_alpha_channel_to_transparency, + tga_default_bits, +}; pub use psd_export::{PsdExportOptions, document_to_psd, document_to_psd_with}; pub use psd_import::{psd_to_document, psd_to_document_with}; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9d80288b67..50a8998f78 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1284,10 +1284,19 @@ impl PhotocraftApp { /// What a save writes by default under the preferences: File Handling › Image Previews /// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox - /// here yet, so it saves the preview, as Photoshop's checkbox starts on. + /// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP + /// is written the way the active document's file was (its JPEG quality, a WebP lossy at + /// its quality or lossless), as Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never; - ExportSettings { image_previews: previews, ..ExportSettings::default() } + let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() }; + if let Some(st) = self.session.active() { + let e = &st.doc.file_encoding; + settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); + settings.webp_lossless = e.webp_lossless.unwrap_or(settings.webp_lossless); + settings.webp_quality = e.webp_quality.map(|q| q.min(100)); + } + settings } /// Whether saving the active document to `path` writes a copy. `tiff_layers` is the TIFF diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index 1aed0fa5b7..c0cd85eaed 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -204,3 +204,30 @@ fn image_previews_preference_reaches_the_exporter() { assert_eq!(*seen.borrow(), [expect; 3], "{value}"); } } + +/// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, +/// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. +#[test] +fn save_settings_start_at_the_opened_files_encoding() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + let px: Vec = (0..32u32 * 24).flat_map(|i| [(i * 3) as u8, (i / 32 * 9) as u8, (i % 97) as u8]).collect(); + let img = Image::from_raw(32, 24, ChannelLayout::Rgb, SampleType::U8, px).unwrap(); + let mut app = app(); + assert_eq!(app.save_settings().jpeg_quality, None); + assert!(app.save_settings().webp_lossless); + + let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions { jpeg_quality: 65, ..Default::default() }).unwrap(); + app.open_bytes("photo.jpg", &jpeg).unwrap(); + let q = app.save_settings().jpeg_quality.unwrap(); + assert!(q.abs_diff(65) <= 2, "estimated {q}"); + + let lossy = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions { webp_lossless: false, webp_quality: 80, ..Default::default() }).unwrap(); + app.open_bytes("photo.webp", &lossy).unwrap(); + let s = app.save_settings(); + assert!(!s.webp_lossless); + assert!(s.webp_quality.unwrap().abs_diff(80) <= 2, "{:?}", s.webp_quality); + + let lossless = photocraft_codecs::encode(&img, Format::WebP, &EncodeOptions::default()).unwrap(); + app.open_bytes("art.webp", &lossless).unwrap(); + assert!(app.save_settings().webp_lossless); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c1da1665a9..deb414961d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -25,11 +25,11 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | -| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | +| JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | | BMP | R/W | yes | yes | 8-bit | -| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP | +| WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | | HEIF/HEIC | R/W(HEIF) | yes (pure-Rust heic-rs, `heif` feature, on in releases) | no | 8/16-bit | @@ -140,5 +140,6 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | From f86517a029d96f887cd82ff8062a5e7759372c5e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:50:36 -0400 Subject: [PATCH 056/523] fix(psd): write descriptor ids warp, list and time as length-4 strings (#4039) A descriptor class or key id is a u32 length and bytes, where length 0 means a 4-byte code follows. Photoshop stores `warp`, `list` and `time` as length-4 strings and treats the code form as a different key, so the warps of new smart objects and type layers and regenerated layer comp lists were not recognised. In the corpus, `warp` appears 741 times as a string and never as a code, `list` 5/0, `time` 7/0. `Id::new` now makes these three names string ids, as keys and class ids. Reading is byte-based and accepts both forms; a code read from a file is written back as a code. Tests: exact bytes for each name as class and key, both forms read, fresh placed-layer warp (SoLd/PlLd) and layer comp list bytes. Co-authored-by: Claude Opus 5.5 --- crates/io/src/comps_map.rs | 3 +++ crates/io/src/smart_map.rs | 22 +++++++++++++++++++ crates/psd/src/descriptor.rs | 41 ++++++++++++++++++++++++++++++++++-- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/io/src/comps_map.rs b/crates/io/src/comps_map.rs index 5b0759da37..8d0c78f3b0 100644 --- a/crates/io/src/comps_map.rs +++ b/crates/io/src/comps_map.rs @@ -376,6 +376,9 @@ mod tests { }); d.last_applied_comp = Some(7); let res = write_comps_resource(&d).unwrap(); + // `list` is a length-4 string id, as Photoshop writes it, never a code. + assert!(res.windows(8).any(|w| w == b"\0\0\0\x04list")); + assert!(!res.windows(8).any(|w| w == b"\0\0\0\0list")); let cm = write_cmls(&d.layer_comps, None, d.layer(lid).unwrap(), 3).unwrap(); // Attach as Photoshop would: shmd with cmls plus another item. let mut raw = vec![(*b"shmd", write_shmd(&[MetadataItem::new(*b"cust", vec![1, 2])]))]; diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index f1fb78e910..d4c02894be 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -817,6 +817,28 @@ pub fn uuid_from(seed: &[u8]) -> String { mod tests { use super::*; + fn count(hay: &[u8], needle: &[u8]) -> usize { + hay.windows(needle.len()).filter(|w| *w == needle).count() + } + + /// A freshly built placed layer writes `warp` (key and class) as a length-4 string, as + /// Photoshop does; the code form is a different key to Photoshop. + #[test] + fn fresh_placed_warp_is_a_string_id() { + let spec = + PlacedSpec { idnt: "id", placed: "id", transform: Affine::IDENTITY, perspective: None, size: (10.0, 10.0), dpi: 72.0, warp: None, filter_fx: None }; + let mut warnings = Vec::new(); + let sold = sold_bytes(None, &spec, &mut warnings); + let plld = plld_bytes(&spec, &mut warnings); + // `SoLd`: the `warp` key and the warp descriptor's class; `PlLd`: the descriptor's class. + for (bytes, n) in [(&sold, 2), (&plld, 1)] { + assert_eq!(count(bytes, b"\0\0\0\x04warp"), n); + assert_eq!(count(bytes, b"\0\0\0\0warp"), 0); + } + let d = sold_descriptor(&sold).unwrap(); + assert!(matches!(d.get("warp"), Some(Value::Descriptor(w)) if w.class_id == Id::Str(b"warp".to_vec()))); + } + fn sf(command: &str, params: J) -> SmartFilter { SmartFilter { command: command.into(), params, blend: BlendMode::Normal, opacity: 1.0, visible: true } } diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index a2eab7ae52..3e0cc2516e 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -44,6 +44,10 @@ impl UnicodeString { } } +/// Four-character class and key ids Photoshop writes as length-4 strings, never as codes +/// (`00000004 'warp'`, not `00000000 'warp'`). +pub const STRING_IDS: [&str; 3] = ["warp", "list", "time"]; + /// A class / key / type identifier: a length-prefixed string, where a stored /// length of 0 means a 4-character code follows. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -57,10 +61,15 @@ pub enum Id { impl Id { /// Convenience constructor: 4-byte strings become [`Id::Code`], others - /// [`Id::Str`]. + /// [`Id::Str`]. The four-character names Photoshop stores as length-4 strings + /// ([`STRING_IDS`]: `warp`, `list`, `time`) are [`Id::Str`] too: Photoshop treats the + /// string and the code as different keys and doesn't recognise the code form. pub fn new(s: &str) -> Self { let b = s.as_bytes(); - if b.len() == 4 { Id::Code([b[0], b[1], b[2], b[3]]) } else { Id::Str(b.to_vec()) } + match b { + [a, b, c, d] if !STRING_IDS.contains(&s) => Id::Code([*a, *b, *c, *d]), + _ => Id::Str(b.to_vec()), + } } /// The identifier's bytes. pub fn as_bytes(&self) -> &[u8] { @@ -609,6 +618,34 @@ mod tests { assert_eq!(p.to_bytes(), b); } + /// `warp`, `list` and `time` are written as length-4 strings, as Photoshop writes them, both + /// as keys and as class ids; other four-character names stay codes. Reading accepts both + /// forms, and a code read from a file is written back as a code. + #[test] + fn photoshop_string_ids_are_written_as_strings() { + let str_id = |s: &str| [&[0, 0, 0, 4][..], s.as_bytes()].concat(); + let code_id = |s: &str| [&[0, 0, 0, 0][..], s.as_bytes()].concat(); + for name in STRING_IDS { + assert_eq!(Id::new(name), Id::Str(name.as_bytes().to_vec())); + let d = Descriptor::new(name).with(name, Value::Integer(1)); + let b = d.to_bytes(); + // Name (empty unicode string), then the class id; then the item count and the key. + assert_eq!(&b[4..12], &str_id(name)[..], "class {name}"); + assert_eq!(&b[16..24], &str_id(name)[..], "key {name}"); + let back = Descriptor::from_bytes(&b).unwrap(); + assert_eq!(back, d); + assert!(back.class_id.is(name) && back.get(name).is_some()); + // A file that wrote the code form keeps it, and lookups still find the key. + let mut coded = b.clone(); + coded.splice(16..24, code_id(name)); + let back = Descriptor::from_bytes(&coded).unwrap(); + assert_eq!(back.get(name), Some(&Value::Integer(1))); + assert_eq!(back.to_bytes(), coded); + } + assert_eq!(Id::new("view"), Id::Code(*b"view")); + assert_eq!(Id::new("Txt "), Id::Code(*b"Txt ")); + } + #[test] fn versioned_roundtrip() { let v = VersionedDescriptor::new(sample()); From 32552a61a1bce252f203aa49aeb8ce8fb9379180 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 05:56:09 -0400 Subject: [PATCH 057/523] fix(liquify): dab spacing runs on along the whole stroke, so pointer speed no longer changes the warp (#2606) (#4047) Liquify dabs were spaced a fifth of the brush radius apart and restarted at every pointer event, so a fast mouse (few far-apart events) and a slow one (many close ones) warped the same path differently, and a slow drag piled up one dab per event. Dabs now fall every twentieth of the radius by distance travelled along the whole stroke, carried from one segment to the next, as in Photoshop: any sampling of a path gives the same field. The stationary tools' per-dab strength is scaled to the new spacing so their effect per distance travelled is unchanged. Co-authored-by: Claude Opus 5.5 --- crates/algo/examples/bench_liquify.rs | 16 ++ crates/algo/src/liquify.rs | 252 ++++++++++++++++++++------ 2 files changed, 212 insertions(+), 56 deletions(-) diff --git a/crates/algo/examples/bench_liquify.rs b/crates/algo/examples/bench_liquify.rs index a953463f07..2eea3b951b 100644 --- a/crates/algo/examples/bench_liquify.rs +++ b/crates/algo/examples/bench_liquify.rs @@ -59,6 +59,22 @@ fn main() { prev = p; } println!("forward-warp segment (600 px brush, 25 px moves) + proxy update: mean {:.2} ms, worst {:.2} ms", total / n as f64, worst); + // One 3000 px drag with a 200 px brush (a straight leg, then a diagonal one), as a slow + // (every 2 px) and a fast (every 60 px) pointer reports it: the field cost of the whole + // stroke, and how far the two results differ. + let mut drag = LiquifyStroke::new(LiquifyTool::ForwardWarp, 200.0); + let mut fields = Vec::new(); + let along = |d: f64| if d <= 1500.0 { vec![1500.0 + d, 2000.0] } else { vec![3000.0 + (d - 1500.0) * 0.6, 2000.0 + (d - 1500.0) * 0.8] }; + for step in [2.0, 60.0] { + drag.points = (0..=(3000.0 / step) as usize).map(|k| along(k as f64 * step)).collect(); + let t = Instant::now(); + let mut f = LiquifyField::new(b, cell); + f.apply_stroke(&drag); + println!("forward-warp 3000 px drag (200 px brush), an event every {step} px: {:.1} ms", t.elapsed().as_secs_f64() * 1e3); + fields.push(f); + } + let diff = fields[0].d.iter().zip(&fields[1].d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max); + println!(" slow vs fast pointer: fields differ by at most {diff:.3} px (max displacement {:.1} px)", fields[0].max_displacement()); st.tool = LiquifyTool::Bloat; let t = Instant::now(); let mut d = Rect::EMPTY; diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index 7e3bb60644..adbca8680f 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -154,7 +154,7 @@ impl LiquifyStroke { } /// The displacement field plus the freeze mask, over `bounds` (document pixels). -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct LiquifyField { pub bounds: Rect, /// Document pixels per field node. @@ -165,6 +165,38 @@ pub struct LiquifyField { pub d: Vec<[f32; 2]>, /// Freeze mask per node (0 = free, 1 = frozen). pub freeze: Vec, + /// Where the brush stroke in progress stands, so dab spacing runs on across its segments. + cursor: Option, +} + +/// Two fields are equal when their displacement and freeze mask are: the stroke cursor is +/// transient brush state, not part of the result. +impl PartialEq for LiquifyField { + fn eq(&self, o: &Self) -> bool { + self.bounds == o.bounds && self.cell == o.cell && self.w == o.w && self.h == o.h && self.d == o.d && self.freeze == o.freeze + } +} + +/// The brush stroke in progress: the point its last segment ended at, its last dab and how far +/// the brush has travelled along the path since that dab. +#[derive(Clone, Copy, Debug, PartialEq)] +struct StrokeCursor { + end: [f64; 2], + last_dab: [f64; 2], + carry: f64, +} + +/// Distance between dabs along a stroke for brush radius `r`: a twentieth of the radius, so a +/// drag is many small pushes and its edge stays smooth. +fn dab_spacing(r: f64) -> f64 { + (r * 0.05).max(0.5) +} + +/// Per-dab strength of a stationary tool (Twirl, Pucker, Bloat, Reconstruct, Smooth, Freeze, +/// Thaw) while it moves: scaled to the spacing so its effect per distance travelled stays what it +/// was with dabs a fifth of the radius apart. +fn moving_weight(r: f64) -> f64 { + dab_spacing(r) / (r * 0.2).max(0.5) } /// The default field resolution for `bounds`: 2 px per node up to 4 MP, 4 px beyond. @@ -177,7 +209,7 @@ impl LiquifyField { let cell = cell.clamp(1.0, 64.0); let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2; let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2; - LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h] } + LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None } } /// Document position of node (i, j) (node 0 sits on the first pixel centre). @@ -220,6 +252,7 @@ impl LiquifyField { /// Applies one stroke. Returns the document rectangle whose output changed. pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect { + self.cursor = None; match s.tool { LiquifyTool::LassoMask => return self.apply_lasso(s), LiquifyTool::ReconstructAll => { @@ -306,77 +339,120 @@ impl LiquifyField { /// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what /// [`Self::apply_stroke`] does with the whole point list. pub fn stroke_begin(&mut self, s: &LiquifyStroke, p: [f64; 3]) -> Rect { - if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0)) } else { Rect::EMPTY } + self.cursor = Some(StrokeCursor { end: [p[0], p[1]], last_dab: [p[0], p[1]], carry: 0.0 }); + if s.tool.is_stationary() && !s.tool.is_global() { self.dab(s, [p[0], p[1]], [0.0, 0.0], p[2].clamp(0.0, 1.0), 1.0) } else { Rect::EMPTY } } - /// Continues a stroke from `a` to `b`: dabs every fifth of the brush radius (a stationary - /// tool held still dabs once at `b`). + /// Continues a stroke from `a` to `b`. Dabs fall every [`dab_spacing`] of distance travelled + /// along the whole stroke, carried from one segment to the next, so the result is the same + /// however finely the pointer path was sampled (a fast and a slow mouse give one warp). A + /// stationary tool held still dabs once at `b`. When `a` is not where the previous segment + /// ended, the stroke starts afresh at `a`. pub fn stroke_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect { + self.walk_segment(s, a, b, true) + } + + /// [`Self::stroke_segment`]; `skip` leaves out the dabs that cannot reach a field node (they + /// would change nothing), which tests compare with the full walk. + fn walk_segment(&mut self, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3], skip: bool) -> Rect { let mut dirty = Rect::EMPTY; let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0)); - let len = (b[0] - a[0]).hypot(b[1] - a[1]); + let stationary = s.tool.is_stationary(); + let cur = match self.cursor { + Some(c) if c.end == [a[0], a[1]] => c, + _ => StrokeCursor { end: [a[0], a[1]], last_dab: [a[0], a[1]], carry: 0.0 }, + }; + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len = dx.hypot(dy); if len == 0.0 { - return if s.tool.is_stationary() { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb) } else { dirty }; + self.cursor = Some(cur); + return if stationary { self.dab(s, [b[0], b[1]], [0.0, 0.0], pb, 1.0) } else { dirty }; + } + // NaN or a length that overflowed: nothing sensible to walk; the next segment restarts. + if !len.is_finite() { + self.cursor = None; + return dirty; } let r = (s.size / 2.0).max(0.5); - let spacing = (r * 0.2).max(0.5); - let steps = (len / spacing).ceil().max(1.0) as usize; - // Walk only the steps whose dab can reach a field node: the rest return from `dab` before - // touching anything, and a segment to a far-off point has trillions of them (#937). - let Some((k0, k1)) = self.reachable_steps(a, b, steps, r, s.tool.is_stationary()) else { return dirty }; - for k in k0..=k1 { - let t0 = (k - 1) as f64 / steps as f64; - let t1 = k as f64 / steps as f64; - let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1]; - let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)]; - let pr = pa + (pb - pa) * t1; - // Forward Warp centres the dab where the brush was (it drags what is under it). - let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] }; - dirty = dirty.union(&self.dab(s, centre, delta, pr)); + let spacing = dab_spacing(r); + let weight = if stationary { moving_weight(r) } else { 1.0 }; + // Distance along this segment of its first dab, then one every `spacing`. + let first = (spacing - cur.carry).clamp(0.0, spacing); + // Distances are compared with a little slack: a dab that rounding puts a hair past the + // segment's end still falls on it, so cutting one segment in two never adds or drops a dab. + let slack = spacing * 1e-6; + if first > len + slack { + self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: cur.last_dab, carry: cur.carry + len }); + return dirty; } + let last_k = ((len - first + slack) / spacing).floor().max(0.0); + let at = |dist: f64| [a[0] + dx * dist / len, a[1] + dy * dist / len]; + let (mut k0, mut k1) = (0.0f64, last_k); + if skip { + match self.reachable_span(a, [dx, dy], len, r) { + // A dragging dab is centred one spacing behind its position: pad by that and a step. + Some((d0, d1)) => { + k0 = k0.max(((d0 - spacing - first) / spacing).floor() - 1.0); + k1 = k1.min(((d1 + spacing - first) / spacing).ceil() + 1.0); + } + None => k1 = -1.0, + } + } + if k0 <= k1 { + let mut prev = if k0 > 0.0 { at(first + (k0 - 1.0) * spacing) } else { cur.last_dab }; + for k in (k0 as u64)..=(k1 as u64) { + let dist = (first + k as f64 * spacing).min(len); + let c = at(dist); + let delta = [c[0] - prev[0], c[1] - prev[1]]; + // Forward Warp centres the dab where the brush was (it drags what is under it). + let centre = if stationary { c } else { prev }; + dirty = dirty.union(&self.dab(s, centre, delta, pa + (pb - pa) * dist / len, weight)); + prev = c; + } + } + let last = (first + last_k * spacing).min(len); + self.cursor = Some(StrokeCursor { end: [b[0], b[1]], last_dab: at(last), carry: (len - last).clamp(0.0, spacing) }); dirty } - /// The steps `k0..=k1` of a segment walked in `steps` whose dab can touch a field node, or - /// `None` when none can. Step `k` centres its dab at `a + (b - a)·(k - o)/steps`, with `o` = 1 - /// for the dragging tools and 0 for the stationary ones (see `stroke_segment`). A dab reaches a - /// node only when its centre is within `r` plus a node of the node grid, so the window below - /// pads that by another node and a step: the dabs it leaves out would each change nothing. - fn reachable_steps(&self, a: [f64; 3], b: [f64; 3], steps: usize, r: f64, stationary: bool) -> Option<(usize, usize)> { + /// The distances along a segment from `a` (direction `dir`, length `len`) whose points lie + /// within a dab radius `r` plus two nodes of the node grid, or `None` when none do. Only dabs + /// near that span can touch a node; a segment to a far-off point has trillions of others + /// (#937). + fn reachable_span(&self, a: [f64; 3], dir: [f64; 2], len: f64, r: f64) -> Option<(f64, f64)> { if self.w == 0 || self.h == 0 { return None; } let pad = r + 2.0 * self.cell; let lo = [f64::from(self.bounds.x0) + 0.5 - pad, f64::from(self.bounds.y0) + 0.5 - pad]; let hi = [lo[0] + (self.w - 1) as f64 * self.cell + 2.0 * pad, lo[1] + (self.h - 1) as f64 * self.cell + 2.0 * pad]; - let (n, o) = (steps as f64, if stationary { 0.0 } else { 1.0 }); - let (mut k0, mut k1) = (1.0f64, n); + let (mut u0, mut u1) = (0.0f64, 1.0f64); for ax in 0..2 { - let d = b[ax] - a[ax]; + let d = dir[ax]; if d == 0.0 { if a[ax].is_nan() || a[ax] < lo[ax] || a[ax] > hi[ax] { return None; } continue; } - let (u0, u1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d); - k0 = k0.max((u0.min(u1) * n + o).floor() - 1.0); - k1 = k1.min((u0.max(u1) * n + o).ceil() + 1.0); + let (v0, v1) = ((lo[ax] - a[ax]) / d, (hi[ax] - a[ax]) / d); + u0 = u0.max(v0.min(v1)); + u1 = u1.min(v0.max(v1)); } - // NaN: a segment whose length overflowed. - if k0.is_nan() || k1.is_nan() || k0 > k1 { + if u0.is_nan() || u1.is_nan() || u0 > u1 { return None; } - Some((k0 as usize, k1 as usize)) + Some((u0 * len, u1 * len)) } - /// One dab at `c` with brush motion `delta`. Returns the document rect it affected. - fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect { + /// One dab at `c` with brush motion `delta`, its strength scaled by `weight`. Returns the + /// document rect it affected. + fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64, weight: f64) -> Rect { if s.tool == LiquifyTool::LassoMask { return Rect::EMPTY; // the lasso is polygon-only; never a brush dab } let r = (s.size / 2.0).max(0.5); - let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure; + let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure * weight; let rate = (s.rate / 100.0).clamp(0.0, 1.0); let density = (s.density / 100.0).clamp(0.0, 1.0); if strength <= 0.0 { @@ -921,21 +997,9 @@ mod tests { use super::*; use photocraft_color::{ColorMode, PixelFormat, SampleType}; - /// The segment walk before #937: every step, whether or not its dab can reach the field. + /// The segment walk before #937: every dab, whether or not it can reach the field. fn full_walk(f: &mut LiquifyField, s: &LiquifyStroke, a: [f64; 3], b: [f64; 3]) -> Rect { - let mut dirty = Rect::EMPTY; - let (pa, pb) = (a[2].clamp(0.0, 1.0), b[2].clamp(0.0, 1.0)); - let len = (b[0] - a[0]).hypot(b[1] - a[1]); - let r = (s.size / 2.0).max(0.5); - let steps = (len / (r * 0.2).max(0.5)).ceil().max(1.0) as usize; - for k in 1..=steps { - let (t0, t1) = ((k - 1) as f64 / steps as f64, k as f64 / steps as f64); - let c = [a[0] + (b[0] - a[0]) * t1, a[1] + (b[1] - a[1]) * t1]; - let delta = [(b[0] - a[0]) * (t1 - t0), (b[1] - a[1]) * (t1 - t0)]; - let centre = if s.tool.is_stationary() { c } else { [c[0] - delta[0], c[1] - delta[1]] }; - dirty = dirty.union(&f.dab(s, centre, delta, pa + (pb - pa) * t1)); - } - dirty + f.walk_segment(s, a, b, false) } #[test] @@ -945,7 +1009,7 @@ mod tests { // every result bit-identical to the full walk. use LiquifyTool::*; let bounds = Rect::new(-7, 3, 89, 67); - let segments: [([f64; 3], [f64; 3]); 9] = [ + let segments: [([f64; 3], [f64; 3]); 10] = [ ([10.0, 20.0, 1.0], [70.0, 50.0, 0.5]), // inside ([-900.0, 30.0, 1.0], [40.0, 31.0, 1.0]), // in from far left ([40.0, 30.0, 1.0], [40.0, 4000.0, 0.2]), // out downwards @@ -955,6 +1019,7 @@ mod tests { ([88.5, -50.0, 1.0], [88.5, 120.0, 1.0]), // vertical, along the right edge ([200.0, 200.0, 1.0], [201.0, 900.0, 1.0]), // entirely outside ([0.0, 0.0, 1.0], [0.25, 0.1, 1.0]), // shorter than one step + ([60.0, 40.0, 1.0], [-2000.0, 45.0, 1.0]), // continues the stroke before it, out left ]; for tool in [ForwardWarp, PushLeft, Reconstruct, Smooth, TwirlCw, TwirlCcw, Pucker, Bloat, Freeze, Thaw] { for size in [6.0, 40.0] { @@ -973,6 +1038,81 @@ mod tests { } } + /// The same path sampled by `parts` pointer events per corner-to-corner leg, unevenly. + fn sampled(path: &[[f64; 3]], parts: usize) -> Vec> { + let mut out = vec![path[0].to_vec()]; + for w in path.windows(2) { + let (a, b) = (w[0], w[1]); + // Uneven steps, as a real pointer gives: t grows quadratically. + for k in 1..=parts { + let t = (k * k) as f64 / (parts * parts) as f64; + out.push((0..3).map(|i| a[i] + (b[i] - a[i]) * t).collect()); + } + } + out + } + + /// #2606: dabs restarted at every pointer event, so a fast mouse (few far-apart events) and a + /// slow one (many close ones) warped the same path differently. Spacing now runs on along the + /// whole stroke: any sampling of a path gives the same field. + #[test] + fn the_result_does_not_depend_on_how_finely_the_path_was_sampled() { + use LiquifyTool::*; + let path = [[20.0, 20.0, 1.0], [62.0, 26.0, 0.6], [70.0, 50.0, 0.9], [28.0, 55.0, 1.0], [30.0, 30.0, 1.0]]; + for tool in [ForwardWarp, PushLeft, TwirlCw, Bloat, Reconstruct, Smooth, Freeze] { + for size in [12.0, 40.0] { + let field = |parts: usize| { + let mut warp = LiquifyStroke::new(ForwardWarp, 30.0); + warp.points = vec![vec![30.0, 30.0], vec![50.0, 40.0]]; + let mut s = LiquifyStroke::new(tool, size); + s.points = sampled(&path, parts); + // Some displacement first, so Reconstruct and Smooth have something to change. + LiquifyField::from_strokes(bounds(), 2.0, &[warp, s]) + }; + let coarse = field(1); + for parts in [3, 17, 60] { + let fine = field(parts); + let dd = coarse.d.iter().zip(&fine.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max); + let df = coarse.freeze.iter().zip(&fine.freeze).map(|(p, q)| (p - q).abs()).fold(0.0f32, f32::max); + assert!(dd < 1e-3 && df < 1e-4, "{tool:?} {size}px, {parts} events per leg: field differs by {dd} px, mask by {df}"); + } + } + } + } + + /// A long drag whose events land exactly on dab positions, along a diagonal: rounding must not + /// add or drop the dab at a segment's end, or the deep displacement a long Forward Warp builds + /// up differs by a whole dab between a slow and a fast pointer. + #[test] + fn a_long_diagonal_drag_gives_one_field_at_any_event_spacing() { + let field = |step: f64| { + let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 40.0); + s.points = (0..=(300.0 / step) as usize).map(|k| vec![40.0 + k as f64 * step * 0.6, 20.0 + k as f64 * step * 0.8]).collect(); + LiquifyField::from_strokes(Rect::new(0, 0, 320, 300), 2.0, &[s]) + }; + let (slow, fast) = (field(2.0), field(60.0)); + let dd = slow.d.iter().zip(&fast.d).map(|(p, q)| (p[0] - q[0]).abs().max((p[1] - q[1]).abs())).fold(0.0f32, f32::max); + assert!(slow.max_displacement() > 50.0, "the drag builds up a deep displacement"); + assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px"); + } + + #[test] + fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() { + // A segment that does not start where the last one ended begins a new run of dabs there. + let s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 20.0); + let mut f = LiquifyField::new(bounds(), 2.0); + f.stroke_begin(&s, [10.0, 10.0, 1.0]); + f.stroke_segment(&s, [10.0, 10.0, 1.0], [10.3, 10.0, 1.0]); + let mut g = LiquifyField::new(bounds(), 2.0); + f.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]); + g.stroke_begin(&s, [40.0, 30.0, 1.0]); + g.stroke_segment(&s, [40.0, 30.0, 1.0], [50.0, 30.0, 1.0]); + assert_eq!(f, g); + // Hostile points: nothing panics and the next segment starts afresh. + f.stroke_segment(&s, [f64::NAN, 3.0, 1.0], [4.0, f64::INFINITY, 1.0]); + f.stroke_segment(&s, [4.0, 3.0, f64::NAN], [9.0, 3.0, 1.0]); + } + #[test] fn a_segment_to_a_far_off_point_returns() { // The issue's request: about 3.3e14 steps before the fix. Also a segment whose length @@ -1110,8 +1250,8 @@ mod tests { /// dab's circle (a hard-edged block at Density 0, half strength at the default 50). #[test] fn forward_warp_leaves_the_brush_rim_in_place() { - // A drag of one dab spacing (a fifth of the radius): one dab, centred where it started. - let (r, drag) = (20.0, 4.0); + // A drag of one dab spacing (a twentieth of the radius): one dab, centred where it started. + let (r, drag) = (20.0, 1.0); for density in [0.0, 50.0, 100.0] { let mut s = LiquifyStroke::new(LiquifyTool::ForwardWarp, 2.0 * r); s.density = density; From c0fa2269d2ec22935e69a01e216b0c95380a0ab6 Mon Sep 17 00:00:00 2001 From: coolvitto <160416811+coolvitto@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:57:45 +0900 Subject: [PATCH 058/523] Update Japanese translations (#3892) Improved readability and several translation corrections. --- crates/ui-egui/src/i18n/ja.tsv | 240 ++++++++++++++++----------------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ffca724d17..4cc90fa0b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -65,7 +65,7 @@ Close 閉じる Minimize 最小化 Maximize 最大化 - Restore 元に戻す(縮小) + Restore 元に戻す (縮小) Close All すべて閉じる Close Others ほかを閉じる Save 保存 @@ -76,7 +76,7 @@ Quick Export as PNG PNG としてクイック書き出し Export As… 書き出し形式を指定… Export Preferences… 書き出しの環境設定… - Save for Web (Legacy)… Web 用に保存(従来方式)… + Save for Web (Legacy)… Web 用に保存 (従来方式)… Artboards to Files… アートボードをファイルへ… Artboards to PDF… アートボードを PDF へ… Layers to Files… レイヤーをファイルへ… @@ -218,7 +218,7 @@ 16 Bits/Channel 16 ビット/チャンネル 16 bit 16 ビット 32 Bits/Channel 32 ビット/チャンネル - 32 bit (float) 32 ビット(float) + 32 bit (float) 32 ビット (float) Floating point 浮動小数点 Color Table… カラーテーブル… Bitmap… ビットマップ… @@ -304,13 +304,13 @@ Blending Options… レイヤー効果の描画オプション… Bevel & Emboss… ベベルとエンボス… Use Global Light グローバルライトを使用 - Inner Shadow… シャドウ(内側)… - Inner Glow… 光彩(内側)… + Inner Shadow… シャドウ (内側)… + Inner Glow… 光彩 (内側)… Satin… サテン… Color Overlay… カラーオーバーレイ… Gradient Overlay… グラデーションオーバーレイ… Pattern Overlay… パターンオーバーレイ… - Outer Glow… 光彩(外側)… + Outer Glow… 光彩 (外側)… Drop Shadow… ドロップシャドウ… Copy Layer Style レイヤースタイルをコピー Paste Layer Style レイヤースタイルを貼り付け @@ -531,18 +531,18 @@ Tilt-Shift… チルトシフト… Path Blur… パスぼかし… Spin Blur… 回転ぼかし… - Blur More ぼかし(強) - Box Blur… ぼかし(ボックス)… - Gaussian Blur… ぼかし(ガウス)… - Lens Blur… ぼかし(レンズ)… - Motion Blur… ぼかし(移動)… - Radial Blur… ぼかし(円形)… - Shape Blur… ぼかし(シェイプ)… - Smart Blur… ぼかし(詳細)… - Surface Blur… ぼかし(表面)… + Blur More ぼかし (強) + Box Blur… ぼかし (ボックス)… + Gaussian Blur… ぼかし (ガウス)… + Lens Blur… ぼかし (レンズ)… + Motion Blur… ぼかし (移動)… + Radial Blur… ぼかし (円形)… + Shape Blur… ぼかし (シェイプ)… + Smart Blur… ぼかし (詳細)… + Surface Blur… ぼかし (表面)… Average 平均 Displace… 置き換え… - Pinch… つまみ… + Pinch… つまむ… Polar Coordinates… 極座標… Ripple… 波紋… Shear… シアー… @@ -554,7 +554,7 @@ Add Noise… ノイズを加える… Despeckle ダスト除去 Dust & Scratches… ダスト&スクラッチ… - Median… メディアン… + Median… 明るさの中間値… Reduce Noise… ノイズを軽減… Pixelate ピクセレート Color Halftone… カラーハーフトーン… @@ -563,10 +563,10 @@ Fragment フラグメント Mezzotint… メゾティント… Mosaic… モザイク… - Pointillize… ポインティリスト… + Pointillize… 点描… Render 描画 Clouds 雲模様 - Difference Clouds 雲模様(差の絶対値) + Difference Clouds 雲模様 (差の絶対値) Fibers… 繊維… Flame… 炎… Lens Flare… レンズフレア… @@ -576,7 +576,7 @@ Tree… 木… Sharpen シャープ Sharpen Edges 輪郭のみシャープ - Sharpen More シャープ(強) + Sharpen More シャープ (強) Smart Sharpen… スマートシャープ… Unsharp Mask… アンシャープマスク… High Pass… ハイパス… @@ -686,11 +686,11 @@ Tile 並べて表示 Tile All Horizontally すべてを横に並べる Tile All Vertically すべてを縦に並べる - 2-up Horizontal 2 アップ(横) - 2-up Vertical 2 アップ(縦) - 3-up Horizontal 3 アップ(横) - 3-up Stacked 3 アップ(積み重ね) - 3-up Vertical 3 アップ(縦) + 2-up Horizontal 2 アップ (横) + 2-up Vertical 2 アップ (縦) + 3-up Horizontal 3 アップ (横) + 3-up Stacked 3 アップ (積み重ね) + 3-up Vertical 3 アップ (縦) 4-up 4 アップ 6-up 6 アップ Consolidate All to Tabs すべてをタブに統合 @@ -711,13 +711,13 @@ Lock Workspace ワークスペースをロック Theme テーマ Pro Theme プロテーマ - Pro Medium Gray Theme プロ(中間グレー)テーマ - Pro Light Gray Theme プロ(ライトグレー)テーマ - Pro Light Theme プロ(ライト)テーマ + Pro Medium Gray Theme プロ (中間グレー) テーマ + Pro Light Gray Theme プロ (ライトグレー) テーマ + Pro Light Theme プロ (ライト) テーマ Studio Theme スタジオテーマ - Studio Light Theme スタジオ(ライト)テーマ - Adwaita Light Theme Adwaita(ライト)テーマ - Adwaita Dark Theme Adwaita(ダーク)テーマ + Studio Light Theme スタジオ (ライト) テーマ + Adwaita Light Theme Adwaita (ライト) テーマ + Adwaita Dark Theme Adwaita (ダーク) テーマ Classic Theme クラシックテーマ Solarized dark Solarized ダーク Solarized Dark Theme Solarized ダークテーマ @@ -824,7 +824,7 @@ Answer the unsaved-changes prompt first 先に未保存の変更の確認に答えてください Anti-alias アンチエイリアス Appearance 外観 - Appearance (Layer Style) 外観(レイヤースタイル) + Appearance (Layer Style) 外観 (レイヤースタイル) Apply Layer Mask レイヤーマスクを適用 Apply Per Tip 先端ごとに適用 Apply next selected layer comp 次のレイヤーカンプを適用 @@ -849,10 +849,10 @@ Begin recording 記録を開始 Bend: 曲げ: Bicubic バイキュービック - Bicubic (smooth gradients) バイキュービック(滑らかな階調) + Bicubic (smooth gradients) バイキュービック (滑らかな階調) Automatic 自動 - Bicubic Smoother (enlargement) バイキュービック法 - 滑らか(拡大) - Bicubic Sharper (reduction) バイキュービック法 - シャープ(縮小) + Bicubic Smoother (enlargement) バイキュービック法 - 滑らか (拡大) + Bicubic Sharper (reduction) バイキュービック法 - シャープ (縮小) Bilinear バイリニア Black 黒 Black & White 白黒 @@ -982,7 +982,7 @@ Custom Shapes カスタムシェイプ Cyan シアン Cyan · Red シアン · 赤 - Darken 比較(暗) + Darken 比較 (暗) Darks 暗部 Data Sets データセット Default 既定 @@ -1030,7 +1030,7 @@ Diamond Gradient ダイヤモンド形グラデーション Difference 差の絶対値 Diffusion 拡散 - Discard the embedded profile (don't color manage) 埋め込みプロファイルを破棄(カラーマネジメントしない) + Discard the embedded profile (don't color manage) 埋め込みプロファイルを破棄 (カラーマネジメントしない) Discontiguous 不連続 Discord Discord Dismiss 閉じる @@ -1070,7 +1070,7 @@ Exact 正確 Expansion: 拡張: Export Presets プリセットを書き出し - Export selected measurements (CSV) 選択した測定値を書き出し(CSV) + Export selected measurements (CSV) 選択した測定値を書き出し (CSV) Exposure 露光量 Exposure and Gamma 露光量とガンマ Exposure: 露光量: @@ -1147,7 +1147,7 @@ Ignore All すべて無視 Image Size 画像サイズ Import Brushes… ブラシを読み込み… - Import brushes (.abr) ブラシ(.abr)を読み込み + Import brushes (.abr) ブラシ (.abr) を読み込み Import Presets プリセットを読み込み Imported 読み込み済み Indent First 先頭行の字下げ @@ -1160,12 +1160,12 @@ Inside 内側 Interlaced インターレース Interpolation: 補間方法: - Intersect with selection ({key}) 選択範囲との共通部分({key}) + Intersect with selection ({key}) 選択範囲との共通部分 ({key}) Invert All すべて反転 Invert has no settings. 階調の反転には設定がありません。 Italic イタリック Join us on Discord Discord に参加 - Justify all 均等配置(最終行も) + Justify all 均等配置 (最終行も) Justify last centered 最終行中央揃えの両端揃え Justify last left 最終行左揃えの両端揃え Justify last right 最終行右揃えの両端揃え @@ -1175,18 +1175,18 @@ Kind 種類 Label Size ラベルのサイズ Labels ラベル - Lanczos (sharp) ランチョス(シャープ) + Lanczos (sharp) ランチョス (シャープ) Landscape 横向き Last Document State ドキュメントの最後の状態 Layout レイアウト Leading 行送り - Leading (set to the font size × auto-leading when Auto) 行送り(自動のときはフォントサイズ × 自動行送り) - Leave as is (don't color manage) そのまま(カラーマネジメントしない) + Leading (set to the font size × auto-leading when Auto) 行送り (自動のときはフォントサイズ × 自動行送り) + Leave as is (don't color manage) そのまま (カラーマネジメントしない) Left align text 左揃え Length: 長さ: Level レベル Levels レベル補正 - Lighten 比較(明) + Lighten 比較 (明) Lightness 明度 Lights ライト Limits: 制限: @@ -1240,7 +1240,7 @@ Name 名前 Name: 名前: Nearest Neighbor ニアレストネイバー - Nearest Neighbor (hard edges) ニアレストネイバー(ハードエッジ) + Nearest Neighbor (hard edges) ニアレストネイバー (ハードエッジ) New Size 新しいサイズ New document… 新規ドキュメント… New effect layer 新規効果レイヤー @@ -1390,7 +1390,7 @@ Resolution: 解像度: Restore All すべて復元 Restore Defaults 既定に戻す - Restrictive (Web) 制限(Web) + Restrictive (Web) 制限 (Web) Right align text 右揃え Rigid 剛体 Rotate: 回転: @@ -1456,7 +1456,7 @@ Size: サイズ: Slice Options… スライスオプション… Slices From Guides ガイドからスライス - Slices are locked (View › Lock Slices) スライスはロックされています(表示 › スライスをロック) + Slices are locked (View › Lock Slices) スライスはロックされています (表示 › スライスをロック) Small Caps スモールキャップ Smoothing スムージング Space After 後ろのスペース @@ -1479,7 +1479,7 @@ Style Options… スタイルオプション… Style: Normal スタイル: 通常 Subtract from Sample サンプルから削除 - Subtract from selection ({key}) 選択範囲から削除({key}) + Subtract from selection ({key}) 選択範囲から削除 ({key}) Suggestions: 候補: Swap colours (X) 描画色と背景色を入れ替え(X) Swaps height and width 高さと幅を入れ替えます @@ -1492,7 +1492,7 @@ Texture Each Tip 先端ごとにテクスチャ Threshold しきい値 Threshold Level しきい値レベル - Thumbnail size (smallest: list) サムネールのサイズ(最小: リスト) + Thumbnail size (smallest: list) サムネールのサイズ (最小: リスト) Tilt X 傾き X Tilt Y 傾き Y Tint 色かぶり補正 @@ -1505,7 +1505,7 @@ Top 上 Total: 合計: Tracking トラッキング - Tracking (1/1000 em) トラッキング(1/1000 em) + Tracking (1/1000 em) トラッキング (1/1000 em) Transparency 透明 Transparent 透明 Trilinear トライリニア @@ -1514,11 +1514,11 @@ Underline 下線 Untitled 名称未設定 Untitled-1 名称未設定-1 - Update layer comp (visibility, position and appearance) レイヤーカンプを更新(表示状態・位置・外観) + Update layer comp (visibility, position and appearance) レイヤーカンプを更新 (表示状態・位置・外観) Use Default 既定を使用 Use Legacy 従来方式を使用 Use Measurement Scale 測定スケールを使用 - Use the embedded profile (instead of the working space) 埋め込みプロファイルを使用(作業用スペースの代わり) + Use the embedded profile (instead of the working space) 埋め込みプロファイルを使用 (作業用スペースの代わり) Variables bind a layer's visibility, text or pixels to a named data slot. 変数は、レイヤーの表示状態・テキスト・ピクセルを、名前付きのデータ枠に結び付けます。 Vertical scale 垂直比率 Vibrance 自然な彩度 @@ -1553,8 +1553,8 @@ ↑↓ navigate {key} run esc close ↑↓ 移動 {key} 実行 esc 閉じる Version {version} バージョン {version} New Document 新規ドキュメント - New Document With Mask 新規ドキュメント(マスク付き) - New Layer With Mask 新規レイヤー(マスク付き) + New Document With Mask 新規ドキュメント (マスク付き) + New Layer With Mask 新規レイヤー (マスク付き) Saved 保存済み Save Preset… プリセットを保存… Preset Name プリセット名 @@ -1658,8 +1658,8 @@ Indexed インデックス Initial Direction 初期の方向 Inner Bevel 内側ベベル - Inner Glow 光彩(内側) - Inner Shadow シャドウ(内側) + Inner Glow 光彩 (内側) + Inner Shadow シャドウ (内側) Lab Lab Landscape, 10 x 8 横向き、10 x 8 Landscape, 6 x 4 横向き、6 x 4 @@ -1673,12 +1673,12 @@ Light ライト Line Tool 直線ツール Linear 線形 - Linear Burn 焼き込み(リニア) + Linear Burn 焼き込み (リニア) Linear Height 線形の高さ Link with Layer レイヤーとリンク Lorem Ipsum ダミーテキスト Magentas マゼンタ系 - Magic Eraser Tool 背景消しゴム(自動)ツール + Magic Eraser Tool 背景消しゴム (自動) ツール Magic Wand Tool 自動選択ツール Marquee 選択 Mean: 平均: @@ -1730,10 +1730,10 @@ PostScript (72 points/inch) PostScript (72 ポイント/インチ) Postcard はがき Poster ポスター - Pro (Dark) プロ(ダーク) - Pro (Light) プロ(ライト) - Pro (Light Gray) プロ(ライトグレー) - Pro (Medium Gray) プロ(中間グレー) + Pro (Dark) プロ (ダーク) + Pro (Light) プロ (ライト) + Pro (Light Gray) プロ (ライトグレー) + Pro (Medium Gray) プロ (中間グレー) Protect Texture テクスチャを保護 Purple 紫 Quick Mask クイックマスク @@ -1774,8 +1774,8 @@ Square, 12 x 12 正方形、12 x 12 Square, 5 x 5 正方形、5 x 5 Std Dev: 標準偏差: - Studio (Dark) スタジオ(ダーク) - Studio (Light) スタジオ(ライト) + Studio (Dark) スタジオ (ダーク) + Studio (Light) スタジオ (ライト) Stylus Wheel スタイラスホイール Subtract 減算 Tabloid タブロイド @@ -1836,9 +1836,9 @@ Autosave 自動保存 Autosave minutes 自動保存の間隔(分) Beep when done 完了時にビープ音を鳴らす - Bicubic automatic バイキュービック(自動) - Bicubic sharper バイキュービック(シャープ) - Bicubic smoother バイキュービック(滑らか) + Bicubic automatic バイキュービック (自動) + Bicubic sharper バイキュービック (シャープ) + Bicubic smoother バイキュービック (滑らか) Bit depth ビット深度 Brush preview color ブラシプレビューの色 Cache levels キャッシュレベル @@ -1855,8 +1855,8 @@ Control port 制御ポート Convert to srgb sRGB に変換 Cursors カーソル - Custom dark カスタム(ダーク) - Custom light カスタム(ライト) + Custom dark カスタム (ダーク) + Custom light カスタム (ライト) Dark ダーク Dark gray ダークグレー Dashed lines 破線 @@ -1933,9 +1933,9 @@ Preserve details ディテールを保持 Print resolution 印刷解像度 Pro プロ - Pro light プロ(ライト) - Pro light gray プロ(ライトグレー) - Pro medium プロ(中間) + Pro light プロ (ライト) + Pro light gray プロ (ライトグレー) + Pro medium プロ (中間) Pro photo ProPhoto Quick export format クイック書き出しの形式 Quick export location クイック書き出しの保存先 @@ -1974,9 +1974,9 @@ Srgb sRGB Standard 標準 Studio スタジオ - Studio light スタジオ(ライト) + Studio light スタジオ (ライト) Adwaita Adwaita - Adwaita dark Adwaita(ダーク) + Adwaita dark Adwaita (ダーク) Subdivisions 分割数 System システム Text engine テキストエンジン @@ -2003,8 +2003,8 @@ Pass Through 通過 Dissolve ディザ合成 Darker Color カラー比較(暗) - Linear Dodge (Add) 覆い焼き(リニア)- 加算 - Lighter Color カラー比較(明) + Linear Dodge (Add) 覆い焼き (リニア)- 加算 + Lighter Color カラー比較 (明) Soft Light ソフトライト Hard Light ハードライト Vivid Light ビビッドライト @@ -2025,7 +2025,7 @@ Collapse 折りたたむ Collapse All Groups すべてのグループを折りたたむ Collapse Panel Group パネルグループを折りたたむ - Collapse group ({key}-click: all groups) グループを折りたたむ({key}+クリック: すべてのグループ) + Collapse group ({key}-click: all groups) グループを折りたたむ ({key}+クリック: すべてのグループ) Create new brush from the current settings 現在の設定から新規ブラシを作成 Create new brush from these settings この設定から新規ブラシを作成 Delete brush ブラシを削除 @@ -2033,9 +2033,9 @@ Drag the arrow to set the angle, the dots to set the roundness 矢印をドラッグして角度、点をドラッグして真円率を設定 Expand 展開 Expand Panel Group パネルグループを展開 - Expand group ({key}-click: all groups) グループを展開({key}+クリック: すべてのグループ) + Expand group ({key}-click: all groups) グループを展開 ({key}+クリック: すべてのグループ) Fade steps フェードのステップ数 - Hide the layer's effects ({key}-click: all layers) レイヤーの効果を隠す({key}+クリック: すべてのレイヤー) + Hide the layer's effects ({key}-click: all layers) レイヤーの効果を隠す ({key}+クリック: すべてのレイヤー) Import Brushes… (.abr) ブラシを読み込み…(.abr) Move Group Down グループを下へ移動 Move Group Up グループを上へ移動 @@ -2043,7 +2043,7 @@ Restore original size 元のサイズに戻す Roundness: 真円率: Search Brushes ブラシを検索 - Show the layer's effects ({key}-click: all layers) レイヤーの効果を表示({key}+クリック: すべてのレイヤー) + Show the layer's effects ({key}-click: all layers) レイヤーの効果を表示 ({key}+クリック: すべてのレイヤー) Brush picker ブラシピッカー Erase 消去 Right click with painting tools 描画ツールで右クリック @@ -2060,8 +2060,8 @@ Click to sample the foreground colour · {key}-click for background クリックで描画色を取得 · {key}+クリックで背景色を取得 {key}-click to set the source {key}+クリックでソースを設定 {key} to subtract {key} で削除 - Clone source {n} ({key}-click with Clone Stamp to set) コピーソース {n}({key}+クリックでコピースタンプのソースを設定) - Source: not set ({key}-click with Clone Stamp) ソース: 未設定({key}+クリックでコピースタンプ) + Clone source {n} ({key}-click with Clone Stamp to set) コピーソース {n} ({key}+クリックでコピースタンプのソースを設定) + Source: not set ({key}-click with Clone Stamp) ソース: 未設定 ({key}+クリックでコピースタンプ) Pixel Layer ピクセルレイヤー Type Layer 文字レイヤー Shape Layer シェイプレイヤー @@ -2128,7 +2128,7 @@ Create new path 新規パスを作成 Delete path パスを削除 Delete vector mask ベクトルマスクを削除 - CPU (no GPU acceleration) CPU(GPU アクセラレーションなし) + CPU (no GPU acceleration) CPU (GPU アクセラレーションなし) DirectX 12 DirectX 12 GPU backend GPU バックエンド Linux display server Linux ディスプレイサーバー @@ -2163,17 +2163,17 @@ Automatic (recommended) 自動(推奨) CPU / Compatibility CPU / 互換性 Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. 自動モードは利用可能な場合にGPUを使用し、エラー時はCPU描画に切り替えます。 - Switched to CPU rendering CPU描画に切り替えました - GPU acceleration could not continue. Your documents are unchanged. GPUアクセラレーションを継続できませんでした。ドキュメントは変更されていません。 - PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraftはCPUで画像を合成しています。ウィンドウは引き続きグラフィックスアダプターを使用する場合があります。 - Retry GPU requires a restart. Save your work first. GPUの再試行には再起動が必要です。先に作業を保存してください。 - Keep Using CPU CPUを使い続ける - Retry GPU GPUを再試行 + Switched to CPU rendering CPU 描画に切り替えました + GPU acceleration could not continue. Your documents are unchanged. GPU アクセラレーションを継続できませんでした。ドキュメントは変更されていません。 + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft は CPU で画像を合成しています。ウィンドウは引き続きグラフィックスアダプターを使用する場合があります。 + Retry GPU requires a restart. Save your work first. GPU の再試行には再起動が必要です。先に作業を保存してください。 + Keep Using CPU CPU を使い続ける + Retry GPU GPU を再試行 Advanced 詳細設定 - GPU Backend GPUバックエンド + GPU Backend GPU バックエンド Details 詳細 Rendering mode レンダリングモード - Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) フリーズなげなわ:ドラッグで範囲をフリーズ、Alt+ドラッグで解凍 (L) + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) 投げ縄でマスク操作: ドラッグで範囲をマスク、Alt+ドラッグで消去 (L) Shadow Clipping Warning (U) シャドウのクリッピング警告 (U) Highlight Clipping Warning (O) ハイライトのクリッピング警告 (O) Show Lab Color Readouts Lab カラー値を表示 @@ -2282,11 +2282,11 @@ cameraRaw Color カラー Apply At Canvas Bounds カンバスの境界に効果を適用 # Shared screen eyedropper and text Color Picker (#1128). - Color Picker (Text Color) カラーピッカー(文字色) + Color Picker (Text Color) カラーピッカー (文字色) Color Picker ({name}) カラーピッカー({name}) - Color Picker (Fill Color) カラーピッカー(塗りつぶしカラー) + Color Picker (Fill Color) カラーピッカー (塗りつぶしカラー) Fill color 塗りつぶしカラー - Color Picker (Stroke Color) カラーピッカー(線のカラー) + Color Picker (Stroke Color) カラーピッカー (線のカラー) Stroke color 線のカラー Pick screen color 画面の色を選択 Sample a pixel anywhere on the screen 画面上の任意のピクセルから色を取得 @@ -2305,7 +2305,7 @@ cameraRaw Color カラー Color… カラー… 50% Gray 50% グレー Color Adaptation カラー適応 - {size} (was {old}) {size}(変更前 {old}) + {size} (was {old}) {size} (変更前 {old}) Cone 円錐 Cone (Inverted) 円錐 (反転) Domed ドーム @@ -2348,11 +2348,11 @@ cameraRaw Color カラー Fuchsia フクシア Violet 紫 Reset 初期化 - Large document: redrawing on the CPU 大きなドキュメント:CPUで再描画しています - This redraw took {seconds} s on the CPU. この再描画はCPUで{seconds}秒かかりました。 + Large document: redrawing on the CPU 大きなドキュメント: CPU で再描画しています + This redraw took {seconds} s on the CPU. この再描画は CPU で {seconds} 秒かかりました。 Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. レイヤーの描画モード、不透明度、表示/非表示など、ドキュメント全体に関わる編集では全体が再描画されます。 - Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーがGPUメモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 - The GPU compositor wasn't used: {reason} GPUコンポジターは使用されませんでした:{reason} + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. レイヤーが GPU メモリの予算に収まりません。環境設定 › パフォーマンスのメモリ使用量を増やすと、このコンピューターのメモリの4分の1まで予算が増えます。 + The GPU compositor wasn't used: {reason} GPU コンポジターは使用されませんでした: {reason} fontWeight Thin 極細 fontWeight ExtraLight 特細 fontWeight Light 細字 @@ -2387,13 +2387,13 @@ fontWeight Italic 斜体 51 by 51 Average 51 × 51 平均 101 by 101 Average 101 × 101 平均 Sample Size: サンプルサイズ: - All Layers No Adjustments すべてのレイヤー(色調補正なし) - Current & Below No Adjustments 現在のレイヤーとその下(色調補正なし) + All Layers No Adjustments すべてのレイヤー (色調補正なし) + Current & Below No Adjustments 現在のレイヤーとその下 (色調補正なし) Show Sampling Ring サンプリングリングを表示 # Color Picker warnings - Out of gamut for printing: click for the nearest printable colour 印刷の色域外:クリックすると最も近い印刷可能な色になります - Not a web colour: click for the nearest web colour Web カラーではありません:クリックすると最も近い Web カラーになります + Out of gamut for printing: click for the nearest printable colour 印刷の色域外: クリックすると最も近い印刷可能な色になります + Not a web colour: click for the nearest web colour Web カラーではありません: クリックすると最も近い Web カラーになります # Generated labels: filter/gallery parameter names, option choices and gallery names (camelCase keys shown as words) (added 2026-10, checked against the code) Accented Edges 輪郭の強調 @@ -2777,7 +2777,7 @@ fontWeight Italic 斜体 Auto-Align Layers レイヤーを自動整列 Auto-Blend Layers レイヤーを自動合成 Bold Italic 太字斜体 - Box Blur ぼかし(ボックス) + Box Blur ぼかし (ボックス) Bulge 膨らみ Burn 焼き込み Camera Raw Filter Camera Raw フィルター @@ -2824,7 +2824,7 @@ fontWeight Italic 斜体 Fish 魚形 Flag 旗 Focus Area 焦点領域 - Gaussian Blur ぼかし(ガウス) + Gaussian Blur ぼかし (ガウス) Global Light グローバル光源 Gray Value (Maximum) グレー値 (最大) Gray Value (Mean) グレー値 (平均) @@ -2844,7 +2844,7 @@ fontWeight Italic 斜体 Label ラベル Layer Comp レイヤーカンプ Layer From Background 背景からレイヤーへ - Lens Blur ぼかし(レンズ) + Lens Blur ぼかし (レンズ) Lens Correction レンズ補正 Lens Flare レンズフレア Lighting Effects 照明効果 @@ -2872,11 +2872,11 @@ fontWeight Italic 斜体 Pencil Tool 鉛筆ツール Perimeter 周囲長 Photomerge 写真を貼り合わせ - Pinch つまみ + Pinch つまむ Place Embedded 埋め込みで配置 Place Linked リンクで配置 Place Scale Marker スケールマーカーを配置 - Pointillize ポインティリスト + Pointillize 点描 Polar Coordinates 極座標 Preserve Transparency 透明部分を保持 Preset Manager プリセットマネージャー @@ -2884,7 +2884,7 @@ fontWeight Italic 斜体 Punctuation 句読点 Quick Selection クイック選択 RGB RGB - Radial Blur ぼかし(円形) + Radial Blur ぼかし (円形) Reading 読み取り Recovery リカバリー Rectangle 長方形 @@ -2897,7 +2897,7 @@ fontWeight Italic 斜体 Ripple 波紋 Rise 上昇 Ruler 定規 - Save for Web (Legacy) Web 用に保存(従来方式) + Save for Web (Legacy) Web 用に保存 (従来方式) Scale Effects 効果を拡大・縮小 Scale Factor 拡大・縮小の倍率 Scale Units スケール単位 @@ -2906,19 +2906,19 @@ fontWeight Italic 斜体 Sepia セピア Set Measurement Scale 測定スケールを設定 Shadows/Highlights シャドウ・ハイライト - Shape Blur ぼかし(シェイプ) + Shape Blur ぼかし (シェイプ) Shear シアー Shell Lower シェル (下) Shell Upper シェル (上) Slice Options スライスオプション - Smart Blur ぼかし(詳細) + Smart Blur ぼかし (詳細) Smart Sharpen スマートシャープ Software ソフトウェア Spherize 球面 Spin Blur 回転ぼかし Squeeze 絞り込み Stroke Path パスのストローク - Surface Blur ぼかし(表面) + Surface Blur ぼかし (表面) Symbols 記号 Tiles タイル Tilt-Shift チルトシフト @@ -3458,9 +3458,9 @@ fontWeight Italic 斜体 Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt+クリックはデスクトップに取られました(Alt+ドラッグでウィンドウを移動する設定です)。デスクトップのウィンドウ移動キーを Super に変更するか、ウィンドウ › 修飾キーで Alt を固定してください。 # Shared Layer Style color picker - Color Picker (Layer Style Color) カラーピッカー(レイヤースタイルの色) + Color Picker (Layer Style Color) カラーピッカー (レイヤースタイルの色) Auto Hide Notifications 通知を自動的に隠す - Notification Duration (seconds) 通知の表示時間(秒) + Notification Duration (seconds) 通知の表示時間 (秒) Appearance Mode 外観モード Dark Theme ダークテーマ Light Theme ライトテーマ @@ -3469,7 +3469,7 @@ fontWeight Italic 斜体 Next Appearance Mode 次の外観モード Sync with system システムと同期 Active 適用中 - Color Picker (Solid Color) カラーピッカー(べた塗り) + Color Picker (Solid Color) カラーピッカー (べた塗り) Straighten the image by drawing a line on it 画像上に線を描いて角度を補正 Set additional Crop options 切り抜きの追加オプションを設定 Show Cropped Area 切り抜かれた領域を表示 @@ -3477,7 +3477,7 @@ fontWeight Italic 斜体 Match Canvas カンバスに合わせる Auto Adjust Opacity 不透明度を自動調整 Crop Shield color 切り抜きシールドのカラー - Color Picker (Crop Shield Color) カラーピッカー(切り抜きシールドのカラー) + Color Picker (Crop Shield Color) カラーピッカー (切り抜きシールドのカラー) From Center 中心から Link radii 半径をリンク Star Ratio: 星の比率: @@ -3489,7 +3489,7 @@ fontWeight Italic 斜体 Click to add a point, right-click to remove クリックでポイントを追加、右クリックで削除 Some brush presets could not be loaded 一部のブラシプリセットを読み込めませんでした Retry saving presets プリセットの保存を再試行 - Brush preset changes are not saved: {error} ブラシプリセットの変更は保存されていません:{error} + Brush preset changes are not saved: {error} ブラシプリセットの変更は保存されていません: {error} Keep this tab open to retain your brushes. ブラシを保持するには、このタブを開いたままにしてください。 Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. ブラウザーのストレージを利用できないため、ブラシはこのセッション中のみ有効です。保持するには、このタブを開いたままにしてください。 Saving brush presets… Keep this tab open until saving finishes. ブラシプリセットを保存中… 保存が完了するまでこのタブを開いたままにしてください。 @@ -3524,7 +3524,7 @@ fontWeight Italic 斜体 No subject found — try a rectangle around the object 被写体が見つかりません — オブジェクトを囲む長方形を試してください Click the first point or press {key} to close · {del} removes a point · Esc cancels 最初の点をクリックするか {key} を押して閉じる · {del} で点を削除 · Esc でキャンセル Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close クリックしてエッジに沿って動かす · クリックで固定ポイント · {alt}-クリックで直線セグメント · ダブルクリックで閉じる - Follows only edges this close to the pointer ([ and ] change it) ポインターからこの距離内のエッジだけをたどる([ と ] で変更) + Follows only edges this close to the pointer ([ and ] change it) ポインターからこの距離内のエッジだけをたどる ([ と ] で変更) Higher values follow only edges that contrast sharply with their surroundings 値を大きくすると、周囲とのコントラストが強いエッジだけをたどる How often fastening points are placed by themselves 固定ポイントが自動で置かれる頻度 Appearance Settings… 外観設定… From 19e51cf0adeb6bf88bdf56ca6922ad61fe0f3ca6 Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:04:23 -0500 Subject: [PATCH 059/523] refactor(ui): unify plain text inputs on one text_field widget (#3789) (#3896) Plain text fields (Preferences search and hex, the Color Picker hex, and the brush search fields) each drew egui's default TextEdit frame, so they differed in padding and border and showed egui's white focus ring (its selection.stroke). Add widgets::text_field, which draws the same box as a value_field (field fill, field_border, radius_sm) with frame NONE, a consistent inner padding, no focus outline, and a scoped, non-blue text-selection colour (grey in the monochrome Pro themes, the theme accent elsewhere). Migrate those fields to it; numeric fields already use value_field. Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib; cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm --- crates/ui-egui/src/brush_picker.rs | 7 +-- crates/ui-egui/src/brushes_tab.rs | 2 +- crates/ui-egui/src/color_picker_ui.rs | 2 +- crates/ui-egui/src/prefs_ui.rs | 4 +- crates/ui-egui/src/theme.rs | 19 ++++++++ crates/ui-egui/src/widgets.rs | 64 +++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/brush_picker.rs b/crates/ui-egui/src/brush_picker.rs index 8a7ec9d475..3cade4981c 100644 --- a/crates/ui-egui/src/brush_picker.rs +++ b/crates/ui-egui/src/brush_picker.rs @@ -262,12 +262,7 @@ pub fn body( ui.horizontal(|ui| { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); - ui.add( - egui::TextEdit::singleline(&mut st.filter) - .hint_text(tl!("Search Brushes")) - .desired_width((size.x - 118.0).max(60.0)) - .id_salt("brush-picker-search"), - ); + widgets::text_field(ui, ui.id().with("brush-picker-search"), &mut st.filter, tl!("Search Brushes"), (size.x - 118.0).max(60.0), false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { gear_menu(ui, current, presets, st, &mut picks); let new_tip = "Create new brush preset from the current settings"; diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index 59b3ba6df5..b6d96f6bd2 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -892,7 +892,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint); ui.add_space(20.0); - ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text(tl!("Search Brushes")).desired_width(WIDTH - 120.0)); + widgets::text_field(ui, ui.id().with("brush-search"), &mut app.ui.brushes_panel.filter, tl!("Search Brushes"), WIDTH - 120.0, false); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let view = &mut app.ui.brushes_panel.view; let grid_tip = "Grid view"; diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index dc57de68b4..cfa7183a59 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -638,7 +638,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } let typing = if ui.memory(|m| m.has_focus(id)) { ui.data(|d| d.get_temp::(id)) } else { None }; let mut h = typing.unwrap_or_else(|| hex(rgb).trim_start_matches('#').to_string()); - let r = ui.add(egui::TextEdit::singleline(&mut h).id(id).char_limit(6).desired_width(64.0).font(crate::theme::mono(12.0))); + let r = crate::widgets::text_field_ex(ui, id, &mut h, "", 64.0, true, Some(6)); if r.gained_focus() { crate::field_tab::select_all(ui.ctx(), id, &h); } diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 5fc1065a4c..5b15e7bc9c 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -1082,7 +1082,7 @@ fn hex_field(ui: &mut egui::Ui, key: &str, rgb: &mut [u8; 3]) { let id = egui::Id::new(("pref-hex", key)); let shown = format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]); let mut text = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut text).font(crate::theme::mono(11.5)).desired_width(72.0).char_limit(7)); + let r = crate::widgets::text_field_ex(ui, id, &mut text, "", 72.0, true, Some(7)); if r.changed() && let Some(c) = prefs::parse_hex(&text) { @@ -1130,7 +1130,7 @@ fn prefs_body(ui: &mut egui::Ui, f: &mut Map, system: Option Color32 { + if self.pro { Color32::from_gray(if self.dark() { 96 } else { 201 }) } else { self.accent_soft } + } + pub fn dark(&self) -> bool { matches!( self.kind, @@ -1066,6 +1075,16 @@ mod tests { assert_eq!(ThemeKind::from_name("lightest"), Some(ThemeKind::ProLight)); } + #[test] + fn field_selection_is_grey_only_for_the_monochrome_pro_themes() { + // #3789: a text field's selection is a subtle grey in the monochrome Pro themes, not a + // second blue accent; coloured themes keep their accent. + assert_eq!(Tokens::for_kind(ThemeKind::Pro).field_selection(), Color32::from_gray(96)); + assert_eq!(Tokens::for_kind(ThemeKind::ProLight).field_selection(), Color32::from_gray(201)); + assert_eq!(Tokens::for_kind(ThemeKind::Studio).field_selection(), Tokens::for_kind(ThemeKind::Studio).accent_soft); + assert_eq!(Tokens::for_kind(ThemeKind::Classic).field_selection(), Tokens::for_kind(ThemeKind::Classic).accent_soft); + } + #[test] fn theme_names_parse() { assert_eq!(ThemeKind::from_name("Classic"), Some(ThemeKind::Classic)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 876ed82e7f..8ec6f94a50 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -661,6 +661,45 @@ pub fn popup_value_field(ui: &mut Ui, name: &str, value: &mut f32, range: std::o out } +/// Height of a [`text_field`]'s box, matching a [`value_field`]. +pub const TEXT_FIELD_H: f32 = 24.0; + +/// Inner padding of a [`text_field`]'s box: 6 pt either side, 2 pt above and below. +const TEXT_FIELD_PAD: Vec2 = vec2(6.0, 2.0); + +/// A single-line text field in PhotoCraft's shared field style (#3789): the same box as a +/// [`value_field`] -- the theme's field fill and `field_border`, rounded by `radius_sm` -- with a +/// consistent inner padding and no focus outline. egui's default white focus ring (its +/// `selection.stroke`) is what made the Preferences search field look different from the Color +/// Picker's hex field, so every plain, non-numeric input (search, hex, name, filter) is drawn +/// through here to look and behave the same. `id` carries the field's text and focus across +/// frames; `hint` shows while it is empty; `mono` picks the monospaced face for hex and other +/// code-like values. +pub fn text_field(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool) -> Response { + text_field_ex(ui, id, text, hint, width, mono, None) +} + +/// [`text_field`] limited to `char_limit` characters (a hex field's six digits, say). +pub fn text_field_ex(ui: &mut Ui, id: egui::Id, text: &mut String, hint: &str, width: f32, mono: bool, char_limit: Option) -> Response { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(width.max(16.0), TEXT_FIELD_H), Sense::hover()); + surface(ui, rect, t.field, false); + if !t.bevel { + ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + let font = if mono { theme::mono(12.0) } else { egui::FontId::proportional(12.5) }; + let mut edit = egui::TextEdit::singleline(text).id(id).hint_text(hint).frame(egui::Frame::NONE).font(font); + if let Some(limit) = char_limit { + edit = edit.char_limit(limit); + } + // A child Ui keeps the field's selection colour local: `Visuals::selection` also fills selected + // list rows and slider trails, which must keep the theme's. + let layout = egui::Layout::centered_and_justified(egui::Direction::TopDown); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(TEXT_FIELD_PAD)).layout(layout)); + child.style_mut().visuals.selection.bg_fill = t.field_selection(); + child.add(edit) +} + fn first_field_id() -> egui::Id { egui::Id::new("pc-focus-first-field") } @@ -1653,6 +1692,31 @@ mod tests { } use egui_kittest::{Harness, kittest::Queryable}; + /// #3789: a plain text field is one boxed, outline-free input whose text-selection colour stays + /// local, so it isn't recoloured for the rest of the frame (selected rows, slider trails). + #[test] + fn text_field_is_a_boxed_input_whose_selection_colour_stays_local() { + use egui::accesskit::Role; + let mut h = Harness::new_ui_state( + |ui, s: &mut (String, egui::Color32, egui::Color32)| { + s.1 = ui.visuals().selection.bg_fill; + super::text_field(ui, egui::Id::new("tf"), &mut s.0, "hint", 140.0, false); + s.2 = ui.visuals().selection.bg_fill; + }, + (String::new(), egui::Color32::WHITE, egui::Color32::BLACK), + ); + crate::PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run(); + assert_eq!(h.state().1, h.state().2, "the field's selection colour does not leak into its parent"); + h.get_by_role(Role::TextInput).click(); + h.run_steps(1); + for ch in "abc".chars() { + h.event(egui::Event::Text(ch.to_string())); + h.run_steps(1); + } + assert_eq!(h.state().0, "abc", "typing reaches the bound string"); + } + /// Sets up a test with one value field. Its state is its value and how many times it changed. fn field(value: f32, range: std::ops::RangeInclusive) -> Harness<'static, (f32, u32)> { let mut h = Harness::new_ui_state( From 83f4452bf2dc1a9efc9381b58e95f012241c6b72 Mon Sep 17 00:00:00 2001 From: Fabian Ghenof <154279715+fabianghe@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:04:35 +0300 Subject: [PATCH 060/523] =?UTF-8?q?Layer=20=E2=80=BA=20Export=20As=20expor?= =?UTF-8?q?ts=20every=20selected=20layer=20or=20artboard=20(#3629)=20(#390?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With several layers selected, Export As was greyed: its dialog exported only the active layer. Someone with 20+ artboards (ads, Steam store capsules) had to export them one at a time. Export As now opens the same dialog for the selection ("Export As: N layers", the preview showing the first) and, on Export, asks for one destination and writes one file per layer with the dialog's format, quality, transparency and scale, each at its own size. Files are named as Quick Export as PNG names a selection's: the chosen path, then numbered siblings named after each layer, now in the chosen format. The layers are captured when Export is pressed. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/export_dialog.rs | 61 ++++++++++++++++++++++++- crates/ui-egui/src/file_dialog/tests.rs | 39 ++++++++++++++++ crates/ui-egui/src/layer_menu_ui.rs | 16 +++---- crates/ui-egui/src/menus.rs | 5 ++ 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index b16fbc31ae..cd520d5c30 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -44,6 +44,23 @@ pub fn open_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Re Ok(id) } +/// Layer › Export As… with several layers (artboards, say) selected: the same dialog, then one +/// file per layer with its settings (#3629). The preview shows the first layer. +pub fn open_layers(app: &mut PhotocraftApp, layers: &[photocraft_doc::LayerId]) -> Result { + let (first, n) = match layers { + [] => return Err("no selected layers".into()), + [one] => return open_layer(app, *one), + [first, ..] => (*first, layers.len()), + }; + let id = open_layer(app, first)?; + if let Some(d) = app.ui.dialogs.iter_mut().find(|d| d.id == id) { + d.fields.insert("__layers".into(), json!(layers.iter().map(|l| l.0).collect::>())); + let template = "Export As: {n} layers"; + d.fields.insert("__label".into(), json!(crate::i18n::fmt(tl!(template), &[("n", &n.to_string())]))); + } + Ok(id) +} + /// Layer › Quick Export as PNG. pub fn quick_export_layer_png(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId) -> Result { let mut f = Map::new(); @@ -96,10 +113,13 @@ fn export_name(name: &str) -> String { if name.is_empty() { "layer".into() } else { name.into() } } +/// The files a multi-layer export writes: the chosen path, then numbered siblings named after +/// each layer, in the chosen path's format. fn batch_paths(path: &str, docs: &[Document]) -> Vec { let first = std::path::Path::new(path); let parent = first.parent().unwrap_or_else(|| std::path::Path::new("")); let stem = first.file_stem().and_then(|s| s.to_str()).unwrap_or("layers"); + let ext = first.extension().and_then(|e| e.to_str()).unwrap_or("png"); docs.iter() .enumerate() .map(|(i, doc)| { @@ -107,7 +127,7 @@ fn batch_paths(path: &str, docs: &[Document]) -> Vec { return path.to_string(); } let name = export_name(&doc.name); - let candidate = parent.join(format!("{stem}-{}-{name}.png", i + 1)); + let candidate = parent.join(format!("{stem}-{}-{name}.{ext}", i + 1)); // The first file's overwrite is handled by the Save dialog; never overwrite sibling // files silently on desktop. Downloads on the web are named by the browser. #[cfg(not(target_arch = "wasm32"))] @@ -115,7 +135,7 @@ fn batch_paths(path: &str, docs: &[Document]) -> Vec { let mut candidate = candidate; let mut suffix = 1u64; while candidate.exists() { - candidate = parent.join(format!("{stem}-{}-{name}-{suffix}.png", i + 1)); + candidate = parent.join(format!("{stem}-{}-{name}-{suffix}.{ext}", i + 1)); suffix = suffix.saturating_add(1); } candidate @@ -335,6 +355,10 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { + if let Some(layers) = f.get("__layers").and_then(Value::as_array) { + let ids: Vec<_> = layers.iter().filter_map(Value::as_u64).map(photocraft_doc::LayerId).collect(); + return confirm_layers(app, f, &ids); + } let doc = source_document(app, f)?; let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a).to_string(); let ext = s_fmt(f); @@ -356,6 +380,39 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, ids: &[photocraft_doc::LayerId]) -> Result { + let st = app.session.active().ok_or("no document")?; + let docs: Vec = + ids.iter().map(|id| photocraft_engine::layer_menu_cmds::layer_document(&st.doc, *id).map_err(|e| e.to_string())).collect::>()?; + if docs.is_empty() { + return Err("no selected layers".into()); + } + let stem = st.doc.name.rsplit_once('.').map_or(st.doc.name.as_str(), |(a, _)| a); + let suggested = format!("{}.{}", export_name(stem), s_fmt(f)); + let (f, settings) = (f.clone(), settings(f)); + app.pick_save(&suggested, move |app, path| { + let mut exported = Vec::new(); + for (doc, path) in docs.iter().zip(batch_paths(&path, &docs)) { + let out = export_document(doc, &f, None)?; + let export = app.services.export.as_ref().ok_or("no exporter configured")?; + let (bytes, warnings) = export(&out, &path, &settings)?; + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let template = "Exported {path}"; + let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(&path))]); + crate::notices::io_warnings(app, &what, &warnings); + exported.push(json!({"path": path, "bytes": bytes.len(), "warnings": warnings})); + } + let template = "Exported {n} files"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("n", &exported.len().to_string())]); + app.ui.status_error = false; + Ok(json!({"files": exported})) + }) +} + /// File › Export › Quick Export as PNG: the format, quality, metadata, colour space and location /// from File › Export › Export Preferences (engine `file.export.quickExport`). On the web (no /// file system) it falls back to a PNG download through the export service. diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index e985a486c7..3081de03f7 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -685,3 +685,42 @@ fn an_unavailable_dialog_is_reported_and_a_cancelled_one_is_not() { assert_eq!(app.ui.status, "No file dialog can open"); assert!(!app.file_dialog_open(), "the pending dialog is over"); } + +/// #3629: Layer › Export As with several artboards selected exports one file per board, at each +/// board's size and with the dialog's settings, after one destination prompt. +#[test] +fn export_as_with_several_artboards_selected_writes_one_file_each() { + let (mut app, open, written) = app(); + let a = app.run("layer.new.artboard", json!({"rect": [0, 0, 40, 30], "name": "Header"})).unwrap()["layer"].as_u64().unwrap(); + let b = app.run("layer.new.artboard", json!({"rect": [60, 0, 20, 50], "name": "Capsule"})).unwrap()["layer"].as_u64().unwrap(); + app.run("layer.select", json!({"layer": a})).unwrap(); + app.run("layer.select", json!({"layer": b, "mode": "add"})).unwrap(); + assert_eq!(app.session.active().unwrap().selected_layers().len(), 2); + let sizes = Rc::new(RefCell::new(Vec::new())); + let capture = sizes.clone(); + app.services.export = Some(Box::new(move |doc: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| { + capture.borrow_mut().push((doc.name.clone(), doc.size.width, doc.size.height)); + Ok((b"out".to_vec(), Vec::new())) + })); + assert!(!crate::layer_menu_ui::single_layer_only("layer.exportAs"), "the menu offers it for a selection"); + let ctx = egui::Context::default(); + let r = menus::invoke(&mut app, &ctx, "layer.exportAs", Value::Null).unwrap(); + let id = r["dialog"].as_u64().unwrap(); + let mut fields = app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.clone(); + assert_eq!(fields["__label"], "Export As: 2 layers"); + // The dialog's settings apply to every file: JPG at 50 %. + fields.insert("format".into(), json!("jpg")); + fields.insert("scale".into(), json!(50)); + crate::export_dialog::confirm(&mut app, &fields).unwrap(); + app.poll_file_dialog(&ctx, None); + assert_eq!(open.borrow().len(), 1, "one destination for every board"); + answer(&open, Some(FileDialogAnswer::SaveTo("/out/boards.jpg".into()))); + app.poll_file_dialog(&ctx, None); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(written.borrow().len(), 2); + assert_eq!(written.borrow()[0], "/out/boards.jpg"); + assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow()); + let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); + got.sort_unstable(); + assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); +} diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index 10629f2542..fa70e436be 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -40,14 +40,13 @@ pub fn mask_button_command(layer: Option<&Layer>, has_selection: bool, alt: bool } /// Commands that act on the active layer only and have no sensible meaning for a selection: Blending -/// Options (one dialog), Copy Layer Style (copy from which layer?), Export As, and the per-layer -/// mask items. With several layers selected they would hit whichever layer is active, not the one -/// that was right-clicked, so the menu greys them. Adding a mask, Paste/Clear Layer Style and -/// Rasterize run on every selected layer instead. +/// Options (one dialog), Copy Layer Style (copy from which layer?) and the per-layer mask items. +/// With several layers selected they would hit whichever layer is active, not the one that was +/// right-clicked, so the menu greys them. Adding a mask, Paste/Clear Layer Style, Rasterize and +/// Export As (one file per layer, #3629) run on every selected layer instead. pub fn single_layer_only(id: &str) -> bool { let adds_mask = matches!(id, "layer.layerMask.revealAll" | "layer.layerMask.hideAll" | "layer.layerMask.revealSelection" | "layer.layerMask.hideSelection"); - matches!(id, "layer.layerStyle.blendingOptions" | "layer.layerStyle.copyLayerStyle" | "layer.exportAs") - || (id.starts_with("layer.layerMask.") && !adds_mask) + matches!(id, "layer.layerStyle.blendingOptions" | "layer.layerStyle.copyLayerStyle") || (id.starts_with("layer.layerMask.") && !adds_mask) } /// The context menu entries for a layer (Photoshop 2026 order, trimmed to the layer kind). @@ -567,11 +566,12 @@ mod tests { fn multi_selection_menu_greys_what_acts_on_one_layer() { use egui_kittest::kittest::{NodeT, Queryable}; let (h, _) = open_multi_selection_menu(); - for label in ["Blending Options…", "Copy Layer Style", "Export As…"] { + for label in ["Blending Options…", "Copy Layer Style"] { assert!(h.get_by_label(label).accesskit_node().is_disabled(), "{label} works on one layer only"); } for label in [ "Quick Export As PNG", + "Export As…", "Add Layer Mask", "Duplicate Layers…", "Delete Layers", @@ -602,12 +602,12 @@ mod tests { "layer.layerMask.enabled", "layer.layerMask.apply", "layer.layerMask.delete", - "layer.exportAs", ] { assert!(single_layer_only(id), "{id}"); } for id in [ "layer.quickExportAsPng", + "layer.exportAs", "layer.duplicate", "layer.delete", "layer.rasterize.layer", diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b5b72e93a5..4e6c9ba188 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -396,6 +396,11 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: l if matches!(l, "layer.exportAs" | "layer.quickExportAsPng") && params.as_object().is_none_or(|o| o.is_empty()) => { let layer = app.session.active().and_then(|d| d.active_layer).ok_or("no active layer")?; if l == "layer.exportAs" { + // Several layers (artboards, say) selected: one file each (#3629). + let layers = app.session.active().map(|d| d.selected_layers()).unwrap_or_default(); + if layers.len() > 1 { + return Ok(json!({"dialog": crate::export_dialog::open_layers(app, &layers)?})); + } Ok(json!({"dialog": crate::export_dialog::open_layer(app, layer)?})) } else { crate::export_dialog::quick_export_selected_layers_png(app) From 24bd7533fc787f3a18aab89c7b294204d740863b Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:05:02 -0500 Subject: [PATCH 061/523] feat(ui): the generic filter dialogs get a Photoshop-style preview window (#3769) (#3877) Photoshop shows a preview of the filtered result inside its filter dialogs; ours only had the parameter widgets, with the live result on the canvas behind the dialog. The generic schema dialog now draws a preview window from the same downsampled proxy the canvas already computes, with - / + zoom steps and a percentage readout, drag to pan, a click to centre on a document point, and press-and-hold to compare with the unfiltered image. It sits under the parameters (reserving its height so a small window still fits OK/Cancel); the Preview checkbox still toggles the on-canvas result, unchanged. Tests: cargo fmt --all -- --check; cargo test -p photocraft-ui-egui --lib (only the pre-existing Czech cs.tsv duplicate-key test fails); cargo clippy -p photocraft-ui-egui --all-targets -- -A clippy::nonminimal_bool -A clippy::collapsible_match -D warnings; cargo xtask wasm; a snapshot of the Emboss dialog shows the window and its zoom controls. --- crates/ui-egui/src/canvas.rs | 8 +- crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/filter_dialog.rs | 3 +- crates/ui-egui/src/filter_preview_ui.rs | 320 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + 5 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/filter_preview_ui.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c982dd9afa..7a2a710ceb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -937,7 +937,7 @@ fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage { /// A composite as monitor pixels: document profile → monitor profile (nothing to do in the /// common sRGB-on-sRGB case). -fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage { +pub(crate) fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage { match display { Some(d) if !d.is_identity() => { let img = d.to_rgba8(buf); @@ -950,7 +950,11 @@ fn display_image(display: Option<&photocraft_engine::display_color::CanvasDispla /// The canvas display of `doc` on `display` (`None`: the main window's) and a key that changes /// with it (folded into the canvas caches' preview keys, so a monitor or profile change /// re-renders). -fn canvas_display(app: &PhotocraftApp, doc: &Document, display: Option) -> (Option>, u64) { +pub(crate) fn canvas_display( + app: &PhotocraftApp, + doc: &Document, + display: Option, +) -> (Option>, u64) { match app.session.color.canvas_display_for(doc, display.or(app.session.color.main_display)) { Ok(d) => { let k = d.key; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1d45c03cc9..2765d0e2a5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -255,10 +255,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { fields.insert("__note".into(), Value::String(crate::monitor_status::note(app))); } // Long parameter lists (Flame, Lighting Effects) scroll, so the title and the - // OK / Cancel buttons stay inside a small window. - let room = (ctx.content_rect().height() - DIALOG_CHROME).max(120.0); + // OK / Cancel buttons stay inside a small window. The preview window below the + // parameters reserves its own space (#3769). + let room = (ctx.content_rect().height() - DIALOG_CHROME - crate::filter_preview_ui::reserved_height(&fields)).max(120.0); let body = |ui: &mut egui::Ui, fields: &mut serde_json::Map| { egui::ScrollArea::vertical().id_salt(id.with("body")).max_height(room).show(ui, |ui| crate::filter_dialog::body(ui, fields)); + // The in-dialog preview window sits under the scrolling parameters (#3769), + // so it never shifts them. + crate::filter_preview_ui::window(app, d.id, ui, fields); }; // Select and Mask leaves the keyboard to its view keys (F, X, O, V…). if crate::select_mask_view::owns(&fields) { diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 997bd56854..5f2f853fda 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1,4 +1,5 @@ -//! Filter dialogs generated from engine command parameter specs, with live on-canvas preview. +//! Filter dialogs generated from engine command parameter specs, with live on-canvas preview and an +//! in-dialog preview window (#3769). //! //! The preview runs the *same engine command* on the downsampled proxy document (pixel-sized //! parameters scaled by the proxy factor), so what you preview is what you get. diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs new file mode 100644 index 0000000000..5f4ac13924 --- /dev/null +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -0,0 +1,320 @@ +//! In-dialog preview window for the generic schema filter dialogs (#3769). +//! +//! Photoshop shows a preview of the filtered result *inside* its filter dialogs. This draws one +//! from the same proxy preview the canvas already shows (`filter_dialog::preview_document`): a +//! cropped region of the filtered proxy with − / + zoom and a percentage readout, drag to pan, a +//! click to centre the view on a document point, and press-and-hold to compare with the unfiltered +//! image. The proxy is drawn scaled by its reduction factor, so 100% is one *document* pixel per +//! point whatever the proxy factor. The Preview checkbox keeps toggling the on-canvas live result +//! (`canvas::ensure_filter_preview`), unchanged. + +use egui::{Color32, Rect, Sense, Stroke, Vec2, pos2, vec2}; +use photocraft_doc::Document; +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::filter_dialog::FilterPreviewKey; +use crate::theme::Tokens; + +/// Zoom bounds, as a fraction of document scale: 100% shows one document pixel per point. +const MIN_ZOOM: f32 = 0.125; +const MAX_ZOOM: f32 = 8.0; +const DEFAULT_ZOOM: f32 = 1.0; +/// The preview region's height in points; its width follows the dialog. +const PREVIEW_H: f32 = 150.0; +/// Private dialog fields holding the preview's view (stripped from the command's params). +const ZOOM_FIELD: &str = "__pvZoom"; +const CENTER_X_FIELD: &str = "__pvCx"; +const CENTER_Y_FIELD: &str = "__pvCy"; + +/// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A +/// no-op for dialogs without a preview and for Select and Mask, which has its own view. +pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map) { + if !f.contains_key("__preview") || crate::select_mask_view::owns(f) { + return; + } + let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return }; + let Some(idx) = app.session.active_index() else { return }; + let Some(st) = app.session.documents().get(idx) else { return }; + let (doc_id, revision) = (st.doc.id, st.revision); + + // The filtered proxy the canvas already computed for this dialog, when there is one. Matching + // the dialog keeps a stacked dialog from showing another's preview; the Preview checkbox still + // decides whether the filtered result (rather than the original) is shown. + let preview_on = f.get("__preview").and_then(Value::as_bool) == Some(true); + let filtered: Option<(FilterPreviewKey, std::sync::Arc)> = app.filter_preview.as_ref().and_then(|p| { + (preview_on && p.key.dialog == dialog && p.key.doc == doc_id && p.key.command == cmd).then(|| p.result.clone().map(|r| (p.key.clone(), r))).flatten() + }); + // The proxy factor the preview runs at (the filtered key's when it exists, else the request's). + let base_k = filtered.as_ref().map(|(k, _)| k.k).unwrap_or_else(|| { + let params = app.with_mask_target(&cmd, crate::filter_dialog::params_of(f)); + crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))) + }); + + let t = Tokens::get(ui.ctx()); + let cache_id = egui::Id::new(("filter-preview-ui", dialog)); + let mut cache: Cache = ui.ctx().data_mut(|d| std::mem::take(d.get_temp_mut_or_default::(cache_id))); + + // Unfiltered proxy: built once per document revision and factor (cheap at the proxy factor). + let orig_key = TexKey::Original(doc_id, revision, base_k); + if cache.original.as_ref().map(|c| &c.key) != Some(&orig_key) { + let proxy = crate::proxy::proxy_document(&st.doc, base_k); + let tex = upload(ui.ctx(), app, "filter-preview-original", &proxy); + cache.original = Some(CachedTex { key: orig_key, tex, size: [proxy.size.width, proxy.size.height] }); + } + // Filtered proxy: rebuilt whenever the worker publishes a new result. + match &filtered { + Some((key, filtered_doc)) => { + let fkey = TexKey::Filtered(key.clone()); + if cache.filtered.as_ref().map(|c| &c.key) != Some(&fkey) { + let tex = upload(ui.ctx(), app, "filter-preview-filtered", filtered_doc); + cache.filtered = Some(CachedTex { key: fkey, tex, size: [filtered_doc.size.width, filtered_doc.size.height] }); + } + } + None => cache.filtered = None, + } + let orig = cache.original.as_ref().map(|c| (c.tex.clone(), c.size)); + let filt = cache.filtered.as_ref().map(|c| (c.tex.clone(), c.size)); + ui.ctx().data_mut(|d| d.insert_temp(cache_id, cache)); + + let Some([img_w, img_h]) = filt.as_ref().map(|(_, s)| *s).or_else(|| orig.as_ref().map(|(_, s)| *s)) else { return }; + if img_w == 0 || img_h == 0 { + return; + } + // The proxy is the document reduced by `base_k`, so the view works in *document* pixels: at + // 100% one document pixel covers one point, whatever the proxy factor (#3769). + let doc = document_size([img_w, img_h], base_k); + + // View state, sanitised: a corrupt field must not divide by zero or draw off the texture. + let mut zoom = f.get(ZOOM_FIELD).and_then(Value::as_f64).map(|v| v as f32).filter(|z| z.is_finite() && *z > 0.0).unwrap_or(DEFAULT_ZOOM); + zoom = zoom.clamp(MIN_ZOOM, MAX_ZOOM); + let mut cx = f.get(CENTER_X_FIELD).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()).unwrap_or(doc.x / 2.0); + let mut cy = f.get(CENTER_Y_FIELD).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()).unwrap_or(doc.y / 2.0); + (cx, cy) = clamp_center(cx, cy, doc.x, doc.y); + + let width = ui.available_width().max(140.0); + let (rect, response) = ui.allocate_exact_size(vec2(width, PREVIEW_H), Sense::click_and_drag()); + let (x0, y0, sw, sh) = source_rect(cx, cy, zoom, rect.size(), doc); + let uv = Rect::from_min_max( + pos2((x0 / doc.x).clamp(0.0, 1.0), (y0 / doc.y).clamp(0.0, 1.0)), + pos2(((x0 + sw) / doc.x).clamp(0.0, 1.0), ((y0 + sh) / doc.y).clamp(0.0, 1.0)), + ); + + // Press-and-hold shows the unfiltered image, as Photoshop's preview does. + let holding = response.is_pointer_button_down_on(); + let shown = if holding { orig.as_ref() } else { filt.as_ref().or(orig.as_ref()) }; + let painter = ui.painter_at(rect); + painter.rect_filled(rect, t.radius_sm, t.canvas); + if let Some((tex, _)) = shown { + crate::widgets::checker(&painter, rect, 8.0); + painter.image(tex.id(), rect, uv, Color32::WHITE); + } + painter.rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + + // Drag to pan (the image follows the pointer); a plain click centres on the clicked point. + if response.dragged() { + let d = response.drag_delta(); + cx -= d.x / zoom; + cy -= d.y / zoom; + } + if response.clicked() + && let Some(p) = response.interact_pointer_pos() + { + (cx, cy) = point_to_source(p - rect.min, x0, y0, zoom, doc); + } + (cx, cy) = clamp_center(cx, cy, doc.x, doc.y); + + ui.horizontal(|ui| { + if crate::icons::button(ui, "minus", 22.0, false, tl!("Zoom out")).clicked() { + zoom = step_zoom(zoom, false); + } + ui.label(egui::RichText::new(format!("{}%", crate::widgets::fmt_num(zoom as f64 * 100.0))).color(t.text_dim)); + if crate::icons::button(ui, "plus", 22.0, false, tl!("Zoom in")).clicked() { + zoom = step_zoom(zoom, true); + } + }); + + f.insert(ZOOM_FIELD.into(), json!(zoom.clamp(MIN_ZOOM, MAX_ZOOM))); + f.insert(CENTER_X_FIELD.into(), json!(cx)); + f.insert(CENTER_Y_FIELD.into(), json!(cy)); +} + +/// The zoom levels the − / + buttons step through, like the Filter Gallery's. +const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; + +/// The vertical space the preview window takes below the parameters: the image, the zoom row and +/// their spacing. `0` when the dialog has no preview (so the caller reserves nothing). +pub(crate) fn reserved_height(f: &Map) -> f32 { + if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 } +} + +/// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends. +fn step_zoom(zoom: f32, up: bool) -> f32 { + if up { + ZOOM_STEPS.iter().copied().find(|s| *s > zoom + 1e-3).unwrap_or(MAX_ZOOM) + } else { + ZOOM_STEPS.iter().rev().copied().find(|s| *s < zoom - 1e-3).unwrap_or(MIN_ZOOM) + } +} + +/// Keep the view centre on the image (so panning never runs off it entirely). A non-finite centre +/// (corrupt dialog state) falls back to the image's origin. +fn clamp_center(cx: f32, cy: f32, w: f32, h: f32) -> (f32, f32) { + let axis = |v: f32, max: f32| if v.is_finite() { v.clamp(0.0, max.max(0.0)) } else { 0.0 }; + (axis(cx, w), axis(cy, h)) +} + +/// The image rectangle shown in a `region` of points at `zoom`, centred on `(cx, cy)`: +/// `(x0, y0, width, height)` in document pixels. A region wider than the image is centred on it. +fn source_rect(cx: f32, cy: f32, zoom: f32, region: Vec2, doc: Vec2) -> (f32, f32, f32, f32) { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { DEFAULT_ZOOM }; + let sw = (region.x / zoom).max(0.0); + let sh = (region.y / zoom).max(0.0); + (origin(cx, sw, doc.x), origin(cy, sh, doc.y), sw, sh) +} + +fn origin(centre: f32, size: f32, total: f32) -> f32 { + if size >= total { (total - size) / 2.0 } else { (centre - size / 2.0).clamp(0.0, total - size) } +} + +/// The document pixel under a pointer at `local` (relative to the region's top-left) in a view whose +/// source rectangle starts at `(x0, y0)` and is shown at `zoom`. +fn point_to_source(local: Vec2, x0: f32, y0: f32, zoom: f32, doc: Vec2) -> (f32, f32) { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { DEFAULT_ZOOM }; + ((x0 + local.x / zoom).clamp(0.0, doc.x), (y0 + local.y / zoom).clamp(0.0, doc.y)) +} + +/// The document-pixel size of a proxy with `k`-reduced dimensions. A hostile/overflowing size falls +/// back to the proxy's own dimensions (factor 1), which stays finite and positive. +fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { + let scale = k.max(1) as f32; + let doc = vec2(proxy[0] as f32 * scale, proxy[1] as f32 * scale); + if doc.x.is_finite() && doc.y.is_finite() && doc.x > 0.0 && doc.y > 0.0 { doc } else { vec2(proxy[0] as f32, proxy[1] as f32) } +} + +/// A composite as monitor pixels, matching the canvas (`canvas::display_image`). +fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { + let buf = photocraft_compose::flatten(doc); + let (display, _) = crate::canvas::canvas_display(app, doc, None); + let img = crate::canvas::display_image(display.as_deref(), &buf); + ctx.load_texture(name, img, egui::TextureOptions::LINEAR) +} + +/// What a cached texture was built from, so it is rebuilt only when its source changes. +#[derive(Clone, PartialEq)] +enum TexKey { + Filtered(FilterPreviewKey), + Original(photocraft_doc::DocId, u64, u32), +} + +#[derive(Clone)] +struct CachedTex { + key: TexKey, + tex: egui::TextureHandle, + /// The image's size in pixels. + size: [u32; 2], +} + +/// The dialog's live textures: the filtered proxy and the unfiltered proxy. +#[derive(Clone, Default)] +struct Cache { + filtered: Option, + original: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zoom_steps_walk_the_levels_and_stay_in_bounds() { + assert_eq!(step_zoom(1.0, true), 2.0); + assert_eq!(step_zoom(1.0, false), 0.5); + assert_eq!(step_zoom(0.9, true), 1.0); + assert_eq!(step_zoom(1.1, false), 1.0); + assert_eq!(step_zoom(MAX_ZOOM, true), MAX_ZOOM, "clamped at the top"); + assert_eq!(step_zoom(MIN_ZOOM, false), MIN_ZOOM, "clamped at the bottom"); + assert_eq!(step_zoom(0.0, true), MIN_ZOOM, "a corrupt zoom recovers upward"); + assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM); + } + + #[test] + fn the_centre_is_clamped_to_the_document() { + assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0)); + assert_eq!(clamp_center(40.0, 25.0, 100.0, 50.0), (40.0, 25.0)); + // A corrupt (non-finite) centre falls back to the origin, not a panic or a NaN. + assert_eq!(clamp_center(f32::NAN, f32::INFINITY, 100.0, 50.0), (0.0, 0.0)); + } + + #[test] + fn the_source_rect_follows_the_centre_and_zoom() { + let doc = vec2(1000.0, 500.0); + let region = vec2(200.0, 100.0); + // 100%: a 200×100 document-pixel crop centred at (500, 250) sits in the middle. + assert_eq!(source_rect(500.0, 250.0, 1.0, region, doc), (400.0, 200.0, 200.0, 100.0)); + // 200%: the crop is half as large, still centred. + assert_eq!(source_rect(500.0, 250.0, 2.0, region, doc), (450.0, 225.0, 100.0, 50.0)); + // Near an edge the crop stops at it instead of running off. + let (x0, _, sw, _) = source_rect(0.0, 250.0, 1.0, region, doc); + assert_eq!((x0, sw), (0.0, 200.0)); + let (x0, _, sw, _) = source_rect(1000.0, 250.0, 1.0, region, doc); + assert_eq!((x0, sw), (800.0, 200.0)); + } + + #[test] + fn a_zoomed_out_region_centres_the_whole_image() { + let doc = vec2(100.0, 50.0); + let region = vec2(200.0, 100.0); + let (x0, y0, sw, sh) = source_rect(50.0, 25.0, 0.5, region, doc); + // The region is wider than the image: it is centred (a negative origin) and covers all of + // it, so the UV clamps to 0..1 rather than reading outside the texture. + assert_eq!((x0, y0, sw, sh), (-150.0, -75.0, 400.0, 200.0)); + assert!(x0 / doc.x < 0.0 && (x0 + sw) / doc.x > 1.0); + } + + #[test] + fn a_click_maps_to_the_document_point_under_it() { + let doc = vec2(1000.0, 500.0); + // The top-left of a crop starting at (400, 200) shown at 100% is that point. + assert_eq!(point_to_source(vec2(0.0, 0.0), 400.0, 200.0, 1.0, doc), (400.0, 200.0)); + // At 200%, 50 points in is 25 document pixels in. + assert_eq!(point_to_source(vec2(50.0, 50.0), 400.0, 200.0, 2.0, doc), (425.0, 225.0)); + // Clicking outside the image clamps to it. + assert_eq!(point_to_source(vec2(-10_000.0, 0.0), 400.0, 200.0, 1.0, doc), (0.0, 200.0)); + } + + #[test] + fn a_hundred_percent_is_one_document_pixel_per_point() { + // A 4×-reduced proxy of a 4000×3000 document: 100% must span 200 document pixels across a + // 200-point region, not 200 proxy pixels (which would be a quarter of the document scale). + let doc = document_size([1000, 750], 4); + assert_eq!(doc, vec2(4000.0, 3000.0)); + let (_, _, sw, sh) = source_rect(2000.0, 1500.0, 1.0, vec2(200.0, 100.0), doc); + assert_eq!((sw, sh), (200.0, 100.0), "one document pixel per point at 100%"); + // The same region covers 50 proxy pixels: the stored UV is a 200/4000 fraction. + let (x0, _, _, _) = source_rect(2000.0, 1500.0, 1.0, vec2(200.0, 100.0), doc); + assert_eq!(x0 / doc.x, 0.475); + assert!(document_size([0, 0], 4) == vec2(0.0, 0.0), "degenerate size stays finite"); + assert_eq!(document_size([10, 20], 0), vec2(10.0, 20.0), "a zero factor means 1×"); + } + + #[test] + fn the_window_defaults_its_view_and_never_panics() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + let id = crate::filter_dialog::open(&mut app, "filter.stylize.emboss").expect("Emboss opens a dialog"); + let mut f = app.ui.dialogs.iter().find(|d| d.id == id).expect("the dialog is open").fields.clone(); + assert!(f.contains_key("__preview"), "Emboss previews"); + egui::Context::default().run_ui(Default::default(), |ui| window(&app, id, ui, &mut f)).textures_delta.clear(); + assert_eq!(f.get(ZOOM_FIELD), Some(&json!(1.0)), "100% by default"); + assert_eq!(f.get(CENTER_X_FIELD), Some(&json!(32.0)), "centred on the document"); + assert_eq!(f.get(CENTER_Y_FIELD), Some(&json!(24.0))); + // Corrupt state is recovered, not panicked on. + f.insert(ZOOM_FIELD.into(), json!(0.0)); + f.insert(CENTER_X_FIELD.into(), json!(f64::NAN)); + f.insert(CENTER_Y_FIELD.into(), json!(-999.0)); + egui::Context::default().run_ui(Default::default(), |ui| window(&app, id, ui, &mut f)).textures_delta.clear(); + assert_eq!(f.get(ZOOM_FIELD), Some(&json!(1.0))); + assert_eq!(f.get(CENTER_Y_FIELD), Some(&json!(0.0))); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50a8998f78..a16dca00d4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,7 @@ pub mod file_open; pub mod file_ui; pub mod fill_ui; pub mod filter_dialog; +mod filter_preview_ui; #[cfg(not(target_arch = "wasm32"))] mod filter_preview_worker; pub mod flick_pan; From 5bc66094f8541911f46ae732d39d945290fc0b49 Mon Sep 17 00:00:00 2001 From: Tarek Mohamed <30214297+seotarek@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:05:15 +0300 Subject: [PATCH 062/523] fix(ui): allow selecting shapes and editing color with path selection (#3915) (#3932) * fix(ui): allow selecting shapes and editing color with path selection (#3915) - In vector_ui.rs, implement pick_shape_at to hit-test visible shape layers by path, surface pixels, or live shape geometry. - In path_selection_finish and canvas ToolEvent::Down, select the clicked shape layer under the pointer when using Path Selection. - In direct_select.rs, include visible shape layers in candidate hit tests and sync layer.select on press. - In options_bar, display Fill swatch/checkbox, Stroke swatch/width/style, and corner radius when a shape is selected with Path Selection or Direct Selection, and dispatch shape.edit. - Add tests in path_selection_tests.rs and vector_ui.rs verifying shape selection on click and options bar editing. Fixes storytold/photocraft#3915 * Collapse nested ifs for clippy (collapsible_if) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 + crates/ui-egui/src/direct_select.rs | 24 ++- crates/ui-egui/src/path_selection_tests.rs | 20 +++ crates/ui-egui/src/vector_ui.rs | 177 ++++++++++++++++++++- 4 files changed, 221 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7a2a710ceb..304f3b2f0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4688,6 +4688,14 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) return; } match tool { + Tool::PathSelection => { + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + if let Some(st) = app.session.active() + && let Some(id) = crate::vector_ui::pick_shape_at(&st.doc, [x, y], tol) + { + let _ = app.run("layer.select", json!({"layer": id.0})); + } + } Tool::Pen => { if !(mods.alt && crate::vector_ui::pen_grab_handle(app, x, y)) { crate::vector_ui::pen_down(app, x, y); diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 4955053962..01deb0a070 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -139,7 +139,20 @@ pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let layer = crate::vector_ui::targeted_vector_mask(app).or_else(|| crate::vector_ui::active_shape_path(app)); let work = app.session.active().and_then(|st| st.doc.work_path.clone()); - layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect() + let mut out: Vec<_> = layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect(); + if let Some(st) = app.session.active() { + for (path, _, l) in st.doc.walk().iter().rev() { + if !l.visible || !(1..=path.len()).all(|n| st.doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { + continue; + } + if let photocraft_doc::LayerContent::Shape(sh) = &l.content + && !out.iter().any(|(r, _)| *r == PathRef::Layer(l.id.0)) + { + out.push((PathRef::Layer(l.id.0), sh.path.clone())); + } + } + } + out } fn retracted(k: &photocraft_doc::Knot, h: Handle) -> bool { @@ -222,14 +235,19 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) - let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() }; edit::hit(&path, Point::new(p[0], p[1]), tol, &handles).map(|h| (r, path, h)) }); - let ds = &mut app.ui.direct_selection; let Some((target, base, hit)) = found else { if convert { return own_tool; } - ds.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); + app.ui.direct_selection.drag = Some(Drag::Marquee { start: p, end: p, add: mods.shift }); return true; }; + if let PathRef::Layer(id) = target + && app.session.active().and_then(|st| st.active_layer).map(|l| l.0) != Some(id) + { + let _ = app.run("layer.select", json!({"layer": id})); + } + let ds = &mut app.ui.direct_selection; if ds.target != Some(target) { *ds = DirectSelection { target: Some(target), ..Default::default() }; } diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index 89dcd49c57..8f82e2ad68 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -227,3 +227,23 @@ fn thumbnail_selection_ctrl_click_layer_path_preserves_panel_target() { h.render().unwrap().save(path).unwrap(); } } + +/// #3915: Clicking a shape with the Path Selection tool selects its layer. +#[test] +fn clicking_shape_with_path_selection_selects_its_layer() { + let mut h = harness(); + let rect_layer = + h.state_mut().run("shape.create", json!({"kind": "rect", "rect": [20, 20, 80, 60], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + // Add a new empty layer above so rect is not the active layer. + let empty_layer = h.state_mut().run("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, empty_layer); + + // Clicking inside the rectangle with Path Selection selects the rect layer. + h.state_mut().ui.tool = Tool::PathSelection; + crate::vector_ui::path_selection_finish(h.state_mut(), [50.0, 50.0], [50.0, 50.0]); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); + + // Clicking outside does not change active layer. + crate::vector_ui::path_selection_finish(h.state_mut(), [150.0, 90.0], [150.0, 90.0]); + assert_eq!(h.state().session.active().unwrap().active_layer.unwrap().0, rect_layer); +} diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index c9b3873c9a..3a5dade0e2 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -642,11 +642,98 @@ pub(crate) fn free_transform_path(app: &PhotocraftApp) -> Option<(Value, Path)> Some((params, path)) } +fn point_in_subpath(sp: &photocraft_doc::vector::Subpath, pt: [f64; 2]) -> bool { + if !sp.closed || sp.knots.len() < 3 { + return false; + } + let mut inside = false; + let n = sp.knots.len(); + for i in 0..n { + let j = (i + 1) % n; + let xi = sp.knots[i].anchor.x; + let yi = sp.knots[i].anchor.y; + let xj = sp.knots[j].anchor.x; + let yj = sp.knots[j].anchor.y; + let intersect = ((yi > pt[1]) != (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi); + if intersect { + inside = !inside; + } + } + inside +} + +/// The topmost visible shape layer under `at` (document coordinates): +/// hits the shape path or anchor within `tol`, or any non-transparent pixel of the shape's raster surface, +/// or point inside the shape's filled geometry. +pub fn pick_shape_at(doc: &Document, at: [f64; 2], tol: f64) -> Option { + let (x, y) = (at[0].floor() as i32, at[1].floor() as i32); + for (path, _, l) in doc.walk().iter().rev() { + if !l.visible || !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { + continue; + } + let board = path.first().and_then(|i| doc.layers.get(*i)).and_then(|t| t.artboard()); + if board.is_some_and(|a| !a.rect.contains(x, y)) { + continue; + } + if let LayerContent::Shape(sh) = &l.content { + if photocraft_vector::edit::hit(&sh.path, photocraft_geom::Point::new(at[0], at[1]), tol, &[]).is_some() { + return Some(l.id); + } + if sh.fill.is_some() { + if let Some(surf) = l.surface() { + let mut px = [[0.0f32; 4]; 1]; + surf.read_rgba_into(photocraft_doc::Rect::from_xywh(x, y, 1, 1), &mut px); + if px[0][3] > 0.0 { + return Some(l.id); + } + } + if let Some(photocraft_doc::LiveShape::Rect { rect, .. }) = &sh.live { + let x0 = rect[0].min(rect[0] + rect[2]); + let x1 = rect[0].max(rect[0] + rect[2]); + let y0 = rect[1].min(rect[1] + rect[3]); + let y1 = rect[1].max(rect[1] + rect[3]); + if at[0] >= x0 && at[0] <= x1 && at[1] >= y0 && at[1] <= y1 { + return Some(l.id); + } + } + if let Some(photocraft_doc::LiveShape::Ellipse { rect }) = &sh.live { + let rx = rect[2].abs() / 2.0; + let ry = rect[3].abs() / 2.0; + if rx > 1e-6 && ry > 1e-6 { + let cx = rect[0] + rect[2] / 2.0; + let cy = rect[1] + rect[3] / 2.0; + let dx = (at[0] - cx) / rx; + let dy = (at[1] - cy) / ry; + if dx * dx + dy * dy <= 1.0 { + return Some(l.id); + } + } + } + if sh.path.subpaths.iter().any(|sp| point_in_subpath(sp, at)) { + return Some(l.id); + } + } + } + } + None +} + pub fn path_selection_finish(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) { let (dx, dy) = (end[0] - start[0], end[1] - start[1]); + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); if dx.abs() + dy.abs() < 0.5 { + let Some(st) = app.session.active() else { return }; + if let Some(id) = pick_shape_at(&st.doc, start, tol) { + let _ = app.run("layer.select", json!({"layer": id.0})); + } return; } + if target_path(app).is_none() { + let Some(st) = app.session.active() else { return }; + if let Some(id) = pick_shape_at(&st.doc, start, tol) { + let _ = app.run("layer.select", json!({"layer": id.0})); + } + } let Some((target, path)) = target_path(app) else { return }; let _ = match target { PathTarget::Shape(id) => app.run("shape.edit", json!({"layer": id, "move": [dx.round(), dy.round()]})), @@ -826,11 +913,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ui.label(egui::RichText::new(s).color(t.text_dim).size(12.0)); }; let o = &mut app.ui.tool_options; - if tool == Tool::PathSelection { + let target = selected_shape(&app.session); + if tool == Tool::PathSelection && target.is_none() { lbl(ui, if app.ui.vector_mask_target { "Drag to move the targeted vector mask" } else { tl!("Drag to move the active shape's path or the Work Path") }); return true; } - if tool == Tool::DirectSelection { + if tool == Tool::DirectSelection && target.is_none() { let (shift, alt) = (crate::shortcuts::pretty("Shift"), crate::shortcuts::pretty("Alt")); lbl( ui, @@ -893,7 +981,6 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::dropdown(ui, "shape-mode", &mut mode, &[("shape".to_string(), tl!("Shape"))], 80.0); crate::widgets::vline(ui, 22.0); // Fill and Stroke edit the selected shape layer too (#3347), and still set the next shape's. - let target = selected_shape(&app.session); let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); let (mut has_fill, fill, stroke) = match &target { Some(s) => (s.fill.is_some(), s.fill.clone(), s.stroke.clone()), @@ -936,7 +1023,15 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo Tool::Rectangle => { crate::widgets::vline(ui, 22.0); lbl(ui, tl!("Radius:")); - crate::widgets::value_field(ui, &mut o.corner_radius, 0.0..=10000.0, "px", 62.0); + let mut r = match target.as_ref().and_then(|s| s.live.as_ref()) { + Some(photocraft_doc::LiveShape::Rect { radii, .. }) => radii[0] as f32, + _ => o.corner_radius, + }; + if crate::widgets::value_field(ui, &mut r, 0.0..=10000.0, "px", 62.0).changed() { + o.corner_radius = r; + let rf = r as f64; + edit = Some(json!({"radii": [rf, rf, rf, rf], "coalesce": key("radius")})); + } } Tool::Polygon => { crate::widgets::vline(ui, 22.0); @@ -955,6 +1050,18 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::vline(ui, 22.0); crate::preset_panels::shape_picker(app, ui); } + _ if matches!(tool, Tool::PathSelection | Tool::DirectSelection) => { + if let Some(photocraft_doc::LiveShape::Rect { radii, .. }) = target.as_ref().and_then(|s| s.live.as_ref()) { + crate::widgets::vline(ui, 22.0); + lbl(ui, tl!("Radius:")); + let mut r = radii[0] as f32; + if crate::widgets::value_field(ui, &mut r, 0.0..=10000.0, "px", 62.0).changed() { + o.corner_radius = r; + let rf = r as f64; + edit = Some(json!({"radii": [rf, rf, rf, rf], "coalesce": key("radius")})); + } + } + } _ => {} } for (is_stroke, change) in [(false, fill_color), (true, stroke_color)] { @@ -986,13 +1093,14 @@ struct SelectedShape { layer: photocraft_doc::LayerId, fill: Option, stroke: Option, + live: Option, } fn selected_shape(session: &photocraft_engine::Session) -> Option { let st = session.active()?; let layer = st.active_layer?; match &st.doc.layer(layer)?.content { - LayerContent::Shape(sh) => Some(SelectedShape { doc: st.doc.id, layer, fill: sh.fill.clone(), stroke: sh.stroke.clone() }), + LayerContent::Shape(sh) => Some(SelectedShape { doc: st.doc.id, layer, fill: sh.fill.clone(), stroke: sh.stroke.clone(), live: sh.live.clone() }), _ => None, } } @@ -2136,6 +2244,65 @@ mod tests { click_fill(&mut h); assert_eq!(h.state().session.active().unwrap().history.past_len(), history); } + + /// #3915: Path Selection and Direct Selection options bars show Fill, Stroke, and Radius + /// controls when a shape is selected, and editing them edits the selected shape. + #[test] + fn path_selection_options_bar_edits_the_selected_shape() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut app = app(); + app.run("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap(); + finish_shape(&mut app, Tool::Rectangle, [20.0, 20.0], [120.0, 100.0], Default::default()); + let shape = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + match &st.doc.layer(st.active_layer.unwrap()).unwrap().content { + LayerContent::Shape(sh) => (sh.fill.clone(), sh.stroke.clone()), + _ => panic!("not a shape layer"), + } + }; + assert!(shape(&app).0.is_some() && shape(&app).1.is_none()); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 48.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal_centered(|ui| options_bar(app, ui, Tool::PathSelection)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let click_fill = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + let label = h.query_all(egui_kittest::kittest::by().label("Fill:").include_labels()).next().expect("the Fill label").rect(); + let at = egui::pos2(label.right() + h.ctx.global_style().spacing.item_spacing.x + 7.0, label.center().y); + h.hover_at(at); + h.step(); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + }; + // Stroke width field is available on Path Selection bar too. + h.get_all_by_role(Role::SpinButton).next().expect("the stroke width").click(); + h.run(); + for c in "6".chars() { + h.event(egui::Event::Text(c.to_string())); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run_steps(2); + let stroke = shape(h.state()).1.expect("the width field stroked the shape"); + assert_eq!(stroke.width, 6.0); + assert_eq!(stroke.paint, photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(0.0, 0.0, 1.0, 1.0))); + // Fill toggle. + click_fill(&mut h); + assert_eq!(shape(h.state()).0, None, "unchecking Fill removed the shape's fill"); + // No shape selected: renders hint text instead. + h.state_mut().run("layer.new.layer", json!({})).unwrap(); + h.run_steps(2); + assert!(h.query_all(egui_kittest::kittest::by().label("Fill:").include_labels()).next().is_none()); + } } #[cfg(test)] From 6f1c4481bfe158eccd5ed9b0bae344145f74e165 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 06:06:46 -0400 Subject: [PATCH 063/523] fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info) (#4041) - TySh: the four values after the warp descriptor are big-endian f32, not i32. Written as Photoshop writes them: zeros for unwarped type, the text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped text oracles). - EngineData AntiAlias numbers as Photoshop writes them, on write and on read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which no corpus file confirms, write Smooth and keep their exact mode in AntA. - Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026", no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles write. Co-authored-by: Claude Opus 5.5 --- crates/io/tests/psd_type_bounds.rs | 4 +- crates/io/tests/psd_type_photoshop_bytes.rs | 74 +++++++++++++++++++++ crates/psd/src/resources.rs | 20 +++++- crates/text/src/psd.rs | 46 +++++++++---- crates/text/src/tests.rs | 40 +++++++++++ 5 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 crates/io/tests/psd_type_photoshop_bytes.rs diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 4ae925ec12..95e7ddfa13 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -112,8 +112,8 @@ fn tysh_with_font(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transf .with("boundingBox", rect("boundingBox")) .with("TextIndex", D::Integer(0)) .with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds, font)))); - let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32]; - write_tysh(&TySh { transform, text: desc, warp: None, bounds }) + // Unwarped type: Photoshop writes four zero f32s after the warp descriptor. + write_tysh(&TySh { transform, text: desc, warp: None, bounds: [0.0; 4] }) } struct Case { diff --git a/crates/io/tests/psd_type_photoshop_bytes.rs b/crates/io/tests/psd_type_photoshop_bytes.rs new file mode 100644 index 0000000000..98524892c4 --- /dev/null +++ b/crates/io/tests/psd_type_photoshop_bytes.rs @@ -0,0 +1,74 @@ +//! Type-layer bytes we write match what Photoshop 2026 writes for the same settings, checked +//! against Photoshop-authored files in `corpus/photoshop` (feature `corpus`; fetched by +//! `cargo xtask corpus --all`, a missing corpus fails): +//! - EngineData `AntiAlias` numbers for every anti-aliasing mode; +//! - the four f32 values that end a `TySh` block (zeros unwarped, text-space bounds warped); +//! - the version info resource (1057). + +#![cfg(feature = "corpus")] + +use std::path::{Path, PathBuf}; + +use photocraft_psd::PsdFile; +use photocraft_psd::descriptor::Value as D; +use photocraft_text::engine_data::Value as E; + +fn corpus(rel: &str) -> PsdFile { + let path: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop").join(rel); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + PsdFile::from_bytes(&bytes).unwrap() +} + +fn first_tysh(f: &PsdFile) -> Vec { + f.layers().iter().find_map(|l| l.block(b"TySh")).map(|b| b.data.clone()).unwrap() +} + +fn engine_antialias(tysh: &[u8]) -> Option { + let t = photocraft_text::psd::parse_tysh(tysh)?; + photocraft_text::psd::engine_data(&t.text)?.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64) +} + +fn rect(t: &photocraft_text::psd::TySh, key: &str) -> [f32; 4] { + let Some(D::Descriptor(d)) = t.text.get(key) else { panic!("no {key}") }; + ["Left", "Top ", "Rght", "Btom"].map(|k| match d.get(k) { + Some(D::UnitFloat { value, .. }) => *value as f32, + other => panic!("{k}: {other:?}"), + }) +} + +#[test] +fn engine_data_antialias_matches_photoshop() { + for name in ["none", "crisp", "strong", "smooth", "sharp"] { + let raw = first_tysh(&corpus(&format!("text/antialias-{name}.psd"))); + let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + layer.psd_raw = None; + let ours = photocraft_text::psd::build_tysh(&layer, 72.0, None); + assert_eq!(engine_antialias(&ours), engine_antialias(&raw), "{name}"); + } +} + +#[test] +fn tysh_trailing_bounds_match_photoshop() { + for (file, warped) in [("text/warp-arc-50.psd", true), ("text/antialias-smooth.psd", false)] { + let raw = first_tysh(&corpus(file)); + let theirs = photocraft_text::psd::parse_tysh(&raw).unwrap(); + let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); + assert_eq!(layer.warp.is_some(), warped, "{file}"); + layer.psd_raw = None; + let ours = photocraft_text::psd::parse_tysh(&photocraft_text::psd::build_tysh(&layer, 72.0, Some(rect(&theirs, "bounds")))).unwrap(); + assert_eq!(ours.bounds, theirs.bounds, "{file}"); + if warped { + assert_eq!(theirs.bounds, rect(&theirs, "bounds"), "{file}"); + } else { + assert_eq!(theirs.bounds, [0.0; 4], "{file}"); + } + } +} + +#[test] +fn version_info_matches_photoshop() { + for file in ["text/antialias-smooth.psd", "text/warp-arc-50.psd"] { + let theirs = corpus(file); + assert_eq!(photocraft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}"); + } +} diff --git a/crates/psd/src/resources.rs b/crates/psd/src/resources.rs index ba2674ba80..c20efbfc9e 100644 --- a/crates/psd/src/resources.rs +++ b/crates/psd/src/resources.rs @@ -250,15 +250,16 @@ pub(crate) fn write_section(out: &mut Vec, res: &[ImageResource]) -> Result< out.end_len(at, false) } -/// Builds the data for a 1057 version info resource. +/// Builds the data for a 1057 version info resource, with the writer and reader names Photoshop +/// 2026 writes ("Adobe Photoshop", "Adobe Photoshop 2026"), without a NUL terminator. pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource { ImageResource::new( ids::VERSION_INFO, VersionInfo { version: 1, has_real_merged_data, - writer: UnicodeString::new_nul("Adobe Photoshop"), - reader: UnicodeString::new_nul("Adobe Photoshop CS6"), + writer: UnicodeString::new("Adobe Photoshop"), + reader: UnicodeString::new("Adobe Photoshop 2026"), file_version: 1, } .to_bytes(), @@ -365,4 +366,17 @@ mod tests { other => panic!("{other:?}"), } } + + /// Byte for byte what Photoshop 2026 writes (every Photoshop-authored oracle in the corpus): + /// u32 count + UTF-16BE, no NUL terminator. + #[test] + fn version_info_matches_photoshop_bytes() { + let mut want = vec![0, 0, 0, 1, 1]; + for s in ["Adobe Photoshop", "Adobe Photoshop 2026"] { + want.extend_from_slice(&(s.len() as u32).to_be_bytes()); + want.extend(s.encode_utf16().flat_map(u16::to_be_bytes)); + } + want.extend_from_slice(&[0, 0, 0, 1]); + assert_eq!(version_info_resource(true).data, want); + } } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index c6724bf2cc..2c0ca38af5 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -24,8 +24,10 @@ pub struct TySh { pub transform: Affine, pub text: Descriptor, pub warp: Option, - /// The four trailing i32 values (left, top, right, bottom). - pub bounds: [i32; 4], + /// The four trailing big-endian f32 values (left, top, right, bottom) after the warp + /// descriptor. Photoshop writes zeros for unwarped type and the text-space bounds (the + /// `bounds` descriptor's rectangle) for warped type. + pub bounds: [f32; 4], } /// Parses a `TySh` block. @@ -42,10 +44,10 @@ pub fn parse_tysh(data: &[u8]) -> Option { None => (None, 0), }; let tail = rest.get(wused..).unwrap_or(&[]); - let mut bounds = [0i32; 4]; + let mut bounds = [0f32; 4]; for (i, b) in bounds.iter_mut().enumerate() { if let Some(x) = tail.get(i * 4..i * 4 + 4) { - *b = i32::from_be_bytes(x.try_into().unwrap_or([0; 4])); + *b = f32::from_be_bytes(x.try_into().unwrap_or([0; 4])); } } Some(TySh { transform: Affine { m }, text: text.descriptor, warp, bounds }) @@ -221,7 +223,11 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { Some("AnSt" | "antiAliasStrong") => AntiAlias::Strong, Some("antiAliasPlatformGray") => AntiAlias::Windows, Some("antiAliasPlatformLCD") => AntiAlias::WindowsLcd, - _ => AntiAlias::Smooth, + Some(_) => AntiAlias::Smooth, + // No `AntA`: the engine data's mode says it. + None => { + engine_data(&t.text).and_then(|e| e.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)).and_then(engine_antialias).unwrap_or(AntiAlias::Smooth) + } }; if let Some(w) = &t.warp { let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into()); @@ -979,12 +985,13 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E "AntiAlias", E::Int(match layer.antialias { AntiAlias::None => 0, - AntiAlias::Sharp => 1, - AntiAlias::Crisp => 2, - AntiAlias::Strong => 3, - AntiAlias::Smooth => 4, - // EngineData has no platform modes; the `AntA` descriptor carries the exact value. - AntiAlias::Windows | AntiAlias::WindowsLcd => 1, + AntiAlias::Crisp => 1, + AntiAlias::Strong => 2, + AntiAlias::Smooth => 3, + AntiAlias::Sharp => 4, + // No Photoshop file confirms the platform modes' numbers here; the `AntA` descriptor + // carries the exact mode, and Smooth is the closest. + AntiAlias::Windows | AntiAlias::WindowsLcd => 3, }), ); if dict.get("UseFractionalGlyphWidths").is_none() { @@ -1059,6 +1066,19 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E e } +/// The anti-aliasing mode for EngineData's `AntiAlias` number, as Photoshop writes it: 0 None, +/// 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Other numbers (platform modes) are left to `AntA`. +fn engine_antialias(n: f64) -> Option { + Some(match n as i64 { + 0 => AntiAlias::None, + 1 => AntiAlias::Crisp, + 2 => AntiAlias::Strong, + 3 => AntiAlias::Smooth, + 4 => AntiAlias::Sharp, + _ => return None, + }) +} + /// Builds the `TySh` block for `layer`, patching its current `psd_raw` when present. /// `ink` is the layer's rendered bounds in text space (for `bounds`/`boundingBox`). pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec { @@ -1123,6 +1143,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec (None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => Some(old), _ => None, }; - let bounds = [l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32]; + // Photoshop's trailing bounds: zeros for unwarped type, the text-space bounds when warped. + let warped = warp.as_ref().is_some_and(|w| enum_value(w, "warpStyle").is_some_and(|s| s != "warpNone")); + let bounds = if warped { [l, t, r, b] } else { [0.0; 4] }; write_tysh(&TySh { transform: layer.transform, text, warp, bounds }) } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index fa0a626fe7..8df4d81b0e 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1505,3 +1505,43 @@ fn translate_tysh_moves_only_the_translation() { assert_eq!(crate::psd::parse_tysh(&tysh).unwrap().bounds, back.bounds, "bounds are in text space"); assert!(crate::psd::translate_tysh(&tysh[..40], 1.0, 1.0).is_none(), "a truncated block is refused"); } + +/// The four values after the `TySh` warp descriptor are big-endian f32, as Photoshop writes them: +/// zeros for unwarped type, the text-space bounds for warped type. +#[test] +fn tysh_trailing_bounds_are_f32() { + use photocraft_doc::text::TextWarp; + let tail = |b: &[u8]| -> [f32; 4] { + let t = b.get(b.len() - 16..).unwrap(); + std::array::from_fn(|i| f32::from_be_bytes(t[i * 4..i * 4 + 4].try_into().unwrap())) + }; + let ink = [0.0, -34.316406, 288.90625, 15.058594]; + let plain = crate::psd::build_tysh(&point("Type", 20.0), 72.0, Some(ink)); + assert_eq!(tail(&plain), [0.0; 4]); + let warped = TextLayer { + warp: Some(TextWarp { style: "warpArc".into(), value: 50.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }), + ..point("Type", 20.0) + }; + let bytes = crate::psd::build_tysh(&warped, 72.0, Some(ink)); + assert_eq!(tail(&bytes), ink); + let parsed = crate::psd::parse_tysh(&bytes).unwrap(); + assert_eq!(parsed.bounds, ink); + assert_eq!(crate::psd::write_tysh(&parsed), bytes); +} + +/// EngineData's `AntiAlias` numbers as Photoshop writes them, beside the `AntA` descriptor: +/// 0 None, 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Read back from EngineData alone (no `AntA`), +/// every mode survives. +#[test] +fn engine_data_antialias_numbers_match_photoshop() { + use photocraft_doc::text::AntiAlias; + for (mode, n) in [(AntiAlias::None, 0), (AntiAlias::Crisp, 1), (AntiAlias::Strong, 2), (AntiAlias::Smooth, 3), (AntiAlias::Sharp, 4)] { + let t = TextLayer { antialias: mode, ..point("Type", 20.0) }; + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap(); + let e = crate::psd::engine_data(&tysh.text).unwrap(); + assert_eq!(e.path(&["EngineDict", "AntiAlias"]).and_then(crate::engine_data::Value::as_f64), Some(f64::from(n)), "{mode:?}"); + tysh.text.items.retain(|(k, _)| !k.is("AntA")); + let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap(); + assert_eq!(back.antialias, mode, "{n}"); + } +} From 0d6e658e73d8d93014ed345ab7e00e8b9de8bd7e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 06:11:33 -0400 Subject: [PATCH 064/523] PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034) * fix(psd): rasterized fill and smart layers stay rasterized after save and reopen Layer > Rasterize > Fill Content / Smart Object swapped the layer's content to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE) blocks read from the PSD. Written back, they made the reopened file a fill layer or smart object again and discarded the pixels. Photoshop writes none of them for a pixel layer. - Rasterize drops those blocks from the layer. - The PSD exporter never writes fill, placed, type (TySh), shape-content (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever command made it one; a vector mask (vmsk/vsms with its vogk) stays. Tests: synthetic fill + smart object rasterize/save/reopen, the exporter backstop with stale blocks, and Photoshop-authored corpus files. Co-Authored-By: Claude Opus 5.5 * fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- README.md | 2 - crates/engine/src/extra_cmds.rs | 3 + crates/engine/tests/rasterize_psd_reopen.rs | 115 ++++++++++++++++++++ crates/io/src/psd_export.rs | 16 ++- packaging/windows/photocraft.en-us.wxl | 1 - packaging/windows/photocraft.wxs | 12 -- 6 files changed, 133 insertions(+), 16 deletions(-) create mode 100644 crates/engine/tests/rasterize_psd_reopen.rs diff --git a/README.md b/README.md index 0455744b05..b6e8c5a601 100644 --- a/README.md +++ b/README.md @@ -331,8 +331,6 @@ sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests wi | arm64 (Snapdragon and other ARM PCs) | `photocraft--windows-arm64.msi` | `photocraft--windows-arm64-portable.zip` | | x86 (32-bit) | `photocraft--windows-x86.msi` | `photocraft--windows-x86-portable.zip` | -Windows 10 or later is required. On Windows 7 and 8 the app can't start (the installer says so). - Installers and executables are code-signed. ### macOS diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index f25ea9a2e9..84c0e24de1 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -673,6 +673,9 @@ fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.fill_cache = None; + // The PSD fill or placed-layer blocks no longer describe the layer: kept, they + // would reopen it as a fill layer or smart object and drop these pixels. + l.psd_blocks.retain(|(k, _)| !matches!(k, b"SoCo" | b"GdFl" | b"PtFl" | b"SoLd" | b"PlLd" | b"SoLE")); Ok(()) }); s.coalesce_request = None; diff --git a/crates/engine/tests/rasterize_psd_reopen.rs b/crates/engine/tests/rasterize_psd_reopen.rs new file mode 100644 index 0000000000..a39005ac14 --- /dev/null +++ b/crates/engine/tests/rasterize_psd_reopen.rs @@ -0,0 +1,115 @@ +//! Layer › Rasterize › Fill Content / Smart Object stays rasterized after a PSD save and reopen. +//! A fill or smart layer opened from a PSD keeps its file blocks (`SoCo`/`GdFl`/`PtFl`, +//! `SoLd`/`PlLd`); left on the rasterized layer, they made the reopened file a fill layer or +//! smart object again and threw the pixels away. Photoshop writes none of them for a pixel layer. + +use photocraft_doc::{Document, LayerContent, LayerId}; +use photocraft_engine::Session; +use serde_json::{Value, json}; + +fn id_of(r: Value) -> LayerId { + LayerId(r["layer"].as_u64().unwrap()) +} + +fn kind(l: &photocraft_doc::Layer) -> &'static str { + match &l.content { + LayerContent::Fill(_) => "fill", + LayerContent::Smart(_) => "smart", + LayerContent::Raster(_) => "raster", + _ => "other", + } +} + +fn kind_named(doc: &Document, name: &str) -> &'static str { + let l = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}")); + kind(l) +} + +fn reopen(doc: &Document) -> Document { + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + photocraft_io::import("x.psd", &out.bytes).unwrap().document +} + +/// Whether the PSD written for `doc` gives the layer `name` any of `keys`. +fn has_block(doc: &Document, name: &str, keys: &[&[u8; 4]]) -> bool { + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + let rec = f.layers().iter().find(|r| r.name() == name).unwrap(); + keys.iter().any(|k| rec.block(k).is_some()) +} + +/// Rasterizes every layer named in `names` of a session opened on `doc`, then saves and reopens. +fn rasterize_and_reopen(doc: Document, names: &[&str]) -> Document { + let mut s = Session::new(); + s.add_document(doc, None); + for name in names { + let id = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == *name).unwrap().id; + s.execute("layer.rasterize.layer", json!({"layer": id.0})).unwrap(); + assert_eq!(kind_named(&s.active().unwrap().doc, name), "raster"); + } + reopen(&s.active().unwrap().doc) +} + +#[test] +fn rasterized_fill_and_smart_object_reopen_as_pixels() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 32})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Source"})).unwrap(); + s.execute("paint.stroke", json!({"points": [[20, 16]], "size": 12})).unwrap(); + let smart = id_of(s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()); + let fill = id_of(s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap()); + let doc = s.active().unwrap().doc.clone(); + let smart_name = doc.layer(smart).unwrap().name.clone(); + let fill_name = doc.layer(fill).unwrap().name.clone(); + assert_ne!(smart_name, fill_name); + + // Opened from a PSD, both layers carry their file blocks. + let opened = reopen(&doc); + assert_eq!(kind_named(&opened, &fill_name), "fill"); + assert_eq!(kind_named(&opened, &smart_name), "smart"); + + let back = rasterize_and_reopen(opened, &[&fill_name, &smart_name]); + assert_eq!(kind_named(&back, &fill_name), "raster"); + assert_eq!(kind_named(&back, &smart_name), "raster"); + assert!(!has_block(&back, &fill_name, &[b"SoCo", b"GdFl", b"PtFl"])); + assert!(!has_block(&back, &smart_name, &[b"SoLd", b"PlLd", b"SoLE"])); +} + +/// The exporter never writes content-defining blocks for a pixel layer, whichever command made it +/// one (a backstop for paths that swap the content without pruning `psd_blocks`). +#[test] +fn raster_layer_with_stale_blocks_writes_none() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap(); + let mut doc = reopen(&s.active().unwrap().doc); + let (id, name) = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| kind(l) == "fill").map(|l| (l.id, l.name.clone())).unwrap(); + let fmt = doc.pixel_format(); + let l = doc.layer_mut(id).unwrap(); + assert!(l.psd_blocks.iter().any(|(k, _)| k == b"SoCo")); + l.content = LayerContent::Raster(photocraft_raster::Surface::new(fmt)); + l.psd_blocks.push((*b"TySh", std::sync::Arc::new(vec![0; 8]))); + l.psd_blocks.push((*b"vscg", std::sync::Arc::new(vec![0; 8]))); + l.psd_blocks.push((*b"levl", std::sync::Arc::new(vec![0; 8]))); + assert!(!has_block(&doc, &name, &[b"SoCo", b"TySh", b"vstk", b"vscg", b"SoLd", b"PlLd", b"levl"])); + assert_eq!(kind_named(&reopen(&doc), &name), "raster"); +} + +/// The same through files Photoshop wrote: a gradient fill layer and a placed smart object. +#[cfg(feature = "corpus")] +#[test] +fn photoshop_fill_and_smart_layers_stay_rasterized() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"); + for (file, want) in [("psd-tools/gradient-fill.psd", "fill"), ("photoshop/smart-filters/sf-none-plain.psd", "smart")] { + let path = root.join(file); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display())); + let doc = photocraft_io::import(file, &bytes).unwrap().document; + let names: Vec = doc.walk().into_iter().map(|(_, _, l)| l).filter(|l| kind(l) == want).map(|l| l.name.clone()).collect(); + assert!(!names.is_empty(), "{file}: no {want} layer"); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + let back = rasterize_and_reopen(doc, &refs); + for n in &names { + assert_eq!(kind_named(&back, n), "raster", "{file}: {n}"); + } + } +} diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index e8784e111d..1c43a36457 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -15,6 +15,11 @@ use crate::blocks; use crate::pixels::{deinterleave, encode_be, psd_depth}; use crate::psd_import::REGENERATED; +/// Blocks that define a layer's content as something other than pixels: fill (`SoCo`, `GdFl`, +/// `PtFl`), placed / smart object (`SoLd`, `PlLd`, `SoLE`), type (`TySh`) and shape fill (`vscg`). +/// A raster layer never writes them, nor adjustment blocks. +const RASTER_STALE_KEYS: [&[u8; 4]; 8] = [b"SoCo", b"GdFl", b"PtFl", b"SoLd", b"PlLd", b"SoLE", b"TySh", b"vscg"]; + const PSD_MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; const PSD_ESTIMATE_FIXED_OVERHEAD: u64 = 1024 * 1024; const PSD_ESTIMATE_LAYER_OVERHEAD: u64 = 64 * 1024; @@ -590,7 +595,16 @@ impl Ex { self.smart_blocks(l, sm, parsed, &mut raw); } } - LayerContent::Raster(_) | LayerContent::Group(_) => {} + // A pixel layer carries no content-defining blocks: a fill, placed (smart), type or + // shape-fill block left over from before Rasterize would make a reader (ours or + // Photoshop's) reopen the layer as that kind and discard the pixels. A shape stroke + // (`vstk`) beside a vector mask makes a shape too; without one, Photoshop keeps it on + // pixel layers, so it stays. A vector mask (`vmsk`/`vsms`, `vogk`) stays. + LayerContent::Raster(_) => { + let masked = l.vector_mask.is_some(); + raw.retain(|(k, _)| !RASTER_STALE_KEYS.contains(&k) && !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && !(k == b"vstk" && masked)); + } + LayerContent::Group(_) => {} } // Effects reference point: written from the field (in place, keeping block order). match l.effects.reference { diff --git a/packaging/windows/photocraft.en-us.wxl b/packaging/windows/photocraft.en-us.wxl index 8c14594d13..faa2835abf 100644 --- a/packaging/windows/photocraft.en-us.wxl +++ b/packaging/windows/photocraft.en-us.wxl @@ -7,6 +7,5 @@ - diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index d0b490950f..98dc1fdfd3 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -31,18 +31,6 @@ DowngradeErrorMessage="A newer version of PhotoCraft is already installed." /> - - - - - - From ad3417dc053995f317c27023001e9dcfd593df3e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Mon, 12 Oct 2026 21:18:58 +1100 Subject: [PATCH 065/523] Dismiss Home when the document count changes. (#3984) Chrome stored the document count when Home opened so File New could leave the overlay. Closing a tab that restored that count re-opened Home and hid remaining tabs (#3981). Clear the stored count as soon as it no longer matches, then keep Home dismissed until the user opens it again. --- crates/ui-egui/src/chrome_ui.rs | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome_ui.rs b/crates/ui-egui/src/chrome_ui.rs index 81cf0fce54..e5aefd7b51 100644 --- a/crates/ui-egui/src/chrome_ui.rs +++ b/crates/ui-egui/src/chrome_ui.rs @@ -22,7 +22,14 @@ pub struct ChromeState { impl ChromeState { /// Is the Home screen up, given the current number of open documents? With no documents it /// shows by itself when `auto_show` (Preferences › General › Auto show the Home Screen). - pub fn shows_home(&self, documents: usize, auto_show: bool) -> bool { + /// + /// `home` stores the document count when Home was opened so File › New / Open can leave it. + /// Closing a tab that returns to that count must not bring Home back (#3981): once the + /// document set has changed, the overlay is gone until the user opens Home again. + pub fn shows_home(&mut self, documents: usize, auto_show: bool) -> bool { + if self.home.is_some_and(|n| n != documents) { + self.home = None; + } (documents == 0 && auto_show) || self.home == Some(documents) } } @@ -377,6 +384,30 @@ mod tests { c.home = Some(2); assert!(c.shows_home(2, true)); assert!(!c.shows_home(3, true)); + assert!(c.home.is_none(), "leaving Home by opening a document dismisses it"); + c.home = Some(1); + assert!(!c.shows_home(2, true)); + assert!(!c.shows_home(1, true), "closing back to the stored count must not re-open Home"); + assert!(c.home.is_none()); + } + + #[test] + fn closing_a_tab_does_not_return_to_home() { + use crate::PhotocraftApp; + use photocraft_engine::Session; + use serde_json::json; + + let mut app = PhotocraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8, "name": "Keep"})).unwrap(); + let n = app.session.documents().len(); + app.ui.chrome.home = Some(n); + assert!(app.ui.chrome.shows_home(n, true)); + app.run("file.new", json!({"width": 8, "height": 8, "name": "Other"})).unwrap(); + assert!(!app.ui.chrome.shows_home(app.session.documents().len(), true)); + app.run("file.close", json!({"document": 1, "discard": true})).unwrap(); + assert_eq!(app.session.documents().len(), 1); + assert!(!app.ui.chrome.shows_home(1, true), "Home must stay dismissed after closing the extra tab"); + assert_eq!(app.session.documents()[0].doc.name, "Keep"); } #[test] From db509dc4d984e3843bccc8edf17f52ecf66ec4e5 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:19:13 +0200 Subject: [PATCH 066/523] fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) --- crates/ui-egui/src/canvas.rs | 16 ++++++++++++++++ crates/ui-egui/src/input_tests.rs | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 304f3b2f0e..19f1cbf4c0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3527,6 +3527,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: pipette_cursor(ui.ctx(), p) } } + // The Paint Bucket: a bucket, or the crosshair for Precise Other Cursors (#3968). + Tool::PaintBucket => { + if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise { + egui::CursorIcon::Crosshair + } else { + paint_bucket_cursor(ui.ctx(), p) + } + } // Preferences › Cursors › Other Cursors: Precise shows a crosshair for every tool. Tool::Move | Tool::Type | Tool::VerticalType | Tool::HorizontalTypeMask | Tool::VerticalTypeMask if app.session.prefs().cursors.other == photocraft_engine::prefs::OtherCursor::Precise => @@ -4346,6 +4354,14 @@ pub(crate) fn pipette_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { egui::CursorIcon::None } +/// The Paint Bucket's cursor: a bucket whose pouring corner is the filled pixel. Draws it at `p` +/// and returns the OS cursor to set (hidden). +pub(crate) fn paint_bucket_cursor(ctx: &egui::Context, p: Pos2) -> egui::CursorIcon { + // The left corner of the icon's bucket is at (2, 12) of its 24-unit box. + crate::icons::cursor(ctx, "paint-bucket", p, vec2(2.0, 12.0) / 24.0, 22.0); + egui::CursorIcon::None +} + /// Decided when the press starts, so ⌥ pressed or released mid-stroke never switches between /// painting and sampling. fn alt_eyedropper(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index bb87d477e0..956a9de9ba 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -564,6 +564,22 @@ fn eyedropper_and_alt_sampling_show_a_pipette() { assert_eq!(precise(&mut h), (egui::CursorIcon::Crosshair, desktop)); } +/// The Paint Bucket shows a bucket instead of the crosshair (#3968); Precise Other Cursors keeps +/// the crosshair. +#[test] +fn paint_bucket_shows_a_bucket_cursor() { + let mut h = harness(); + let p = h.state().last_canvas_rect.center(); + h.state_mut().ui.tool = crate::state::Tool::PaintBucket; + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the bucket replaces the pointer"); + h.state_mut().run("prefs.set", json!({"values": {"cursors.other": "precise"}})).unwrap(); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Crosshair, "Precise keeps the crosshair"); +} + /// The Hand tool, and Space held over another tool, show an open hand while hovering and a fist /// while they pan (#2196). The OS draws those everywhere but Windows, where winit maps `Grab` to /// the four-arrow move cursor, so the app hands it a bitmap there (`Crosshair` is the fallback). From 73520f868103c65585ce517f067a550bb2dea8c5 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:19:28 +0700 Subject: [PATCH 067/523] fix(ui): Open layer color choices from the visibility icon (#4001) * fix(ui): offer label colors from the layer eye icon * fix(ui): offer label colors from the layer eye icon * test(ui): cover layer eye color context menu * style(ui): keep extracted color menu indentation consistent --- crates/ui-egui/src/layer_menu_ui.rs | 41 +++++++++++-------- .../ui-egui/src/layer_menu_ui/color_tests.rs | 15 +++++++ crates/ui-egui/src/panels.rs | 28 ++++++++----- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index fa70e436be..6c4c0ec99e 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -212,26 +212,31 @@ fn common_color(app: &crate::PhotocraftApp, l: &Layer, on_set: bool) -> Option) { - let current = common_color(app, l, on_set); ui.menu_button(tl!("Color"), |ui| { - crate::widgets::menu_scroll(ui, |ui| { - ui.set_min_width(170.0); - for color in LabelColor::ALL { - if color == LabelColor::Red { - ui.separator(); - } - if color_button(ui, color, current == Some(color)).clicked() { - let params = if on_set { - json!({"color": color.id()}) - } else { - actions.push(("layer.select".into(), json!({"layer": l.id.0}))); - json!({"layer": l.id.0, "color": color.id()}) - }; - actions.push(("layer.setLabelColor".into(), params)); - ui.close(); - } + color_choices(app, ui, l, on_set, actions); + }); +} + +/// The eye column opens the same color choices directly, without the row's full context menu. +pub(crate) fn color_choices(app: &crate::PhotocraftApp, ui: &mut egui::Ui, l: &Layer, on_set: bool, actions: &mut Vec<(String, Value)>) { + let current = common_color(app, l, on_set); + crate::widgets::menu_scroll(ui, |ui| { + ui.set_min_width(170.0); + for color in LabelColor::ALL { + if color == LabelColor::Red { + ui.separator(); } - }); + if color_button(ui, color, current == Some(color)).clicked() { + let params = if on_set { + json!({"color": color.id()}) + } else { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + json!({"layer": l.id.0, "color": color.id()}) + }; + actions.push(("layer.setLabelColor".into(), params)); + ui.close(); + } + } }); } diff --git a/crates/ui-egui/src/layer_menu_ui/color_tests.rs b/crates/ui-egui/src/layer_menu_ui/color_tests.rs index a2a113a30a..88c11dfd2c 100644 --- a/crates/ui-egui/src/layer_menu_ui/color_tests.rs +++ b/crates/ui-egui/src/layer_menu_ui/color_tests.rs @@ -113,6 +113,21 @@ fn layer_color_background_is_confined_to_the_eye_column_in_every_theme() { } } +#[test] +fn right_clicking_eye_opens_color_choices_without_toggling_visibility() { + let (mut h, red, other) = harness(crate::theme::ThemeKind::Pro, 1.0); + let r = row(&h, red); + let eye = pos2(r.left() + 17.0, r.center().y); + click(&mut h, eye, PointerButton::Secondary, Modifiers::NONE); + let blue = h.get_by_label("Blue").rect().center(); + click(&mut h, blue, PointerButton::Primary, Modifiers::NONE); + + let st = h.state().session.active().unwrap(); + assert!(st.doc.layer(red).unwrap().visible); + assert_eq!(st.doc.layer(red).unwrap().label, LabelColor::Blue); + assert_eq!(st.doc.layer(other).unwrap().label, LabelColor::None); +} + #[test] fn layer_color_menu_marks_only_the_common_color() { let mut h = Harness::new_ui(|ui| { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 696e5b38c0..25f3653b05 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2636,6 +2636,10 @@ fn layer_row( actions.push(("layer.setProps".into(), json!({"layer": l.id.0, "visible": !l.visible}))); } } + eye_resp.context_menu(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); + crate::layer_menu_ui::color_choices(app, ui, l, row.multi && selected, actions); + }); // Everything but the thumbnails, the indentation and the name, so a narrow panel squeezes // the indentation first, then the thumbnails (a layer with two masks has three). let others = 6.0 @@ -2796,17 +2800,19 @@ fn layer_row( if let Some(done) = crate::layer_row_ui::rename_field(ui, l.id.0, edit_rect) { actions.push(done); } - // Right-click context menu. - resp.context_menu(|ui| { - crate::widgets::style_spectrum_popup_menu(ui); - // Right-clicking inside a multi-selection keeps it and acts on every selected layer. - let on_set = row.multi && selected; - if crate::layer_menu_ui::show(app, ui, l, on_set, actions) - && let Some(done) = crate::layer_row_ui::start_rename(ui.ctx(), l.id.0, &l.name) - { - actions.push(done); - } - }); + // The visibility icon has its own color menu; elsewhere use the full layer menu. + if !eye_resp.secondary_clicked() && !eye_resp.context_menu_opened() { + resp.context_menu(|ui| { + crate::widgets::style_spectrum_popup_menu(ui); + // Right-clicking inside a multi-selection keeps it and acts on every selected layer. + let on_set = row.multi && selected; + if crate::layer_menu_ui::show(app, ui, l, on_set, actions) + && let Some(done) = crate::layer_row_ui::start_rename(ui.ctx(), l.id.0, &l.name) + { + actions.push(done); + } + }); + } rect } From 6ca7e60cb02833fc2afb657c5946034783da10bf Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:19:44 +0700 Subject: [PATCH 068/523] fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) --- crates/ui-egui/src/widgets.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8ec6f94a50..5a3def4f2d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1548,6 +1548,7 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; let (rect, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); let p = ui.painter(); if *on { p.rect_filled(rect, 2.0, t.accent); @@ -1560,7 +1561,9 @@ pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { p.rect_filled(rect, 2.0, t.field); p.rect_stroke(rect, 2.0, Stroke::new(1.5, if resp.hovered() { t.text_dim } else { t.text_faint }), StrokeKind::Inside); } - let l = ui.add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).sense(Sense::click())); + let l = ui + .add(egui::Label::new(egui::RichText::new(tl!(label)).color(t.text_dim)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); resp.union(l) }) .inner; @@ -1628,6 +1631,27 @@ fn clean(text: &str) -> String { mod tests { use egui::{Key, Modifiers}; + #[test] + fn checkbox_label_is_a_button_target_not_selectable_text() { + use egui::CursorIcon; + use egui_kittest::{Harness, kittest::Queryable}; + + let mut h = Harness::builder().with_size(egui::vec2(300.0, 80.0)).build_ui_state( + |ui, on: &mut bool| { + super::checkbox(ui, on, "Show Transform Controls"); + }, + false, + ); + h.run_steps(2); + let label = h.get_by_label("Show Transform Controls").rect(); + h.hover_at(label.center()); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, CursorIcon::PointingHand); + h.get_by_label("Show Transform Controls").click(); + h.run_steps(2); + assert!(*h.state()); + } + /// #3208: a rounded swatch's checker is one shared, repeating, unblurred two-by-two tile on /// the rounded rect, scaled so a cell stays `cell` points, so nothing shows past the corners. #[test] From be877c27d0cb9db43a11fad5c74615eec388664c Mon Sep 17 00:00:00 2001 From: McClabe <57244142+rozaqabdul656@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:20:00 +0700 Subject: [PATCH 069/523] fix(ui): keep filter jobs polling on idle Linux (#3961) --- crates/ui-egui/src/jobs_ui.rs | 19 ++++++++++++++++--- crates/ui-egui/src/jobs_ui_tests.rs | 21 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 301460fc5c..f0164c197c 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -368,11 +368,9 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { // Keep checking on a cancelled save's worker until it returns. ctx.request_repaint_after(std::time::Duration::from_millis(50)); } - if !app.session.has_jobs() { + if !keep_polling(app, ctx) { return; } - // Animate progress and poll for results at display rate; the UI thread only draws. - ctx.request_repaint_after(std::time::Duration::from_millis(16)); if let Some(job) = job_in_view(app) && !ctx.text_edit_focused() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) @@ -381,6 +379,21 @@ pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { } } +/// Keep the event loop awake while a background document job exists. +/// +/// Jobs can be started by a menu or dialog while the current frame is being drawn, after +/// [`tick`] has already run. Calling this from the shell after drawing avoids leaving such a job +/// unapplied on an otherwise idle event loop (notably Wayland): its document would remain locked, +/// making Undo and Redo appear unavailable. +pub fn keep_polling(app: &PhotocraftApp, ctx: &egui::Context) -> bool { + if !app.session.has_jobs() { + return false; + } + // Animate progress and poll for results at display rate; the UI thread only draws. + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + true +} + /// The job the user is looking at: the open tab in view, the active document's job, or (for /// the status bar) the newest running job. pub fn job_in_view(app: &PhotocraftApp) -> Option { diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index 1893075ac2..9d5c15506c 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -104,6 +104,27 @@ fn the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it() { assert!(h.query_by_label("Esc to cancel").is_none(), "the dialog closed"); } +#[test] +fn a_job_started_while_drawing_requests_the_follow_up_poll() { + let mut h = app_harness(); + let gate = Arc::new(AtomicBool::new(false)); + let job = slow_job(h.state_mut(), &gate); + let repaints = Arc::new(AtomicUsize::new(0)); + let seen = repaints.clone(); + h.ctx.set_request_repaint_callback(move |_| { + seen.fetch_add(1, Ordering::Relaxed); + }); + + // A job may start from a menu or dialog after the frame's initial `jobs_ui::tick`. + // The UI must still schedule a poll, or an idle Wayland event loop leaves the document + // locked after the worker finishes and blocks Edit > Undo / Redo. + assert!(crate::jobs_ui::keep_polling(h.state(), &h.ctx)); + assert_eq!(repaints.load(Ordering::Relaxed), 1); + + crate::jobs_ui::cancel(h.state_mut(), job); + h.run_steps(2); +} + #[test] fn a_finished_job_lands_as_one_undo_step_and_the_dialog_cancel_button_works() { let mut h = app_harness(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a16dca00d4..4dcc725697 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1732,6 +1732,9 @@ impl eframe::App for PhotocraftApp { let _ = w.send(serde_json::json!({"ok": true, "result": null})); } } + // A menu or dialog can start a filter after `jobs_ui::tick` ran in this frame. Keep + // polling it even when the platform would otherwise put the event loop to sleep. + jobs_ui::keep_polling(self, &ctx); // Menus and buttons in this frame may have asked for a file dialog. self.poll_file_dialog(&ctx, Some(frame)); } From ce16e100b0e0f9c05a36b05c3ac2575ac64436a3 Mon Sep 17 00:00:00 2001 From: zenjabba Date: Mon, 12 Oct 2026 06:20:16 -0400 Subject: [PATCH 070/523] Expose WebP and optional Batch export quality (#3956) Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com> --- crates/ui-egui/src/view_cmds.rs | 21 +- crates/ui-egui/tests/batch_export_options.rs | 327 +++++++++++++++++++ 2 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/batch_export_options.rs diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f5b6f60bdd..f0404633cf 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -882,6 +882,21 @@ pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for k in keys { let v = f.get(&k).cloned().unwrap_or(Value::Null); + if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") { + ui.label(label(&k)); + let mut custom = v.as_f64().is_some(); + let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32; + ui.horizontal(|ui| { + crate::widgets::checkbox(ui, &mut custom, tl!("Custom")); + ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0)); + }); + // None preserves the command's existing JPEG and lossless WebP defaults. + // The inactive custom value is shell state, never a command parameter. + f.insert("__batchQuality".into(), json!(quality)); + f.insert(k, if custom { json!(quality) } else { Value::Null }); + ui.end_row(); + continue; + } let new = match &v { Value::Array(_) | Value::Object(_) | Value::Null => continue, Value::Bool(b) => { @@ -1106,7 +1121,7 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option dialog( app, json!({"input": dir, "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}), - json!({"format": ["jpg", "png", "psd", "tiff"]}), + json!({"format": ["jpg", "png", "psd", "tiff", "webp"]}), ), "file.automate.batch" => { let Some(action) = crate::actions::selected_action(app) else { @@ -1116,8 +1131,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option; + +fn harness() -> Harness { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 760.0)).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.prefs.edit(|p| p.interface.language = "en".into()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app.run("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + app.run("actions.record", json!({"name": "Invert"})).unwrap(); + app.run("image.adjustments.invert", json!({})).unwrap(); + app.run("actions.stop", json!({})).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn open(h: &mut Harness, command: &str) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, command, json!({})).expect("open the real menu dialog"); + h.run_steps(5); +} + +fn evidence(h: &mut Harness, name: &str) { + if let Some(dir) = std::env::var_os("PHOTOCRAFT_BATCH_EVIDENCE_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + h.render().expect("offscreen UI render").save(Path::new(&dir).join(format!("{name}.png"))).unwrap(); + } +} + +fn open_format_choices(h: &mut Harness) { + let label = h.get_by_label("Format").rect(); + // The full app also draws the workspace and layer-blend dropdowns behind this modal. + // Target the actual form field beside Format, like the existing numeric-field harnesses. + h.query_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("format dropdown beside its label") + .click(); + h.run_steps(3); +} + +fn choose_webp(h: &mut Harness) { + open_format_choices(h); + h.query_by_label("WebP").or_else(|| h.query_by_label("Webp")).expect("WebP output choice").click(); + h.run_steps(3); +} + +fn replace_text(h: &mut Harness, value: &str) { + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(value.into())); + h.run_steps(3); +} + +fn edit_path(h: &mut Harness, name: &str, value: &Path) { + let label = h.get_by_label(name).rect(); + h.query_all_by_role(egui::accesskit::Role::TextInput) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("path text field beside its label") + .click(); + h.run_steps(2); + replace_text(h, &value.to_string_lossy()); + h.get_by_label(name).click(); + h.run_steps(2); +} + +fn edit_quality(h: &mut Harness, value: &str) { + let label = h.get_by_label("Quality").rect(); + h.query_all_by_role(egui::accesskit::Role::SpinButton) + .find(|n| { + let r = n.rect(); + r.left() >= label.right() && (r.center().y - label.center().y).abs() < 3.0 + }) + .expect("quality numeric field beside its label") + .click(); + h.run_steps(2); + replace_text(h, value); + h.get_by_label("Quality").click(); + h.run_steps(3); +} + +#[test] +fn batch_menu_offers_webp() { + let _gpu = photocraft_testkit::gpu_lock(); + let mut h = harness(); + open(&mut h, "file.automate.batch"); + open_format_choices(&mut h); + evidence(&mut h, "batch-formats"); + assert!(h.query_by_label("WebP").or_else(|| h.query_by_label("Webp")).is_some(), "Batch must offer the supported WebP output format"); +} + +#[test] +fn batch_menu_offers_quality() { + let _gpu = photocraft_testkit::gpu_lock(); + let mut h = harness(); + open(&mut h, "file.automate.batch"); + evidence(&mut h, "batch-quality"); + assert!(h.query_by_label("Quality").is_some(), "Batch must expose its supported output quality"); +} + +#[test] +fn image_processor_menu_offers_webp() { + let _gpu = photocraft_testkit::gpu_lock(); + let mut h = harness(); + open(&mut h, "file.scripts.imageProcessor"); + open_format_choices(&mut h); + evidence(&mut h, "image-processor-formats"); + assert!(h.query_by_label("WebP").or_else(|| h.query_by_label("Webp")).is_some(), "Image Processor must offer the supported WebP output format"); +} + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("photocraft-batch-options-{}-{now}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn pattern() -> Image { + let mut data = Vec::new(); + for y in 0..48u32 { + for x in 0..64u32 { + data.extend([(x * 13 + y * 3) as u8, (y * 17 + x * 5) as u8, ((x ^ y) * 11) as u8]); + } + } + Image::from_u8(64, 48, ChannelLayout::Rgb, data).unwrap() +} + +#[test] +fn existing_engine_batch_and_image_processor_support_webp_quality() { + let dir = Scratch::new(); + let input = dir.0.join("owned-pattern.png"); + let source = pattern(); + std::fs::write(&input, photocraft_codecs::encode(&source, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let doc = app.session.active().unwrap().doc.clone(); + let revision = app.session.active().unwrap().revision; + let history = app.session.active().unwrap().history.entries(); + let prefs = serde_json::to_value(app.session.prefs()).unwrap(); + + for (command, prefix) in [("file.automate.batch", "batch"), ("file.scripts.imageProcessor", "processor")] { + let expected: Vec = source.data().iter().map(|v| if prefix == "batch" { 255 - v } else { *v }).collect(); + let mut outputs = Vec::new(); + for quality in [Value::Null, json!(0), json!(12)] { + let out = dir.0.join(format!("{prefix}-{quality}")); + std::fs::create_dir(&out).unwrap(); + let result = app + .run(command, json!({"input": [input], "output": out, "steps": [["image.adjustments.invert", {}]], "format": "webp", "quality": quality})) + .unwrap(); + assert_eq!(result["errors"], json!([]), "{command}: {result}"); + let path = result["files"][0].as_str().unwrap(); + assert!(path.ends_with(".webp")); + let bytes = std::fs::read(path).unwrap(); + let decoded = photocraft_codecs::decode_as(Format::WebP, &bytes).unwrap(); + assert_eq!(decoded.dimensions(), (64, 48)); + assert_eq!(decoded.layout(), ChannelLayout::Rgb); + if quality.is_null() && prefix == "batch" { + assert_eq!(decoded.data(), expected, "default Batch WebP is lossless, and the action was applied"); + } + outputs.push(bytes); + } + assert_ne!(outputs[1], outputs[2], "{command}: low and high quality must affect the encoder"); + assert!(outputs[1].len() < outputs[2].len(), "{command}: this synthetic pattern grows at higher quality"); + } + let active = app.session.active().unwrap(); + assert!(Arc::ptr_eq(&active.doc, &doc), "batch commands leave the active document untouched"); + assert_eq!(active.revision, revision); + assert_eq!(active.history.entries(), history); + assert_eq!(serde_json::to_value(app.session.prefs()).unwrap(), prefs); +} + +#[test] +fn batch_custom_quality_is_bounded_dialog_local_and_reversible() { + let _gpu = photocraft_testkit::gpu_lock(); + let mut h = harness(); + open(&mut h, "file.automate.batch"); + assert!(h.state().ui.dialogs.last().unwrap().fields["quality"].is_null(), "default command quality remains unset"); + h.get_by_label("Custom").click(); + h.run_steps(3); + edit_quality(&mut h, "-5"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["quality"].as_f64(), Some(0.0)); + edit_quality(&mut h, "99"); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["quality"].as_f64(), Some(12.0)); + edit_quality(&mut h, "5"); + h.get_by_label("Custom").click(); + h.run_steps(3); + assert!(h.state().ui.dialogs.last().unwrap().fields["quality"].is_null()); + h.get_by_label("Custom").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.last().unwrap().fields["quality"].as_f64(), Some(5.0), "switching defaults does not lose the dialog's custom value"); + h.get_by_label("Cancel").click(); + h.run_steps(3); + open(&mut h, "file.automate.batch"); + assert!(h.state().ui.dialogs.last().unwrap().fields["quality"].is_null(), "reopening returns to the existing default"); +} + +#[test] +fn batch_and_processor_real_controls_write_the_chosen_webp_quality() { + let _gpu = photocraft_testkit::gpu_lock(); + let dir = Scratch::new(); + let input = dir.0.join("input"); + std::fs::create_dir(&input).unwrap(); + let original = pattern(); + std::fs::write(input.join("owned-pattern.png"), photocraft_codecs::encode(&original, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + let mut h = harness(); + let doc = h.state().session.active().unwrap().doc.clone(); + let revision = h.state().session.active().unwrap().revision; + let history = h.state().session.active().unwrap().history.entries(); + let prefs = serde_json::to_value(h.state().session.prefs()).unwrap(); + let actions = serde_json::to_value(&h.state().session.actions.list).unwrap(); + for (command, prefix) in [("file.automate.batch", "batch"), ("file.scripts.imageProcessor", "processor")] { + let mut outputs = Vec::new(); + for quality in [0, 12] { + let output = dir.0.join(format!("{prefix}-{quality}")); + open(&mut h, command); + if prefix == "batch" { + assert!(h.state().ui.dialogs.last().unwrap().fields["quality"].is_null()); + h.get_by_label("Custom").click(); + h.run_steps(3); + } else { + let fields = &h.state().ui.dialogs.last().unwrap().fields; + assert_eq!(fields["quality"], json!(8)); + assert_eq!(fields["width"], json!(0)); + assert_eq!(fields["height"], json!(0)); + assert_eq!(fields["convertToSrgb"], json!(true)); + } + choose_webp(&mut h); + edit_path(&mut h, "Input", &input); + edit_path(&mut h, "Output", &output); + edit_quality(&mut h, &quality.to_string()); + evidence(&mut h, &format!("{prefix}-webp-quality-{quality}")); + h.get_by_label("OK").click(); + h.run_steps(5); + assert!(h.state().ui.dialogs.is_empty()); + let bytes = std::fs::read(output.join("owned-pattern.webp")).expect("the actual dialog confirmation writes the selected WebP"); + let decoded = photocraft_codecs::decode_as(Format::WebP, &bytes).unwrap(); + assert_eq!(decoded.dimensions(), (64, 48)); + assert_eq!(decoded.layout(), ChannelLayout::Rgb); + let (_, params) = h.state().session.journal.last().unwrap(); + assert_eq!(params["format"], json!("webp")); + assert_eq!(params["quality"].as_f64(), Some(f64::from(quality))); + assert!(params.as_object().unwrap().keys().all(|k| !k.starts_with("__")), "private shell values never become command params"); + outputs.push(bytes); + } + assert_ne!(outputs[0], outputs[1], "{command}: actual chosen quality reaches the encoder"); + assert!(outputs[0].len() < outputs[1].len()); + } + let active = h.state().session.active().unwrap(); + assert!(Arc::ptr_eq(&active.doc, &doc)); + assert_eq!((active.revision, active.history.entries()), (revision, history)); + assert_eq!(serde_json::to_value(h.state().session.prefs()).unwrap(), prefs); + assert_eq!(serde_json::to_value(&h.state().session.actions.list).unwrap(), actions); +} + +#[test] +fn batch_default_quality_keeps_lossless_webp_and_existing_jpeg_bytes() { + let _gpu = photocraft_testkit::gpu_lock(); + let dir = Scratch::new(); + let input = dir.0.join("input"); + std::fs::create_dir(&input).unwrap(); + let original = pattern(); + std::fs::write(input.join("owned-pattern.png"), photocraft_codecs::encode(&original, Format::Png, &EncodeOptions::default()).unwrap()).unwrap(); + let mut h = harness(); + for format in ["webp", "jpg"] { + let output = dir.0.join(format!("ui-default-{format}")); + let reference = dir.0.join(format!("engine-existing-default-{format}")); + open(&mut h, "file.automate.batch"); + if format == "webp" { + choose_webp(&mut h); + } else { + open_format_choices(&mut h); + h.get_by_label("Jpg").click(); + h.run_steps(3); + } + edit_path(&mut h, "Input", &input); + edit_path(&mut h, "Output", &output); + assert!(h.state().ui.dialogs.last().unwrap().fields["quality"].is_null()); + evidence(&mut h, &format!("batch-default-{format}")); + h.get_by_label("OK").click(); + h.run_steps(5); + let result = std::fs::read(output.join(format!("owned-pattern.{format}"))).unwrap(); + let mut session = photocraft_engine::Session::new(); + let r = session + .execute("file.automate.batch", json!({"input": input, "output": reference, "steps": [["image.adjustments.invert", {}]], "format": format})) + .unwrap(); + assert_eq!(r["errors"], json!([])); + assert_eq!( + result, + std::fs::read(reference.join(format!("owned-pattern.{format}"))).unwrap(), + "{format}: UI default equals the command's existing unset quality" + ); + if format == "webp" { + let decoded = photocraft_codecs::decode_as(Format::WebP, &result).unwrap(); + let expected: Vec = original.data().iter().map(|v| 255 - v).collect(); + assert_eq!(decoded.data(), expected, "default WebP remains lossless after the recorded action"); + } + } +} From 27f56eb76258a7f69ad97ff648618b4ec713f6d4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 06:20:44 -0400 Subject: [PATCH 071/523] feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038) In Lab documents Photoshop evaluates these adjustments on the Lab channels, not on the screen RGB colour: - Invert and Posterize act on L, a and b alike (normalised L/100, (a+128)/255, (b+128)/255; Invert is n -> 1 - n). - Brightness/Contrast (modern and legacy) applies its tone curve to L only; a and b are unchanged. - Threshold splits on L alone and outputs neutral black or white. - Gradient Map is driven by L (Reverse uses 1 - L). Adjustment layers (CPU compositor) and destructive Image > Adjustments both go through the new `adjust::apply_in`; the GPU compositor defers these adjustments to the CPU in Lab documents. Levels and Curves keep their Lab records; other adjustments stay on the screen colour. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 206 ++++++++++++++++++++++++++++-- crates/compose/src/lib.rs | 4 +- crates/compose/src/tests.rs | 115 +++++++++++++++++ crates/engine/src/pixels.rs | 2 +- crates/engine/src/pixels_tests.rs | 18 +++ crates/gpu/src/plan.rs | 34 ++++- 6 files changed, 363 insertions(+), 16 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 0f26c61327..357d8ac0d8 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -3,8 +3,8 @@ //! Formulas are documented approximations of Photoshop behaviour. Exact matching is tuned in //! milestone M7 against Photoshop-rendered PSD composites (the oracle in `testkit`). -use photocraft_color::SampleType; -use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, mat3_mul, rgb_to_gray, srgb_to_linear}; +use photocraft_color::convert::{D50, SRGB_TO_XYZ_D50, XYZ_D50_TO_SRGB, lab_to_srgb, mat3_mul, rgb_to_gray, srgb_to_lab, srgb_to_linear}; +use photocraft_color::{ColorMode, SampleType}; use photocraft_doc::Adjustment; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace, lut3d_len}; @@ -92,11 +92,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth // 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while // the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first; // contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130). - let b = brightness / 255.0; - let c = contrast.clamp(-100.0, 99.0); - let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; - let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; - map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0))) + let f = legacy_brightness_contrast(*brightness, *contrast); + map_rgb(buf, |px| px.map(&f)) } Adjustment::BrightnessContrast { brightness, contrast, .. } => { // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth @@ -236,6 +233,99 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } } +/// Legacy Brightness/Contrast as one channel's tone curve (see [`apply_depth`]). +fn legacy_brightness_contrast(brightness: f32, contrast: f32) -> impl Fn(f32) -> f32 { + let b = brightness / 255.0; + let c = contrast.clamp(-100.0, 99.0); + let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 }; + let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) }; + move |v: f32| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0) +} + +/// Applies an adjustment in a document of colour `mode`: [`apply_depth`], except that in Lab +/// documents the adjustments Photoshop evaluates on the L, a and b channels do so (see +/// [`lab_channel_adjustment`]). +pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option, mode: ColorMode) { + if mode == ColorMode::Lab && lab_channel_adjustment(adj) { + apply_lab(adj, buf); + } else { + apply_depth(adj, buf, transfer, depth); + } +} + +/// Whether a Lab document evaluates `adj` on its L, a and b channels rather than on the screen +/// colour: Invert and Posterize (L, a and b alike), Brightness/Contrast (L only), Threshold +/// (split on L, neutral result) and Gradient Map (driven by L). Levels and Curves carry their own +/// Lab records ([`ToneSpace::Lab`]); everything else stays on the screen colour. +pub fn lab_channel_adjustment(adj: &Adjustment) -> bool { + matches!( + adj, + Adjustment::Invert + | Adjustment::Posterize { .. } + | Adjustment::Threshold { .. } + | Adjustment::BrightnessContrast { .. } + | Adjustment::GradientMap { .. } + ) +} + +/// Encoded sRGB → Lab normalised the way Photoshop's channels hold it: L/100, (a+128)/255, +/// (b+128)/255. +fn lab_norm(c: [f32; 3]) -> [f32; 3] { + let l = srgb_to_lab(c); + [l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0] +} + +/// The inverse of [`lab_norm`]. +fn lab_denorm(n: [f32; 3]) -> [f32; 3] { + lab_to_srgb([n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]) +} + +/// The Lab evaluation of a [`lab_channel_adjustment`] (no-op for any other adjustment). +fn apply_lab(adj: &Adjustment, buf: &mut Buffer) { + match adj { + // n → 1 - n on every channel: L → 100 - L and a, b mirrored about neutral. + Adjustment::Invert => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| 1.0 - v.clamp(0.0, 1.0)))), + Adjustment::Posterize { levels } => map_rgb(buf, |c| lab_denorm(lab_norm(c).map(|v| posterize(v, *levels)))), + Adjustment::Threshold { level } => { + // Split on L alone (on the 8-bit grid); the result is neutral black or white. + let t = (level * 255.0).round(); + map_rgb(buf, |c| [if (lab_norm(c)[0].clamp(0.0, 1.0) * 255.0).round() >= t { 1.0 } else { 0.0 }; 3]) + } + Adjustment::BrightnessContrast { brightness, contrast, legacy } => { + let legacy_curve = legacy_brightness_contrast(*brightness, *contrast); + let curve = |v: f32| if *legacy { legacy_curve(v) } else { modern_contrast(modern_brightness(v, *brightness), *contrast).clamp(0.0, 1.0) }; + // The tone curve on L only. Added as the difference of two round trips (like + // `tone_in_space`) so a and b, and colours a round trip moves, stay put. + map_rgb(buf, |c| { + let n = lab_norm(c); + let l = curve(n[0]); + if l == n[0] { + return c; + } + let (before, after) = (lab_denorm(n), lab_denorm([l, n[1], n[2]])); + std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0)) + }) + } + Adjustment::GradientMap { stops, reverse, dither } => { + let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0); + for (i, p) in buf.px.iter_mut().enumerate() { + if p[3] <= 0.0 { + continue; + } + // Driven by L: equal lightness maps to the same stop colour whatever the chroma. + let l = lab_norm([p[0], p[1], p[2]])[0].clamp(0.0, 1.0); + let mut o = gradient(stops, if *reverse { 1.0 - l } else { l }); + if *dither { + let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0; + o = o.map(|v| (v + d).clamp(0.0, 1.0)); + } + p[..3].copy_from_slice(&o); + } + } + _ => {} + } +} + /// Float RGB curve channels with no edit must preserve samples outside the LUT domain. /// Edited curves retain the existing spline/LUT behaviour until HDR extrapolation has an oracle. pub fn float_tone_identity(adj: &Adjustment, depth: Option) -> [bool; 3] { @@ -293,7 +383,7 @@ pub fn tone_luts_depth(adj: &Adjustment, depth: Option) -> [Vec /// through the conversions the document's surfaces use. The change is added as a difference of /// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact. fn tone_in_space(space: ToneSpace, luts: &[Vec; 4], c: [f32; 3]) -> [f32; 3] { - use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab}; + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk}; let (before, after) = match space { ToneSpace::Cmyk => { let ink = rgb_to_cmyk(c); @@ -1214,6 +1304,106 @@ mod tone_tests { assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6)); assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0])); } + + /// `adj` applied in a Lab document to one encoded sRGB colour, returned as (L, a, b). + fn in_lab(adj: &Adjustment, c: [f32; 3]) -> [f32; 3] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply_in(adj, &mut b, Transfer::Srgb, Some(SampleType::U8), ColorMode::Lab); + srgb_to_lab([b.px[0][0], b.px[0][1], b.px[0][2]]) + } + + /// A muted, in-gamut colour from (L, a, b). + fn lab(l: f32, a: f32, b: f32) -> [f32; 3] { + lab_to_srgb([l, a, b]) + } + + fn near(got: [f32; 3], want: [f32; 3], tol: f32) -> bool { + got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol) + } + + #[test] + fn lab_invert_flips_l_a_and_b() { + let got = in_lab(&Adjustment::Invert, lab(30.0, 12.0, -18.0)); + // n -> 1 - n: L -> 100 - L, a -> -a - 1, b -> -b - 1 (mirrored on the 0..255 grid). + assert!(near(got, [70.0, -13.0, 17.0], 0.6), "{got:?}"); + // On the screen colour it would not be a mirror in Lab. + let mut rgb = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 1.0]); + let c = lab(30.0, 12.0, -18.0); + rgb.px[0] = [c[0], c[1], c[2], 1.0]; + apply(&Adjustment::Invert, &mut rgb); + assert!(!near(srgb_to_lab([rgb.px[0][0], rgb.px[0][1], rgb.px[0][2]]), got, 1.0)); + } + + #[test] + fn lab_posterize_steps_l_a_and_b() { + let got = in_lab(&Adjustment::Posterize { levels: 2 }, lab(70.0, 20.0, -30.0)); + // Two levels: each normalised channel snaps to 0 or 1 (L 100, a and b at +127 / -128). + assert!(near(got, srgb_to_lab(lab_denorm([1.0, 1.0, 0.0])), 1e-3), "{got:?}"); + let grey = in_lab(&Adjustment::Posterize { levels: 255 }, lab(50.0, 0.0, 0.0)); + assert!(near(grey, [50.0, 0.0, 0.0], 1.1), "255 levels is within a step of the identity: {grey:?}"); + } + + #[test] + fn lab_threshold_splits_on_l_with_a_neutral_result() { + let t = Adjustment::Threshold { level: 0.5 }; + // A saturated blue (L ≈ 32) and yellow (L ≈ 97): black and white, nothing in between. + for (c, white) in [([0.0, 0.0, 1.0], false), ([1.0, 1.0, 0.0], true), (lab(52.0, 40.0, 30.0), true), (lab(48.0, -40.0, 30.0), false)] { + let mut b = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [c[0], c[1], c[2], 1.0]); + apply_in(&t, &mut b, Transfer::Srgb, None, ColorMode::Lab); + let v = if white { 1.0 } else { 0.0 }; + assert_eq!(b.px[0], [v, v, v, 1.0], "{c:?}"); + } + } + + #[test] + fn lab_brightness_contrast_moves_l_only() { + for legacy in [false, true] { + let adj = Adjustment::BrightnessContrast { brightness: 40.0, contrast: 30.0, legacy }; + let before = [45.0, 15.0, -20.0]; + let got = in_lab(&adj, lab(before[0], before[1], before[2])); + assert!(got[0] > before[0] + 5.0, "{legacy}: L rises: {got:?}"); + assert!((got[1] - before[1]).abs() < 0.5 && (got[2] - before[2]).abs() < 0.5, "{legacy}: a, b kept: {got:?}"); + } + } + + #[test] + fn lab_gradient_map_follows_l() { + let stops = vec![(0.0, [1.0, 0.0, 0.0]), (1.0, [0.0, 0.0, 1.0])]; + for reverse in [false, true] { + let map = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false }; + // A grey and a saturated colour with the same L map to the same colour. + let grey = in_lab(&map, lab(60.0, 0.0, 0.0)); + let green = in_lab(&map, lab(60.0, -50.0, 40.0)); + assert!(near(grey, green, 1e-3), "{reverse}: {grey:?} vs {green:?}"); + let t = if reverse { 0.4 } else { 0.6 }; + let want = srgb_to_lab([1.0 - t, 0.0, t]); + assert!(near(grey, want, 0.05), "{reverse}: {grey:?} vs {want:?}"); + } + } + + #[test] + fn other_modes_and_adjustments_keep_the_screen_colour_path() { + let c = [0.2, 0.5, 0.7, 1.0]; + let lab_only = [Adjustment::Invert, Adjustment::Threshold { level: 0.47 }, Adjustment::Posterize { levels: 4 }]; + for adj in + lab_only.iter().chain([&Adjustment::HueSaturation { hue: 30.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }]) + { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + let (mut a, mut b) = (Buffer::filled(crate::Rect::new(0, 0, 1, 1), c), Buffer::filled(crate::Rect::new(0, 0, 1, 1), c)); + apply_in(adj, &mut a, Transfer::Srgb, None, mode); + apply_depth(adj, &mut b, Transfer::Srgb, None); + assert_eq!(a.px == b.px, mode != ColorMode::Lab || !lab_channel_adjustment(adj), "{adj:?} in {mode:?}"); + } + } + // Transparent pixels are left alone and odd values never panic. + let mut t = Buffer::filled(crate::Rect::new(0, 0, 2, 1), [f32::NAN, 2.0, -1.0, 0.0]); + for adj in lab_only.iter().chain([&Adjustment::GradientMap { stops: vec![], reverse: true, dither: true }]) { + apply_in(adj, &mut t, Transfer::Srgb, None, ColorMode::Lab); + let mut odd = Buffer::filled(crate::Rect::new(0, 0, 1, 1), [f32::NAN, f32::INFINITY, -3.0, 1.0]); + apply_in(adj, &mut odd, Transfer::Srgb, None, ColorMode::Lab); + } + assert!(t.px[0][1] == 2.0); + } } /// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128, diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 37c6167e99..a7c0576103 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1248,7 +1248,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer // Adjustment layers transform the backdrop, then blend the result back in. if let LayerContent::Adjustment(adj) = &layer.content { let mut adjusted = backdrop.clone(); - adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth)); + adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode); // Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result. advanced::composite_clipped(clipped, &mut adjusted, cx); let mv = mask_vals(layer, rect, cx); @@ -1589,7 +1589,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) { let rect = base.rect; if let LayerContent::Adjustment(adj) = &layer.content { let mut adjusted = base.clone(); - adjust::apply_depth(adj, &mut adjusted, cx.transfer, Some(cx.depth)); + adjust::apply_in(adj, &mut adjusted, cx.transfer, Some(cx.depth), cx.mode); let mv = mask_vals(layer, rect, cx); for (i, p) in base.px.iter_mut().enumerate() { let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 853767ae81..c56b7de49d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2203,3 +2203,118 @@ fn outer_glow_does_not_paint_through_a_zero_fill_layer() { assert!(opaque[0] > 0.9 && opaque[1] > 0.8, "{technique:?}: opaque fill disappeared: {opaque:?}"); } } + +// Lab documents: Invert mirrors L, a and b; Threshold splits on L with a neutral result; Gradient +// Map follows L. Rendered through the document compositor at every depth. +#[test] +fn lab_documents_evaluate_invert_threshold_and_gradient_map_on_lab() { + use photocraft_color::convert::{lab_to_srgb, srgb_to_lab}; + let c = lab_to_srgb([30.0, 12.0, -18.0]); + for depth in SampleType::ALL { + let render = |adj: Adjustment| { + let mut d = Document::with_background("t", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(c[0], c[1], c[2])); + d.layers.push(Layer::new("adj", LayerContent::Adjustment(adj))); + let p = flatten(&d).px[5]; + srgb_to_lab([p[0], p[1], p[2]]) + }; + let near = |got: [f32; 3], want: [f32; 3], tol: f32| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol); + let inv = render(Adjustment::Invert); + assert!(near(inv, [70.0, -13.0, 17.0], 1.0), "{depth:?}: {inv:?}"); + let th = render(Adjustment::Threshold { level: 0.25 }); + assert!(near(th, [100.0, 0.0, 0.0], 0.5), "{depth:?}: L 30 is above level 64: {th:?}"); + let gm = render(Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false }); + let want = srgb_to_lab([0.3; 3]); + assert!(near(gm, want, 1.0), "{depth:?}: L 30 → 30 % of the way: {gm:?} vs {want:?}"); + } +} + +/// `adj` over a one-colour Lab document (background `rgb`) at `depth`, read back as Lab. +fn lab_doc_adjusted(rgb: [f32; 3], adj: Adjustment, depth: SampleType) -> [f32; 3] { + let mut doc = Document::with_background("lab", Size::new(4, 4), ColorMode::Lab, depth, Color::rgb(rgb[0], rgb[1], rgb[2])); + doc.layers.push(Layer::new("adj", LayerContent::Adjustment(adj))); + let p = px(&doc, 1, 1); + photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]]) +} + +fn near3(got: [f32; 3], want: [f32; 3], tol: f32, what: &str) { + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= tol), "{what}: got {got:?}, want {want:?}"); +} + +/// A muted, in-gamut colour (L ≈ 46, a ≈ 10, b ≈ -20) and its Lab coordinates. +const MUTED: [f32; 3] = [0.40, 0.42, 0.60]; + +#[test] +fn lab_documents_invert_and_posterize_l_a_and_b() { + let lab = photocraft_color::convert::srgb_to_lab(MUTED); + for depth in SampleType::ALL { + // Invert: every normalised channel n -> 1 - n, so L -> 100 - L and a, b -> their opposites + // (-a - 1 on the 0..255 grid). + let inv = lab_doc_adjusted(MUTED, Adjustment::Invert, depth); + near3(inv, [100.0 - lab[0], -lab[1] - 1.0, -lab[2] - 1.0], 1.0, &format!("{depth:?} invert")); + // An RGB invert would land elsewhere: lightness 100 - L is the Lab rule. + let rgb_inv = photocraft_color::convert::srgb_to_lab(MUTED.map(|v| 1.0 - v)); + assert!((rgb_inv[0] - inv[0]).abs() > 2.0, "{depth:?}: {rgb_inv:?} vs {inv:?}"); + // Posterize, three levels: the same steps on L, a and b (here all land on the middle + // step: L ≈ 50 and a, b ≈ -1, a neutral grey). + let post = lab_doc_adjusted(MUTED, Adjustment::Posterize { levels: 3 }, depth); + let n = [lab[0] / 100.0, (lab[1] + 128.0) / 255.0, (lab[2] + 128.0) / 255.0].map(|v| adjust::posterize(v, 3)); + let want = [n[0] * 100.0, n[1] * 255.0 - 128.0, n[2] * 255.0 - 128.0]; + assert!(want[0] > 45.0 && want[1].abs() < 2.0 && want[2].abs() < 2.0, "{want:?}"); + near3(post, want, 1.0, &format!("{depth:?} posterize")); + } +} + +#[test] +fn lab_documents_brightness_contrast_moves_lightness_only() { + let lab = photocraft_color::convert::srgb_to_lab(MUTED); + for depth in SampleType::ALL { + for legacy in [true, false] { + let got = lab_doc_adjusted(MUTED, Adjustment::BrightnessContrast { brightness: 40.0, contrast: 20.0, legacy }, depth); + assert!(got[0] > lab[0] + 5.0, "{depth:?} legacy {legacy}: {got:?}"); + near3([lab[0], got[1], got[2]], lab, 0.75, &format!("{depth:?} legacy {legacy}: a, b kept")); + } + } +} + +#[test] +fn lab_documents_threshold_on_lightness_with_a_neutral_result() { + for depth in SampleType::ALL { + // A saturated yellow is light (L ≈ 97): white, not a coloured result. + let yellow = lab_doc_adjusted([1.0, 1.0, 0.0], Adjustment::Threshold { level: 0.5 }, depth); + near3(yellow, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} yellow")); + // Pure blue has L ≈ 30: black. + let blue = lab_doc_adjusted([0.0, 0.0, 1.0], Adjustment::Threshold { level: 0.5 }, depth); + near3(blue, [0.0, 0.0, 0.0], 0.5, &format!("{depth:?} blue")); + // Mid grey L 53.4 against level 128 (n 0.502): Lab L decides, not RGB luminance. + let level = 0.52; + let grey = lab_doc_adjusted([0.5; 3], Adjustment::Threshold { level }, depth); + near3(grey, [100.0, 0.0, 0.0], 0.5, &format!("{depth:?} grey")); + } +} + +#[test] +fn lab_documents_gradient_map_follows_lightness() { + let stops = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])]; + // A grey and a saturated colour of the same L map to the same colour. + let red = [0.9, 0.1, 0.1]; + let l = photocraft_color::convert::srgb_to_lab(red)[0]; + let grey = photocraft_color::convert::lab_to_srgb([l, 0.0, 0.0]); + for depth in SampleType::ALL { + for reverse in [false, true] { + let adj = Adjustment::GradientMap { stops: stops.clone(), reverse, dither: false }; + let a = lab_doc_adjusted(red, adj.clone(), depth); + let b = lab_doc_adjusted(grey, adj.clone(), depth); + near3(a, b, 1.0, &format!("{depth:?} reverse {reverse}")); + let t = if reverse { 1.0 - l / 100.0 } else { l / 100.0 }; + let want = photocraft_color::convert::srgb_to_lab([t, t, 1.0 - t]); + near3(a, want, 1.5, &format!("{depth:?} reverse {reverse}")); + } + } + // In RGB documents the map still follows RGB luminance: red and grey differ there. + let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [red[0], red[1], red[2], 1.0]); + let adj = Adjustment::GradientMap { stops, reverse: false, dither: false }; + adjust::apply_in(&adj, &mut buf, adjust::Transfer::Srgb, None, ColorMode::Rgb); + let mut grey_buf = Buffer::filled(Rect::new(0, 0, 1, 1), [grey[0], grey[1], grey[2], 1.0]); + adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb); + assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); +} diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 323d6ccdad..2ba5b5dcfd 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -116,7 +116,7 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, // 32-bit documents get the float behaviour of an adjustment layer there (Levels doesn't clip); // integer depths keep the unrounded, clipped curves. let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); - adjust::apply_depth(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth); + adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); let w = r.width() as usize; let mut out = Vec::with_capacity(raw.len()); for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index 0da4880106..c0dc368e40 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -241,3 +241,21 @@ fn benchmark_clear_large_selection() { println!("#3463 clear 8000x6000, background {background}, feather {feather}: {:?}", t.elapsed()); } } + +/// Image › Adjustments › Invert in a Lab document mirrors L, a and b, at every depth. +#[test] +fn invert_in_a_lab_document_mirrors_l_a_and_b() { + use photocraft_color::convert::{lab_to_srgb, srgb_to_lab}; + let c = lab_to_srgb([30.0, 12.0, -18.0]); + for depth in SampleType::ALL { + let fmt = PixelFormat { mode: ColorMode::Lab, sample: depth, alpha: true }; + let mut s = Surface::new(fmt); + let r = Rect::new(0, 0, 2, 2); + s.write_region(r, &(0..4).flat_map(|_| from_rgba(&fmt, [c[0], c[1], c[2], 1.0])).collect::>()); + adjust_surface(&mut s, &Adjustment::Invert, None, ColorMode::Lab); + let px = s.read_region(Rect::new(1, 1, 2, 2)); + let rgba = photocraft_raster::to_rgba(&fmt, &px); + let got = srgb_to_lab([rgba[0], rgba[1], rgba[2]]); + assert!(got.iter().zip([70.0, -13.0, 17.0]).all(|(g, w)| (g - w).abs() < 1.0), "{depth:?}: {got:?}"); + } +} diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index a5218f826f..f5a3b573e4 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -934,7 +934,7 @@ impl<'a> Planner<'a> { /// adjust::apply_with on a slot (consumes it). fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Result { - if !adjustment_on_gpu(adj) { + if !adjustment_on_gpu(adj, self.cx.mode) { return Err(Unsupported(format!("{} on CMYK/Lab channels (evaluated on the CPU)", adj.label()))); } if let Adjustment::GradientMap { stops, .. } = adj { @@ -1512,10 +1512,15 @@ fn to_row(t: &[f32]) -> [f32; 4096] { row } -/// Whether the adjustment kernel can evaluate `adj`: Levels and Curves on CMYK ink or Lab -/// channels convert through ICC profiles per pixel, which only the CPU does. -pub fn adjustment_on_gpu(adj: &Adjustment) -> bool { - !matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. }) +/// Whether the adjustment kernel can evaluate `adj` in a document of colour `mode`: Levels and +/// Curves on CMYK ink or Lab channels convert through ICC profiles per pixel, and so do the +/// adjustments a Lab document evaluates on L, a and b (`photocraft_compose::adjust::lab_channel_adjustment`), +/// which only the CPU does. +pub fn adjustment_on_gpu(adj: &Adjustment, mode: photocraft_color::ColorMode) -> bool { + let tone_in_space = + matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. }); + let lab_channels = mode == photocraft_color::ColorMode::Lab && photocraft_compose::adjust::lab_channel_adjustment(adj); + !tone_in_space && !lab_channels } /// A tone transfer as the shader's `t_decode`/`t_encode` exponent (0: the sRGB curve). @@ -1731,6 +1736,25 @@ mod tests { use photocraft_color::{Color, ColorMode, SampleType}; use photocraft_geom::Size; + #[test] + fn lab_channel_adjustments_defer_to_the_cpu_in_lab_documents() { + let lab_rules = [ + Adjustment::Invert, + Adjustment::Posterize { levels: 4 }, + Adjustment::Threshold { level: 0.5 }, + Adjustment::BrightnessContrast { brightness: 10.0, contrast: 5.0, legacy: false }, + Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false, dither: false }, + ]; + for adj in &lab_rules { + assert!(!adjustment_on_gpu(adj, ColorMode::Lab), "{adj:?}"); + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale] { + assert!(adjustment_on_gpu(adj, mode), "{adj:?} in {mode:?}"); + } + } + // Screen-colour adjustments stay on the GPU in Lab documents. + assert!(adjustment_on_gpu(&Adjustment::Exposure { exposure: 1.0, offset: 0.0, gamma: 1.0 }, ColorMode::Lab)); + } + #[test] fn native_cmyk_fills_fall_back_only_without_a_valid_cache() { use photocraft_doc::{FillCache, GradientStyle}; From 04396dd85f7e234702cd09ef48b0e8b2f58b7aed Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 12:27:57 +0200 Subject: [PATCH 072/523] PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651) Co-authored-by: Claude Opus 5.5 --- crates/psd/src/pixels.rs | 81 +++++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index 013d82176b..d4948457f9 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -31,35 +31,58 @@ pub fn f32_to_bytes(samples: &[f32]) -> Vec { samples.iter().flat_map(|s| s.to_be_bytes()).collect() } +/// Allocates `len` bytes, reporting a machine without that much memory instead of aborting. +fn try_buffer(len: usize) -> Result> { + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| PsdError::LimitExceeded("not enough memory for the converted plane"))?; + Ok(out) +} + /// Expands a 1-bit plane (MSB first, rows padded to bytes) to one byte per -/// pixel (0 or 1). -pub fn unpack_bits(bytes: &[u8], width: usize, height: usize) -> Vec { +/// pixel (0 or 1). Errors with [`PsdError::LimitExceeded`] when the plane +/// size overflows or cannot be allocated. +pub fn unpack_bits(bytes: &[u8], width: usize, height: usize) -> Result> { + unpack_bits_with(bytes, width, height, |bit| bit) +} + +/// Shared 1-bit expansion; `map` turns each bit (0 or 1) into the output byte. +fn unpack_bits_with(bytes: &[u8], width: usize, height: usize, map: impl Fn(u8) -> u8) -> Result> { + let len = width.checked_mul(height).ok_or(PsdError::LimitExceeded("plane size overflow"))?; let rb = width.div_ceil(8); - let mut out = Vec::with_capacity(width * height); + let mut out = try_buffer(len)?; for y in 0..height { for x in 0..width { - let b = bytes.get(y * rb + x / 8).copied().unwrap_or(0); - out.push((b >> (7 - (x % 8))) & 1); + let b = y.checked_mul(rb).and_then(|row| bytes.get(row + x / 8)).copied().unwrap_or(0); + out.push(map((b >> (7 - (x % 8))) & 1)); } } - out + Ok(out) } /// Converts one plane of samples at `depth` to 8-bit. pub fn plane_to_u8(bytes: &[u8], depth: u16, width: usize, height: usize) -> Result> { Ok(match depth { - 1 => unpack_bits(bytes, width, height).into_iter().map(|b| if b == 1 { 0 } else { 255 }).collect(), - 8 => bytes.to_vec(), - 16 => bytes.as_chunks::<2>().0.iter().map(|b| ((u32::from(u16::from_be_bytes(*b)) * 255 + 32767) / 65535) as u8).collect(), - 32 => bytes - .as_chunks::<4>() - .0 - .iter() - .map(|b| { + 1 => unpack_bits_with(bytes, width, height, |b| if b == 1 { 0 } else { 255 })?, + 8 => { + let mut out = try_buffer(bytes.len())?; + out.extend_from_slice(bytes); + out + } + 16 => { + let chunks = bytes.as_chunks::<2>().0; + let mut out = try_buffer(chunks.len())?; + out.extend(chunks.iter().map(|b| ((u32::from(u16::from_be_bytes(*b)) * 255 + 32767) / 65535) as u8)); + out + } + 32 => { + let chunks = bytes.as_chunks::<4>().0; + let mut out = try_buffer(chunks.len())?; + out.extend(chunks.iter().map(|b| { let v = f32::from_be_bytes(*b); if v.is_nan() { 0 } else { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } - }) - .collect(), + })); + out + } d => return Err(PsdError::Unsupported(format!("depth {d}"))), }) } @@ -282,6 +305,10 @@ impl PsdFile { h.validate()?; let (w, hh) = (h.width as usize, h.height as usize); let n = w.checked_mul(hh).ok_or(PsdError::LimitExceeded("composite size overflow"))?; + // Same budget as `Layer::rgba8`: refuse before decoding or converting any plane. + if n.checked_mul(4).is_none_or(|len| len as u64 > crate::compression::MAX_DECODED_BYTES) { + return Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")); + } let cc = color_channel_count(h.color_mode)?; if usize::from(h.channels) < cc { return Err(PsdError::invalid("fewer channels than the color mode requires")); @@ -358,7 +385,7 @@ mod tests { #[test] fn unpack_bits_rows() { - assert_eq!(unpack_bits(&[0b1010_0000, 0b1000_0000], 3, 2), vec![1, 0, 1, 1, 0, 0]); + assert_eq!(unpack_bits(&[0b1010_0000, 0b1000_0000], 3, 2).unwrap(), vec![1, 0, 1, 1, 0, 0]); } #[test] @@ -369,6 +396,26 @@ mod tests { assert!(plane_to_u8(&[], 12, 0, 0).is_err()); } + /// A plane size that overflows or cannot be allocated is an error, not a panic or abort. + #[test] + fn plane_conversion_reports_absurd_sizes() { + assert!(matches!(unpack_bits(&[0], usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); + assert!(matches!(plane_to_u8(&[0], 1, usize::MAX / 2, 4), Err(PsdError::LimitExceeded(_)))); + assert!(matches!(unpack_bits(&[0], 1 << 40, 1 << 20), Err(PsdError::LimitExceeded(_)))); + } + + /// A bitmap composite whose RGBA output is over the decode budget is refused up front. + #[test] + fn composite_rgba8_checks_the_output_budget_first() { + let mut file = crate::testgen::small(Version::Psb, crate::Compression::Zip); + file.header.color_mode = ColorMode::Bitmap; + file.header.depth = 1; + file.header.channels = 1; + file.header.width = 60_000; + file.header.height = 60_000; + assert!(matches!(file.composite_rgba8(), Err(PsdError::LimitExceeded("RGBA output exceeds MAX_DECODED_BYTES")))); + } + #[test] fn rgb_interleave_checks_planes_and_preserves_alpha() { let color = [Cow::Borrowed(&[1, 2][..]), Cow::Borrowed(&[3, 4][..]), Cow::Borrowed(&[5, 6][..])]; From 7c77143b97d2e4530dda666dbdd8677483f858e3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 12:28:09 +0200 Subject: [PATCH 073/523] PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654) Co-authored-by: Claude Opus 5.5 --- crates/psd/src/path.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/psd/src/path.rs b/crates/psd/src/path.rs index ea345b8ce4..79f055cb0b 100644 --- a/crates/psd/src/path.rs +++ b/crates/psd/src/path.rs @@ -155,7 +155,8 @@ impl PathData { out.push(PsdSubpath { closed: r.selector == selector::CLOSED_LENGTH, operation: r.u16_at(2) as i16, - knots: Vec::with_capacity(remaining.min(4096)), + // No preallocation: the count is untrusted (u16) and PsdKnot is 56 bytes. + knots: Vec::new(), }); } else if r.is_knot() { let closed = r.selector <= 2; @@ -261,6 +262,19 @@ mod tests { ] } + #[test] + fn subpaths_does_not_preallocate_from_untrusted_counts() { + let mut bytes = Vec::new(); + for _ in 0..100_000 { + bytes.extend_from_slice(&selector::CLOSED_LENGTH.to_be_bytes()); + bytes.extend_from_slice(&0xFFFFu16.to_be_bytes()); + bytes.extend_from_slice(&[0u8; 22]); + } + let subs = PathData::from_bytes(&bytes).unwrap().subpaths(); + assert_eq!(subs.len(), 100_000); + assert!(subs.iter().all(|s| s.knots.capacity() == 0)); + } + #[test] fn subpaths_roundtrip_and_bytes_are_stable() { let p = PathData::from_subpaths(&sample(), true); From 16341720c0166050b43b64c13f1d106577853b9c Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 12:28:14 +0200 Subject: [PATCH 074/523] PSD: bound the zero padding of undersized pattern channels (#3652) (#3686) Co-authored-by: Claude Opus 5.5 --- crates/psd/src/patterns.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index 00e61934de..04d5c24c24 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -26,6 +26,11 @@ use crate::header::Version; /// Largest pattern edge accepted (Photoshop's own limit is far lower). const MAX_EDGE: u32 = 30_000; +/// Budget for zero-padded channel planes summed over one pattern. Photoshop writes every channel +/// at the pattern rectangle, so a smaller channel rectangle is only padding; without this cap a +/// few hundred bytes (1x1 channels, a huge pattern rectangle) would allocate up to +/// `MAX_DECODED_BYTES` of zeros. Matches `abr::MAX_TOTAL_BYTES`. +const MAX_PADDED_PATTERN_BYTES: u64 = 512 << 20; // A PackBits run packet emits at most 128 bytes from a 2-byte packet. const MAX_RLE_EXPANSION: u64 = 64; @@ -154,6 +159,7 @@ fn read_pattern(r: &mut Rd) -> Result { let mut channels = Vec::new(); let mut depth = 8u16; let mut decoded_bytes = 0u64; + let mut padded_bytes = 0u64; for _ in 0..count + 2 { if v.p >= body.len() { break; @@ -193,6 +199,12 @@ fn read_pattern(r: &mut Rd) -> Result { if decoded_bytes > MAX_DECODED_BYTES { return Err(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES")); } + if cw != w as usize || ch != h as usize { + padded_bytes = padded_bytes.checked_add(plane_bytes).ok_or(PsdError::LimitExceeded("pattern padded size overflow"))?; + if padded_bytes > MAX_PADDED_PATTERN_BYTES { + return Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES")); + } + } let _ = layout.decoded_len()?; let compression = if comp == 1 { Compression::Rle @@ -554,13 +566,13 @@ mod tests { let vma = pattern_vma_rect_offset(4, &p); set_i32(&mut global, vma + 8, 30_000); set_i32(&mut global, vma + 12, 30_000); - assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES")))); + assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded(_)))); let mut standalone = write_pat_file(std::slice::from_ref(&p)).unwrap(); let vma = pattern_vma_rect_offset(10, &p); set_i32(&mut standalone, vma + 8, 30_000); set_i32(&mut standalone, vma + 12, 30_000); - assert!(matches!(parse_pat_file(&standalone), Err(PsdError::LimitExceeded("pattern data exceeds MAX_DECODED_BYTES")))); + assert!(matches!(parse_pat_file(&standalone), Err(PsdError::LimitExceeded(_)))); let mut abr = crate::abr::write_v6( 1, @@ -658,6 +670,17 @@ mod tests { assert!(matches!(zeroed_pattern_plane(usize::MAX), Err(PsdError::LimitExceeded("not enough memory for the padded pattern channel")))); } + #[test] + fn tiny_channels_in_a_huge_pattern_rect_are_not_zero_padded_without_bound() { + // 3 channels of 5x3 padded to 30000x30000 would allocate 2.7 GB of zeros. + let p = pat(8, false); + let mut global = write_pattern_block(std::slice::from_ref(&p)).unwrap(); + let vma = pattern_vma_rect_offset(4, &p); + set_i32(&mut global, vma + 8, 30_000); + set_i32(&mut global, vma + 12, 30_000); + assert!(matches!(parse_pattern_block(&global), Err(PsdError::LimitExceeded("padded pattern channels exceed MAX_PADDED_PATTERN_BYTES")))); + } + #[test] fn zero_size_pattern_rectangles_are_rejected() { let p = pat(8, false); From 64a02107bad9281e0cebfad3afab66481d79cbb8 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 12:28:18 +0200 Subject: [PATCH 075/523] PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699) Co-authored-by: Claude Opus 5.5 --- crates/psd/src/descriptor.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index 3e0cc2516e..b0e64ba439 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -349,6 +349,15 @@ impl VersionedDescriptor { } } +/// Most items preallocated for a file-declared count. `check_count` only +/// guarantees a few bytes per item while parsed items are far larger, so the +/// count must not drive the up-front reservation (or abort on allocation). +const MAX_PREALLOC: usize = 4096; + +fn vec_for_count(n: u32) -> Vec { + Vec::with_capacity((n as usize).min(MAX_PREALLOC)) +} + fn read_descriptor(r: &mut Reader<'_>, depth: usize) -> Result { if depth > MAX_DEPTH { return Err(PsdError::LimitExceeded("descriptor nesting too deep")); @@ -358,7 +367,7 @@ fn read_descriptor(r: &mut Reader<'_>, depth: usize) -> Result { let n = r.u32()?; // Each item needs at least 5 (1-byte string key) + 4 (type) + 1 (bool). r.check_count(u64::from(n), 10)?; - let mut items = Vec::with_capacity(n as usize); + let mut items = vec_for_count(n); for _ in 0..n { let key = Id::read(r)?; let v = read_value(r, depth + 1)?; @@ -380,7 +389,7 @@ fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result { let n = r.u32()?; r.check_count(u64::from(n), 8)?; - let mut items = Vec::with_capacity(n as usize); + let mut items = vec_for_count(n); for _ in 0..n { items.push(read_reference_item(r)?); } @@ -391,7 +400,7 @@ fn read_typed_value(r: &mut Reader<'_>, ty: &[u8; 4], depth: usize) -> Result { let n = r.u32()?; r.check_count(u64::from(n), 4)?; - let mut items = Vec::with_capacity(n as usize); + let mut items = vec_for_count(n); for _ in 0..n { items.push(read_value(r, depth + 1)?); } @@ -571,6 +580,22 @@ mod tests { Class { name: UnicodeString::new(name), class_id: Id::new(id) } } + #[test] + fn declared_counts_do_not_drive_preallocation() { + assert!(vec_for_count::(u32::MAX).capacity() <= MAX_PREALLOC); + // A 1M-item list of booleans parses (items grow the vector as needed). + let n = 1_000_000u32; + let mut data = n.to_be_bytes().to_vec(); + for _ in 0..n { + data.extend_from_slice(b"bool\x01"); + } + let mut r = Reader::new(&data); + match read_typed_value(&mut r, b"VlLs", 0).unwrap() { + Value::List(v) => assert_eq!(v.len(), n as usize), + other => panic!("expected list, got {other:?}"), + } + } + pub(crate) fn sample() -> Descriptor { Descriptor::new("null") .with("Nm ", Value::Text(UnicodeString::new_nul("Hello \u{1F600}"))) From 2cb396d1544f063732c685f413892fd8fec8eca4 Mon Sep 17 00:00:00 2001 From: Scott Macdonald Date: Mon, 12 Oct 2026 06:28:33 -0400 Subject: [PATCH 076/523] fix(engine): Displace smart filter ignores its mapPath displacement map (#3676) A Displace applied to a smart object is recorded as a smart filter and rendered by apply_smart_filters -> filters::apply_filter_to_surface, both when it is added and on every later re-render (replace contents, toggling a filter, transforms). That path built FilterParams with params_for alone, which leaves Displace's map as None; only run_filter resolved `mapPath` (through filters_ext::resolve, which needs the session). With no map, algo's displace returns the source unchanged, so the smart filter was a silent no-op. apply_filter_to_surface now resolves `mapPath` through the same file read and import the direct command uses (filters_ext::map_from_path, shared by resolve and the new session-free resolve_map_path). A map that can no longer be read on re-render leaves the pixels alone, as for an unknown filter; the command still refuses an unreadable map when the filter is added. `mapDocument` needs the session and stays unresolved on re-render. Regression test: smart_cmds::tests:: displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/filters.rs | 5 ++++- crates/engine/src/filters_ext.rs | 30 +++++++++++++++++++++++---- crates/engine/src/smart_cmds/tests.rs | 30 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 2ccc999b40..40b51189e2 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -185,7 +185,10 @@ pub fn apply_filter_to_surface( if id == crate::plugin_cmds::RUN { return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas); } - let fp = params_for(id, params)?; + let mut fp = params_for(id, params)?; + // A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an + // unknown filter; the command itself already refused an unreadable map when it was added. + crate::filters_ext::resolve_map_path(&mut fp, params).ok()?; let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds); let content = surf.content_bounds(); let area = algo::output_area(&fp, content, bounds, sel_bounds); diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index bf1656cae7..f1787fd1e3 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -435,10 +435,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<( return Ok(()); } let layer = p.get("mapLayer").and_then(Value::as_u64); - let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) { - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?; - let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?; - map_from_document(&imported, layer) + let img = if let Some(path) = map_path(p) { + map_from_path(path, layer)? } else if let Some(d) = p.get("mapDocument") { let docs = s.documents(); let found = match d { @@ -454,6 +452,30 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<( Ok(()) } +fn map_path(p: &Value) -> Option<&str> { + p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) +} + +/// Displace's map read from a file (flattened, or its `mapLayer`). +fn map_from_path(path: &str, layer: Option) -> Result> { + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?; + let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?; + Ok(map_from_document(&imported, layer)) +} + +/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not +/// the map, so their re-render reads the map again here; without it the filter passed the pixels +/// through. `mapDocument` needs the session and is left unresolved. +pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> { + if let FilterParams::Displace { map, .. } = fp + && map.is_none() + && let Some(path) = map_path(p) + { + *map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new); + } + Ok(()) +} + /// Resolves inputs read from the target layer itself: the layer mask as Lens Blur depth. pub(crate) fn resolve_in_layer(fp: &mut FilterParams, layer: &Layer, bounds: Rect) { if let FilterParams::LensBlur { depth: DepthSource::LayerMask, depth_map, .. } = fp diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 2ee54322bf..90bb325a2c 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -977,3 +977,33 @@ fn inspect_reports_the_smart_source() { assert_eq!(source(&s)["kind"], "embedded"); assert!(source(&s)["fileName"].is_string()); } + +#[test] +fn displace_smart_filter_reads_its_map_path_on_re_render() { + let dir = std::env::temp_dir().join(format!("pcraft-displace-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // A white map displaces by the full scale; mid-grey would not move anything. + let white = Document::with_background( + "map", + Size::new(W as u32, H as u32), + photocraft_color::ColorMode::Rgb, + photocraft_color::SampleType::U8, + photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), + ); + let map = dir.join("white.png"); + std::fs::write(&map, photocraft_io::export(&white, "png", &Default::default()).unwrap().bytes).unwrap(); + for depth in DEPTHS { + let mut s = session(depth); + paint(&mut s); + convert(&mut s); + let before = flat(&s); + s.execute("filter.distort.displace", json!({"horizontal": 8, "vertical": 8, "mapPath": map.to_str().unwrap()})).unwrap(); + assert!(max_diff(&before, &flat(&s)) > 0.1, "the smart filter displaced the pixels at {depth}-bit"); + // A later re-render (here: hide and show the filter) reads the map again. + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(max_diff(&before, &flat(&s)), 0.0, "hidden at {depth}-bit"); + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap(); + assert!(max_diff(&before, &flat(&s)) > 0.1, "shown again at {depth}-bit"); + } + std::fs::remove_dir_all(&dir).ok(); +} From 5ed758763d1b77e188d66ab782490e9639b6421c Mon Sep 17 00:00:00 2001 From: Scott Macdonald Date: Mon, 12 Oct 2026 06:28:38 -0400 Subject: [PATCH 077/523] PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677) A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp mesh) can have control points outside its source rect 0..w x 0..h. Photoshop maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft mapped the source rect onto it, so a mesh that spills past the source counted the overhang twice and the re-rendered smart object came out stretched. Measured against Photoshop's own export of a real shirt mockup (4200 x 4800 source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch, the tile offset growing from 0 to -16 px top to bottom; after, alignment is exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of the evaluated warp outline (rather than the control points) fixed that file but put another at 5.4% RMSE, so it is the control-point box. - blocks::placed_frame: the shared rule. The control-point box of a single-patch custom warp, else the source rect; a degenerate or non-finite box falls back to the source rect. - blocks::parse_smart (and so smart_map::parse_sold): the affine maps that frame onto Trnf. - smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of the same frame, so a spilled mesh round-trips and quad_moved keeps the imported Trnf as stored. Distort / Perspective placements keep the source rect, matching parse_smart_perspective. Refs #579. Co-authored-by: Claude Opus 5.5 --- crates/io/src/blocks.rs | 78 +++++++++++++++++++++++++++++++++++++- crates/io/src/smart_map.rs | 29 +++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 329ee35fb8..3a6b220d18 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -582,6 +582,22 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { VersionedDescriptor::parse_prefix(data).ok().map(|(v, _)| v.descriptor) } +/// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a +/// single-patch custom warp's 16 control points, which may spill past the source, else the source +/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the +/// placement. A degenerate or non-finite box falls back to the source rect. +pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { + let rect = [0.0, 0.0, w, h]; + let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) + else { + return rect; + }; + let inf = f64::INFINITY; + let b = m.points.iter().fold([inf, inf, -inf, -inf], |b, [x, y]| [b[0].min(*x), b[1].min(*y), b[2].max(*x), b[3].max(*y)]); + let ok = b.iter().all(|v| v.is_finite()) && b[2] - b[0] > 1e-9 && b[3] - b[1] > 1e-9; + if ok { b } else { rect } +} + /// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { // SoLd: 'soLD' + version(4) + versioned descriptor. @@ -596,7 +612,10 @@ pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); let (w, h) = (num(sz.get("Wdth")).unwrap_or(0.0), num(sz.get("Hght")).unwrap_or(0.0)); if p.len() == 8 && w > 0.0 && h > 0.0 { - affine = Affine { m: [(p[2] - p[0]) / w, (p[3] - p[1]) / w, (p[6] - p[0]) / h, (p[7] - p[1]) / h, p[0], p[1]] }; + let [x0, y0, x1, y1] = placed_frame(parse_placed_warp(key, data).as_ref(), w, h); + let (bw, bh) = (x1 - x0, y1 - y0); + let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); + affine = Affine { m: [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0] }; } } (id, affine) @@ -899,6 +918,63 @@ mod more_tests { assert_eq!(parse_smart(b"PlLd", &data).0, ""); } + /// `SoLd` data with a 50 × 25 source, the quad `trnf` and, when given, a custom warp whose + /// 16 control points are `pts`. + fn sold_with_warp(trnf: [f64; 8], pts: Option<&[[f64; 2]]>) -> Vec { + use photocraft_psd::descriptor::ObjectArray; + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(trnf.map(Value::Double).to_vec())) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + if let Some(pts) = pts { + let bounds = Descriptor::new("Rctn") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(25.0)) + .with("Rght", Value::Double(50.0)); + let mesh = Descriptor::new("null") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() }); + let warp = Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpCustom")).with("bounds", Value::Descriptor(bounds)).with( + "customEnvelopeWarp", + Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh }))), + ); + d = d.with("warp", Value::Descriptor(warp)); + } + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the + /// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. + #[test] + fn smart_transform_maps_the_warp_control_box_onto_the_quad() { + let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 1, 1).points; + pts[0] = [-10.0, -5.0]; + pts[15] = [60.0, 25.0]; + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let (_, a) = parse_smart(b"SoLd", &sold_with_warp(quad, Some(&pts))); + assert_eq!(a.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]); + // Without a warp the source rect is the frame, as before. + assert_eq!(parse_smart(b"SoLd", &sold_with_warp(quad, None)).1.m, [2.8, 0.0, 0.0, 2.4, 100.0, 200.0]); + } + + /// A custom mesh with no area (every control point on one spot) can't frame the quad: the + /// source rect does, with no division by zero. + #[test] + fn smart_transform_with_a_degenerate_warp_box_uses_the_source_rect() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let (_, a) = parse_smart(b"SoLd", &sold_with_warp(quad, Some(&[[7.0, 7.0]; 16]))); + assert_eq!(a.m, [2.8, 0.0, 0.0, 2.4, 100.0, 200.0]); + let mut warp = photocraft_geom::warp::Warp::preset(photocraft_geom::warp::WarpStyle::Custom, 0.0, [0.0, 0.0, 50.0, 25.0]); + warp.mesh = Some(photocraft_geom::warp::BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: vec![[f64::NAN, 1.0]; 16] }); + assert_eq!(placed_frame(Some(&warp), 50.0, 25.0), [0.0, 0.0, 50.0, 25.0]); + assert_eq!(placed_frame(None, 50.0, 25.0), [0.0, 0.0, 50.0, 25.0]); + } + #[test] fn effects_switch() { let mut lfx = vec![0, 0, 0, 0]; diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index d4c02894be..2a7cc5c5fc 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -526,7 +526,9 @@ pub struct PlacedSpec<'a> { pub filter_fx: Option, } -/// Where the source corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs. +/// Where the frame corners land (top-left, top-right, bottom-right, bottom-left), as x, y pairs. +/// The frame is [`crate::blocks::placed_frame`] for an affine placement (the inverse of +/// `parse_smart`), the source rect for a projective one (as `parse_smart_perspective` reads it). fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { let (w, h) = s.size; let [a, b, c, d, e, f] = s.transform.m; @@ -534,8 +536,9 @@ fn quad_points(s: &PlacedSpec<'_>) -> [f64; 8] { Some(p) => photocraft_algo::transform::Homography(*p).apply(x, y), None => (a * x + c * y + e, b * x + d * y + f), }; + let [x0, y0, x1, y1] = if s.perspective.is_some() { [0.0, 0.0, w, h] } else { crate::blocks::placed_frame(s.warp, w, h) }; let mut out = [0.0; 8]; - for (i, (x, y)) in [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].into_iter().enumerate() { + for (i, (x, y)) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].into_iter().enumerate() { (out[2 * i], out[2 * i + 1]) = map(x, y); } out @@ -1080,6 +1083,28 @@ mod tests { } } + /// Export writes `Trnf` as the image of the custom warp's control-point box, the inverse of + /// import: a spilled mesh comes back with the same transform and the quad Photoshop expects. + #[test] + fn warp_control_box_quad_round_trips() { + use photocraft_geom::warp::BezierMesh; + let mut mesh = BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 1, 1); + mesh.points[0] = [-10.0, -5.0]; + mesh.points[15] = [60.0, 25.0]; + let mut warp = Warp::preset(WarpStyle::Custom, 0.0, [0.0, 0.0, 50.0, 25.0]); + warp.mesh = Some(mesh); + let transform = Affine { m: [2.0, 0.0, 0.0, 2.0, 120.0, 210.0] }; + let spec = PlacedSpec { idnt: "i", placed: "p", transform, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let sold = sold_bytes(None, &spec, &mut Vec::new()); + let p = parse_sold(&sold).expect("parses"); + let Some(Value::List(q)) = p.descriptor.get("Trnf") else { panic!("no Trnf") }; + let q: Vec = q.iter().filter_map(|v| num(Some(v))).collect(); + assert_eq!(q, [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]); + assert_eq!(p.transform, transform); + // The imported quad reads as unmoved, so the template's `Trnf` is kept as stored. + assert!(!quad_moved(&p.descriptor, &spec)); + } + #[test] fn hex_and_uuid() { assert_eq!(from_hex(&to_hex(&[0, 1, 0xab, 0xff])), Some(vec![0, 1, 0xab, 0xff])); From 590bec7998ac32dd830e4760791eb451901f118c Mon Sep 17 00:00:00 2001 From: Scott Macdonald Date: Mon, 12 Oct 2026 06:28:55 -0400 Subject: [PATCH 078/523] feat(io): import Photoshop Displace smart filters with their embedded map (#3680) A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in the modelled filter list, so it became psd.unsupportedFilter and rendered as a pass-through. Mockup PSDs that bend a replaceable design over fabric (a design smart object with Displace then Gaussian Blur) rendered flat. Photoshop stores the displacement map inside the filter: `DspD` (raw data) holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes (horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM (StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an alias to the author's map file, usually unavailable). The filter now maps to filter.distort.displace. As for Camera Raw, the whole item (with the map) is kept as hex in `__displacePsd`, so it re-renders, survives .pcraft and is written back to PSD with only the editable settings and blending replaced. params_for resolves the embedded map, so the smart filter re-render applies it, stretched over the document canvas (the bounds smart filters already pass). Another mode, a missing or truncated map, or unknown enum values keep the item opaque as before. The map parser uses checked sizes and caps maps at 2^26 pixels. Tests: displace_map unit tests (layout, every truncation, hostile and corrupted headers, round trip, edited settings, opaque fallbacks, bad exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map (8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/filters_ext.rs | 3 +- crates/engine/tests/smart_psd.rs | 65 ++++++ crates/io/src/displace_map.rs | 343 +++++++++++++++++++++++++++++++ crates/io/src/smart_map.rs | 21 +- 4 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 crates/io/src/displace_map.rs diff --git a/crates/engine/src/filters_ext.rs b/crates/engine/src/filters_ext.rs index f1787fd1e3..dae5e9b98b 100644 --- a/crates/engine/src/filters_ext.rs +++ b/crates/engine/src/filters_ext.rs @@ -153,7 +153,8 @@ pub fn params_for(id: &str, p: &Value) -> Option { vertical: f(p, "vertical", 10.0).clamp(-999.0, 999.0), stretch: s(p, "fit", "stretch") != "tile", undefined: undefined(p, "repeat"), - map: None, + // A Displace imported from PSD carries its map; `mapPath` / `mapDocument` resolve later. + map: photocraft_io::smart_map::displace_embedded_map(p), }, "filter.distort.shear" => FilterParams::Shear { points: list::<[f32; 2]>(p, "points").unwrap_or_else(|| { diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 8b86f2c884..5f4c7bf6e5 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -403,3 +403,68 @@ fn edited_psd_smart_objects_drop_their_old_file() { let SmartSource::Linked { path } = &sm.source else { panic!() }; assert_eq!(uuids, vec![path.clone()]); } + +/// A Displace smart filter from a Photoshop mockup PSD carries its displacement map embedded +/// (`DspD`, stretched over the canvas): it re-renders displaced instead of passing the pixels +/// through, and keeps that map through PSD export, `.pcraft` and a PSD re-import. +#[test] +fn displace_psd_filter_renders_with_its_embedded_map() { + use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value}; + for depth in [8, 16, 32] { + let mut s = session(depth); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = photocraft_doc::LayerId(s.active().unwrap().active_layer.unwrap().0); + // Photoshop's layout: height, width, mode 3, horizontal plane (all right), vertical plane + // (neutral), padding. 128/255 is a hair over mid-grey: a sub-pixel vertical shift. + let (mw, mh) = (12u32, 8u32); + let n = (mw * mh) as usize; + let mut tdta = [mh.to_le_bytes().as_slice(), &mw.to_le_bytes(), &3u16.to_le_bytes()].concat(); + tdta.extend(std::iter::repeat_n(255, n).chain(std::iter::repeat_n(128, n)).chain(std::iter::repeat_n(0, mw as usize))); + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let fltr = Descriptor::new("Dspl") + .with("HrzS", Value::Integer(10)) + .with("VrtS", Value::Integer(10)) + .with("DspM", e("DspM", "StrF")) + .with("UndA", e("UndA", "RptE")) + .with("DspF", Value::Alias(b"Macintosh HD:displace.psd".to_vec())) + .with("EmbF", Value::Boolean(true)) + .with("DspD", Value::RawData(tdta)); + let item = Descriptor::new("filterFX") + .with("Nm ", Value::Text(UnicodeString::new_nul("Displace..."))) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with("Fltr", Value::Descriptor(fltr)) + .with("filterID", Value::Integer(1148416108)); + let filter = photocraft_io::smart_map::filter_from_item(&item); + assert_eq!(filter.command, "filter.distort.displace"); + s.edit("displace", |doc, _| { + let mut l = doc.layer(id).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") }; + sm.smart_filters.push(filter.clone()); + assert!(refresh_layer(doc, &mut l).unwrap()); + *doc.layer_mut(id).unwrap() = l; + Ok(()) + }) + .unwrap(); + let doc = s.active().unwrap().doc.clone(); + let alpha = |doc: &Document, x: i32| smart(doc).1.cache.unwrap().pixel(x, 30)[3]; + // 10 % of 128 px at full map contrast: the content (x 8..80) is read from 12.8 px to the + // right, so it moves left over the canvas edge and leaves its right edge. + assert!(alpha(&doc, 2) > 0.99 && alpha(&doc, 75) < 0.01, "depth {depth}: {} {}", alpha(&doc, 2), alpha(&doc, 75)); + + let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(structure_errors(&out.bytes), Vec::::new()); + let back = photocraft_io::import("displace.psd", &out.bytes).unwrap().document; + let native = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&back, &Default::default()).unwrap()).unwrap(); + for doc in [&back, &native] { + let (bid, after) = smart(doc); + assert_eq!(after.smart_filters, vec![filter.clone()], "depth {depth}"); + let mut l = doc.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(doc, &mut l).unwrap()); + let LayerContent::Smart(sm) = &l.content else { panic!("smart") }; + let (a, b) = (sm.cache.as_ref().unwrap(), smart(&s.active().unwrap().doc).1.cache.unwrap()); + assert_eq!((a.pixel(2, 30)[3], a.pixel(75, 30)[3]), (b.pixel(2, 30)[3], b.pixel(75, 30)[3]), "depth {depth}: re-render"); + } + } +} diff --git a/crates/io/src/displace_map.rs b/crates/io/src/displace_map.rs new file mode 100644 index 0000000000..8993663cc0 --- /dev/null +++ b/crates/io/src/displace_map.rs @@ -0,0 +1,343 @@ +//! Displace smart filters (`Fltr` class `Dspl`) as observed in Photoshop-authored mockup PSDs. +//! +//! The filter descriptor holds the scales (`HrzS`, `VrtS`, percent), the fit (`DspM`: `StrF` +//! stretch or `Tile`), the undefined areas (`UndA`: `RptE` repeat edge pixels or `WrpA` wrap), +//! an alias to the map file on the author's disk (`DspF`, usually unavailable), `EmbF` (true) and +//! the map itself, embedded as raw data (`DspD`, type `tdta`): +//! +//! ```text +//! u32 LE height, u32 LE width, u16 LE mode (3 observed), then two 8-bit planes of +//! width * height bytes (horizontal map, vertical map), then padding +//! ``` +//! +//! The whole `filterFX` item (with the map) is kept verbatim in the params as a template +//! ([`TEMPLATE_KEY`]), so the filter re-renders, survives `.pcraft` and is written back to PSD +//! with only the editable settings replaced. Anything else (another mode, a missing or truncated +//! map, unknown enum values) stays an opaque [`super::UNSUPPORTED_FILTER`]. + +use std::sync::Arc; + +use photocraft_algo::Image; +use photocraft_doc::SmartFilter; +use photocraft_geom::Rect; +use photocraft_psd::descriptor::{Descriptor, Id, Value}; +use serde_json::{Value as J, json}; + +use super::{blend_options, from_hex, get_desc, int, set, to_hex}; +use crate::blocks::{enum_of, num}; + +pub(super) const CLASS: &str = "Dspl"; +pub(super) const COMMAND: &str = "filter.distort.displace"; +const FILTER_ID: i32 = super::code(b"Dspl"); +pub(super) const TEMPLATE_KEY: &str = "__displacePsd"; +/// The embedded map (raw data). +const MAP_KEY: &str = "DspD"; +/// `tdta` header: height, width (u32 LE) and mode (u16 LE). +const HEADER: usize = 10; +/// The only map layout seen in Photoshop files: two 8-bit planes. +const MODE_TWO_PLANES: u16 = 3; +/// 8K × 8K: far beyond the observed document-sized maps, and bounds the f32 map we build. +const MAX_MAP_PIXELS: usize = 1 << 26; +/// The template is the item plus the map; a little room for the rest of the descriptor. +const MAX_TEMPLATE_BYTES: usize = 2 * MAX_MAP_PIXELS + (1 << 20); + +const FITS: [(&[u8], &str); 2] = [(b"StrF", "stretch"), (b"Tile", "tile")]; +const UNDEFINED: [(&[u8], &str); 2] = [(b"RptE", "repeat"), (b"WrpA", "wrap")]; + +/// A parsed embedded map: size and the two 8-bit planes (borrowed from the blob). +#[derive(Debug, PartialEq)] +pub(super) struct EmbeddedMap<'a> { + pub width: usize, + pub height: usize, + pub horizontal: &'a [u8], + pub vertical: &'a [u8], +} + +/// Parses an embedded displacement map; `None` for any other mode, a zero or oversized map, or a +/// blob shorter than the header plus both planes. +pub(super) fn parse_map(data: &[u8]) -> Option> { + let u32_at = |i: usize| Some(u32::from_le_bytes(data.get(i..i + 4)?.try_into().ok()?)); + let height = usize::try_from(u32_at(0)?).ok()?; + let width = usize::try_from(u32_at(4)?).ok()?; + let mode = u16::from_le_bytes(data.get(8..HEADER)?.try_into().ok()?); + let n = width.checked_mul(height)?; + if mode != MODE_TWO_PLANES || n == 0 || n > MAX_MAP_PIXELS { + return None; + } + let horizontal = data.get(HEADER..HEADER.checked_add(n)?)?; + let vertical = data.get(HEADER + n..HEADER.checked_add(n.checked_mul(2)?)?)?; + Some(EmbeddedMap { width, height, horizontal, vertical }) +} + +fn fltr_map(item: &Descriptor) -> Option> { + match get_desc(item, "Fltr")?.get(MAP_KEY)? { + Value::RawData(b) => parse_map(b), + _ => None, + } +} + +fn lookup<'a>(table: &[(&[u8], &'a str)], v: &[u8]) -> Option<&'a str> { + table.iter().find(|(k, _)| *k == v).map(|(_, s)| *s) +} + +/// Editable settings of a `Dspl` descriptor, `None` when one is missing or unknown. +fn settings(f: &Descriptor) -> Option { + let scale = |k| num(f.get(k)).filter(|v| v.is_finite() && (-999.0..=999.0).contains(v)); + Some(json!({ + "horizontal": scale("HrzS")?, + "vertical": scale("VrtS")?, + "fit": lookup(&FITS, enum_of(f, "DspM")?)?, + "undefinedAreas": lookup(&UNDEFINED, enum_of(f, "UndA")?)?, + })) +} + +/// `filter.distort.displace` params for a Displace item with an embedded map. +pub(super) fn import_params(item: &Descriptor) -> Option { + let f = get_desc(item, "Fltr")?; + if !f.class_id.is(CLASS) { + return None; + } + let mut params = settings(f)?; + fltr_map(item)?; + // Neutral blending in the template: the model's own fields replace it on export. + let mut template = item.clone(); + let neutral = SmartFilter { command: String::new(), params: J::Null, blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; + set(&mut template, "blendOptions", blend_options(&neutral)); + set(&mut template, "enab", Value::Boolean(true)); + params.as_object_mut()?.insert(TEMPLATE_KEY.into(), json!(to_hex(&template.to_bytes()))); + Some(params) +} + +/// The template item of `params`, checked to be a Displace filter with a readable map. +fn template(params: &J) -> Result { + let raw = params.get(TEMPLATE_KEY).ok_or("a Displace map from a file or document is not written to PSD")?; + let raw = raw.as_str().ok_or("the Displace PSD data must be hex text")?; + if raw.len() > MAX_TEMPLATE_BYTES * 2 { + return Err("the Displace PSD data is too large".into()); + } + let bytes = from_hex(raw).ok_or("the Displace PSD data is not valid hex")?; + let item = Descriptor::from_bytes(&bytes).map_err(|e| format!("the Displace PSD data is unreadable ({e})"))?; + if !item.class_id.is("filterFX") || int(&item, "filterID") != Some(FILTER_ID) || fltr_map(&item).is_none() { + return Err("the Displace PSD data is not a Displace filter with an embedded map".into()); + } + Ok(item) +} + +/// The `filterFXList` item: the template with the current scales, fit and undefined areas. +pub(super) fn export_item(params: &J) -> Result { + let object = params.as_object().ok_or("Displace parameters must be an object")?; + if let Some(k) = object.keys().find(|k| !super::is_meta(k) && !["horizontal", "vertical", "fit", "undefinedAreas"].contains(&k.as_str())) { + return Err(format!("Displace setting `{k}` has no PSD equivalent next to its embedded map")); + } + let mut item = template(params)?; + let mut f = get_desc(&item, "Fltr").ok_or("the Displace PSD data has no filter")?.clone(); + let scale = |k: &str, d: f64| -> Result { + let v = params.get(k).map_or(Some(d), J::as_f64).filter(|v| v.is_finite()).ok_or(format!("Displace `{k}` must be a number"))?; + Ok(Value::Integer(v.round().clamp(-999.0, 999.0) as i32)) + }; + let choice = |k: &str, table: &[(&[u8], &str)], d: &str| -> Result { + let v = params.get(k).map_or(Some(d), J::as_str).ok_or(format!("Displace `{k}` must be text"))?; + let code = table.iter().find(|(_, s)| *s == v).map(|(c, _)| *c).ok_or(format!("Displace `{k}` is not one of the PSD values"))?; + Ok(Id::new(std::str::from_utf8(code).map_err(|e| e.to_string())?)) + }; + // Keep the template's enum types; only the values change. + let set_enum = |f: &mut Descriptor, key: &str, value: Id| { + let type_id = match f.get(key) { + Some(Value::Enumerated { type_id, .. }) => type_id.clone(), + _ => Id::new(key), + }; + set(f, key, Value::Enumerated { type_id, value }); + }; + set(&mut f, "HrzS", scale("horizontal", 10.0)?); + set(&mut f, "VrtS", scale("vertical", 10.0)?); + set_enum(&mut f, "DspM", choice("fit", &FITS, "stretch")?); + set_enum(&mut f, "UndA", choice("undefinedAreas", &UNDEFINED, "repeat")?); + set(&mut item, "Fltr", Value::Descriptor(f)); + Ok(item) +} + +/// The displacement map embedded in Displace params imported from PSD (`None` when there is +/// none or it is unreadable). The two planes become the first two channels; identical planes +/// (every file seen so far) become one channel, which Displace reads for both directions. +pub fn embedded_map(params: &J) -> Option> { + let item = template(params).ok()?; + let m = fltr_map(&item)?; + let rect = Rect::new(0, 0, i32::try_from(m.width).ok()?, i32::try_from(m.height).ok()?); + let v = |b: u8| f32::from(b) / 255.0; + let (ch, data) = if m.horizontal == m.vertical { + (1, m.horizontal.iter().map(|&b| v(b)).collect()) + } else { + (3, m.horizontal.iter().zip(m.vertical).flat_map(|(&h, &w)| [v(h), v(w), 0.5]).collect()) + }; + Some(Arc::new(Image { rect, ch, data })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::smart_map::{UNSUPPORTED_FILTER, filter_from_item, item_for_filter}; + + /// A `tdta` blob: `w`×`h`, horizontal plane `h_of(i)`, vertical `v_of(i)`, then `pad` zeros. + fn tdta(w: u32, h: u32, h_of: impl Fn(usize) -> u8, v_of: impl Fn(usize) -> u8, pad: usize) -> Vec { + let n = (w * h) as usize; + let mut b = Vec::new(); + b.extend(h.to_le_bytes()); + b.extend(w.to_le_bytes()); + b.extend(MODE_TWO_PLANES.to_le_bytes()); + b.extend((0..n).map(h_of)); + b.extend((0..n).map(v_of)); + b.extend(std::iter::repeat_n(0, pad)); + b + } + + /// A `filterFXList` item as Photoshop writes a Displace smart filter. + fn item(map: Vec) -> Descriptor { + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + Descriptor::new("filterFX") + .with("Nm ", Value::Text(photocraft_psd::descriptor::UnicodeString::new_nul("Displace..."))) + .with( + "blendOptions", + blend_options(&SmartFilter { + command: String::new(), + params: J::Null, + blend: photocraft_color::BlendMode::Normal, + opacity: 1.0, + visible: true, + }), + ) + .with("enab", Value::Boolean(true)) + .with("hasoptions", Value::Boolean(true)) + .with( + "Fltr", + Value::Descriptor( + Descriptor::new(CLASS) + .with("HrzS", Value::Integer(10)) + .with("VrtS", Value::Integer(10)) + .with("DspM", e("DspM", "StrF")) + .with("UndA", e("UndA", "RptE")) + .with("DspF", Value::Alias(b"Macintosh HD:maps:displace.psd".to_vec())) + .with("EmbF", Value::Boolean(true)) + .with(MAP_KEY, Value::RawData(map)), + ), + ) + .with("filterID", Value::Integer(FILTER_ID)) + } + + #[test] + fn filter_id_is_dspl() { + assert_eq!(FILTER_ID, 1148416108); + } + + #[test] + fn parses_the_observed_layout() { + let b = tdta(3, 2, |i| i as u8, |i| 100 + i as u8, 3); + let m = parse_map(&b).unwrap(); + assert_eq!((m.width, m.height), (3, 2)); + assert_eq!(m.horizontal, &[0, 1, 2, 3, 4, 5]); + assert_eq!(m.vertical, &[100, 101, 102, 103, 104, 105]); + } + + #[test] + fn hostile_maps_are_rejected_without_panicking() { + let good = tdta(4, 4, |i| i as u8, |i| i as u8, 0); + for len in 0..good.len() { + assert!(parse_map(&good[..len]).is_none(), "truncated to {len}"); + } + let header = |h: u32, w: u32, mode: u16| [h.to_le_bytes().as_slice(), &w.to_le_bytes(), &mode.to_le_bytes(), &[0; 64]].concat(); + assert!(parse_map(&header(u32::MAX, u32::MAX, 3)).is_none()); + assert!(parse_map(&header(1 << 16, 1 << 16, 3)).is_none()); + assert!(parse_map(&header(0, 4, 3)).is_none()); + assert!(parse_map(&header(2, 2, 2)).is_none()); + assert!(parse_map(&header(2, 2, 4)).is_none()); + assert!(parse_map(&header(2, 2, 3)).is_some()); + // Every possible short blob, and a few corrupted bytes of a good one. + for seed in 0..512u32 { + let mut b = good.clone(); + let i = (seed as usize * 7) % b.len(); + b[i] = b[i].wrapping_add(seed as u8 | 1); + let _ = parse_map(&b); + let _ = embedded_map(&json!({TEMPLATE_KEY: to_hex(&item(b).to_bytes())})); + } + } + + #[test] + fn displace_items_map_to_the_command_and_round_trip() { + let src = item(tdta(4, 3, |i| (i * 20) as u8, |i| (i * 20) as u8, 4)); + let f = filter_from_item(&src); + assert_eq!(f.command, COMMAND); + assert_eq!( + (f.params["horizontal"].as_f64(), f.params["fit"].as_str(), f.params["undefinedAreas"].as_str()), + (Some(10.0), Some("stretch"), Some("repeat")) + ); + assert_eq!(item_for_filter(&f).unwrap(), src, "unchanged settings write the original item back"); + let map = embedded_map(&f.params).unwrap(); + assert_eq!((map.rect, map.ch), (Rect::new(0, 0, 4, 3), 1)); + assert_eq!(map.data[1], 20.0 / 255.0); + + let mut edited = f.clone(); + edited.params["horizontal"] = json!(-25.4); + edited.params["fit"] = json!("tile"); + edited.params["undefinedAreas"] = json!("wrap"); + edited.opacity = 0.5; + let back = filter_from_item(&item_for_filter(&edited).unwrap()); + assert_eq!(back.command, COMMAND); + assert_eq!( + (back.params["horizontal"].as_f64(), back.params["fit"].as_str(), back.params["undefinedAreas"].as_str()), + (Some(-25.0), Some("tile"), Some("wrap")) + ); + assert_eq!(back.opacity, 0.5); + assert!(embedded_map(&back.params).is_some(), "the map survives"); + } + + #[test] + fn different_planes_drive_each_direction() { + let f = filter_from_item(&item(tdta(2, 1, |_| 255, |_| 0, 0))); + let map = embedded_map(&f.params).unwrap(); + assert_eq!(map.ch, 3); + assert_eq!(&map.data[..3], &[1.0, 0.0, 0.5]); + } + + #[test] + fn unreadable_displace_filters_stay_opaque() { + let only_alias = { + let mut i = item(Vec::new()); + let mut f = get_desc(&i, "Fltr").unwrap().clone(); + f.items.retain(|(k, _)| !k.is(MAP_KEY)); + set(&mut i, "Fltr", Value::Descriptor(f)); + i + }; + let mut bad_enum = item(tdta(2, 2, |_| 0, |_| 0, 0)); + let mut f = get_desc(&bad_enum, "Fltr").unwrap().clone(); + set(&mut f, "DspM", Value::Enumerated { type_id: Id::new("DspM"), value: Id::new("Huh?") }); + set(&mut bad_enum, "Fltr", Value::Descriptor(f)); + let wrong_mode = item([tdta(2, 2, |_| 0, |_| 0, 0)[..8].to_vec(), 1u16.to_le_bytes().to_vec(), vec![0; 8]].concat()); + for src in [only_alias, bad_enum, wrong_mode, item(vec![1, 2, 3])] { + let f = filter_from_item(&src); + assert_eq!(f.command, UNSUPPORTED_FILTER); + assert_eq!(item_for_filter(&f).unwrap(), src); + } + } + + #[test] + fn bad_exports_report_errors() { + let ok = filter_from_item(&item(tdta(2, 2, |_| 0, |_| 0, 0))).params; + let with = |k: &str, v: J| { + let mut p = ok.clone(); + p[k] = v; + p + }; + for p in [ + J::Null, + json!({"horizontal": 10}), + json!({TEMPLATE_KEY: "zz"}), + json!({TEMPLATE_KEY: 12}), + json!({TEMPLATE_KEY: to_hex(&Descriptor::new("filterFX").with("filterID", Value::Integer(FILTER_ID)).to_bytes())}), + with("horizontal", json!("big")), + with("fit", json!("squash")), + with("mapPath", json!("/tmp/map.psd")), + ] { + assert!(export_item(&p).is_err(), "{p}"); + } + assert!(embedded_map(&json!({TEMPLATE_KEY: "0".repeat(MAX_TEMPLATE_BYTES * 2 + 2)})).is_none()); + } +} diff --git a/crates/io/src/smart_map.rs b/crates/io/src/smart_map.rs index 2a7cc5c5fc..ab9349fbde 100644 --- a/crates/io/src/smart_map.rs +++ b/crates/io/src/smart_map.rs @@ -18,6 +18,9 @@ //! to their command id and dialog parameters; any other filter becomes a //! [`UNSUPPORTED_FILTER`] entry that keeps its descriptor verbatim (hex in `params.psd`), so it is //! listed, can be hidden, reordered or deleted, and is written back unchanged. +//! +//! Camera Raw and Displace keep their whole item as a template next to the editable settings +//! (Displace's carries the embedded displacement map, see `displace_map.rs`). use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{LayerMask, SmartFilter}; @@ -34,6 +37,10 @@ use crate::blocks::{enum_of, get_desc, num}; #[path = "camera_raw_map.rs"] mod camera_raw; +#[path = "displace_map.rs"] +mod displace; + +pub use displace::embedded_map as displace_embedded_map; /// Command id of a Photoshop smart filter PhotoCraft does not implement. Its params hold the /// filter's name, Photoshop filter id and descriptor (`psd`, hex); it renders as a pass-through. @@ -312,10 +319,10 @@ pub fn filter_from_item(item: &Descriptor) -> SmartFilter { let class = fltr.map(|f| String::from_utf8_lossy(f.class_id.as_bytes()).into_owned()); let filter_id = int(item, "filterID").unwrap_or(0); let class = class.or_else(|| KNOWN.iter().find(|k| k.filter_id == filter_id).map(|k| k.class.to_string())); - let known = if class.as_deref() == Some(camera_raw::CLASS) { - camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)) - } else { - class.as_deref().and_then(|c| known_params(c, fltr)) + let known = match class.as_deref() { + Some(camera_raw::CLASS) => camera_raw::import_params(item).map(|p| (camera_raw::COMMAND, p)), + Some(displace::CLASS) => displace::import_params(item).map(|p| (displace::COMMAND, p)), + c => c.and_then(|c| known_params(c, fltr)), }; let (command, params) = match known { Some((c, p)) => (c.to_string(), p), @@ -460,6 +467,12 @@ pub fn item_for_filter(f: &SmartFilter) -> Result { set(&mut d, "enab", Value::Boolean(f.visible)); return Ok(d); } + if f.command == displace::COMMAND { + let mut d = displace::export_item(&f.params)?; + set(&mut d, "blendOptions", blend_options(f)); + set(&mut d, "enab", Value::Boolean(f.visible)); + return Ok(d); + } if f.command == UNSUPPORTED_FILTER { let raw = f.params.get("psd").and_then(J::as_str).and_then(from_hex).ok_or("its filter data is missing")?; let mut d = Descriptor::from_bytes(&raw).map_err(|e| format!("its filter data is unreadable ({e})"))?; From 304720b1a088cfeee4164cd482b1416008c20ef1 Mon Sep 17 00:00:00 2001 From: Arthur Wagner <97618574+arthurdeka@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:30:14 -0300 Subject: [PATCH 079/523] Gradient Map: edit color stops on the ramp (#3982) * Gradient Map: edit color stops on the ramp (#2958) * Gradient Map: keep stop removal scoped to the editor * Gradient Map: fit selected stop controls in narrow Properties panels --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 + crates/ui-egui/src/adjust_dialog.rs | 31 +++++ crates/ui-egui/src/adjust_editors.rs | 152 +++++++++++++++++---- crates/ui-egui/src/adjust_editors_tests.rs | 134 ++++++++++++++++++ 4 files changed, 294 insertions(+), 26 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index e182a21144..37220e0e80 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -117,3 +117,6 @@ sync_github_name = true [people.FredApps] sync_github_name = true + +[people.arthurdeka] +sync_github_name = true diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 71392662e9..7c97835354 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -437,6 +437,37 @@ mod tests { assert!(std::sync::Arc::ptr_eq(&before, &after)); assert!(harness.state().ui.dialogs.is_empty()); } + + #[test] + fn gradient_map_dialog_handles_preview_without_committing_until_ok() { + use egui_kittest::kittest::Queryable; + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + let id = open(h.state_mut(), "image.adjustments.gradientMap").unwrap(); + h.run_steps(4); + let doc = h.state().session.active().unwrap().doc.clone(); + let steps = h.state().session.active().unwrap().history.past_len(); + let strip = h.get_by_label("Gradient stops").rect(); + let start = egui::pos2(strip.left() + 8.0, strip.top() + 36.0); + let to = start + egui::vec2((strip.width() - 16.0) * 0.3, 0.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(to); + h.run_steps(1); + h.drop_at(to); + h.run_steps(3); + let stops = h.state().ui.dialogs[0].fields["stops"].as_array().unwrap(); + assert!((stops[0][0].as_f64().unwrap() - 0.3).abs() < 0.001); + assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc), "dialog edits are previews"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(*h.state().session.active().unwrap().doc, *doc); + } #[test] fn curves_pencil_drag_draws_the_map_and_confirm_applies_it() { let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index a939d20eca..4d25d822c6 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -1244,10 +1244,108 @@ fn sample_stops(s: &[Stop], t: f32) -> [f32; 3] { sorted.last().map_or([t; 3], |l| l.1) } +#[derive(Clone, Copy)] +struct MapStopDrag { + index: usize, + offset: f32, + changed: bool, +} + +/// Keep selection on the same stop when it crosses another stop (including coincident stops). +fn move_map_stop(stops: &mut [Stop], index: usize, location: f32) -> Option { + let stop = stops.get_mut(index)?; + stop.0 = round(location.clamp(0.0, 1.0), 0.001); + let location = stop.0; + let rank = stops.iter().enumerate().filter(|(i, s)| *i != index && (s.0 < location || (s.0 == location && *i < index))).count(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + Some(rank) +} + +fn gradient_map_strip(ui: &mut egui::Ui, stops: &mut Vec, cx: &EditorCx, selected: &mut usize) -> Edit { + let t = Tokens::get(ui.ctx()); + let key = cx.mem.with("gm-stop-drag"); + let mut edit = Edit::default(); + let w = ui.available_width().min(300.0); + let (outer, resp) = ui.allocate_exact_size(vec2(w, 44.0), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Gradient stops"))); + let bar = Rect::from_min_max(outer.min + vec2(8.0, 2.0), pos2(outer.right() - 8.0, outer.top() + 26.0)); + let x_of = |loc: f32| bar.left() + loc * bar.width(); + let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0); + let hit = + |pos: Pos2, stops: &[Stop]| stops.iter().enumerate().rev().find(|(_, s)| pos.y >= bar.bottom() && (x_of(s.0) - pos.x).abs() <= 6.0).map(|(i, _)| i); + if resp.drag_started() + && let Some(pos) = ui.input(|i| i.pointer.press_origin()) + && let Some(index) = hit(pos, stops) + { + *selected = index; + let offset = stops.get(index).map_or(0.0, |s| pos.x - x_of(s.0)); + ui.data_mut(|d| d.insert_temp(key, MapStopDrag { index, offset, changed: false })); + } + if let Some(mut drag) = ui.data(|d| d.get_temp::(key)) { + if (resp.dragged() || resp.drag_stopped()) + && let Some(pos) = resp.interact_pointer_pos() + { + let location = round(loc_of(pos.x - drag.offset), 0.001); + let changed = stops.get(drag.index).is_some_and(|s| s.0 != location); + if let Some(index) = move_map_stop(stops, drag.index, location) { + drag.index = index; + drag.changed |= changed; + *selected = index; + edit.changed |= changed; + } + } + if resp.drag_stopped() { + edit.commit = drag.changed; + ui.data_mut(|d| d.remove::(key)); + } else if ui.input(|i| i.pointer.primary_down()) { + ui.data_mut(|d| d.insert_temp(key, drag)); + } else { + ui.data_mut(|d| d.remove::(key)); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + { + resp.request_focus(); + if let Some(index) = hit(pos, stops) { + *selected = index; + } else if stops.len() < 64 { + let at = round(loc_of(pos.x), 0.001); + let color = sample_stops(stops, at); + stops.push((at, color)); + let index = stops.len() - 1; + *selected = move_map_stop(stops, index, at).unwrap_or(0); + edit.add(Edit::discrete(true)); + } + } + if resp.hovered() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| hit(pos, stops).is_some()) || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + // Markers address un-reversed locations, as in the Gradient Tool; Reverse changes the image. + let cols: Vec = (0..=48).map(|i| color32(sample_stops(stops, i as f32 / 48.0))).collect(); + spectrum_bar(ui.painter(), bar, &cols); + ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + for (i, (at, color)) in stops.iter().enumerate() { + let x = x_of(*at); + let border = if i == *selected { t.accent } else { t.text_dim }; + let body = Rect::from_center_size(pos2(x, bar.bottom() + 10.0), vec2(10.0, 10.0)); + ui.painter().add(egui::Shape::convex_polygon( + vec![pos2(x, bar.bottom() + 1.0), pos2(x + 5.0, body.top()), pos2(x - 5.0, body.top())], + border, + Stroke::NONE, + )); + ui.painter().rect_filled(body, 1.0, color32(*color)); + ui.painter().rect_stroke(body, 1.0, Stroke::new(1.0, border), StrokeKind::Outside); + } + edit +} + fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let t = Tokens::get(ui.ctx()); let mut e = Edit::default(); let mut stops = read_stops(v); + let selection = cx.mem.with("gm-stop-selection"); + let mut selected = ui.data(|d| d.get_temp::(selection)).unwrap_or(0).min(stops.len().saturating_sub(1)); // Photoshop's gradient picker: the same library as the Gradient tool and Gradients panel // (built-in groups and the user's own), Foreground/Background resolved to the current colours. let [fg, bg] = cx.swatches.map(|c| [c[0], c[1], c[2], 1.0]); @@ -1257,7 +1355,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let (press, close) = crate::press_menu::PressCombo::before(ui, cx.mem.with("gm-preset")); let combo = egui::ComboBox::from_id_salt(cx.mem.with("gm-preset")) .selected_text(tl!("Custom")) - .width(190.0) + .width(ui.available_width().min(190.0)) .height(420.0) .icon(widgets::chevron_icon) .close_behavior(close) @@ -1282,47 +1380,46 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { press.after(&combo.response); if let Some(p) = picked { stops = p; + selected = 0; + ui.data_mut(|d| d.remove::(cx.mem.with("gm-stop-drag"))); write_stops(v, &stops); e.add(Edit::discrete(true)); } }); - // The gradient. - let w = ui.available_width().min(300.0); - let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); - let reverse = flag(v, "reverse", false); - let cols: Vec = (0..=48) - .map(|i| { - let x = i as f32 / 48.0; - color32(sample_stops(&stops, if reverse { 1.0 - x } else { x })) - }) - .collect(); - spectrum_bar(ui.painter(), r, &cols); - ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - ui.add_space(4.0); - // Stops: colour, location, remove. - let mut remove = None; - let mut changed = false; - for (i, s) in stops.iter_mut().enumerate() { + let strip = gradient_map_strip(ui, &mut stops, cx, &mut selected); + let mut changed = strip.changed; + e.add(strip); + // The selected stop's colour and location, like the Gradient Tool's compact stop editor. + let mut location = None; + let mut remove = false; + let can_remove = stops.len() > 2; + if let Some(s) = stops.get_mut(selected) { ui.horizontal(|ui| { + label(ui, tl!("Color:")); let r = color_button(ui, &mut s.1); changed |= r.changed; e.add(r); + ui.add_enabled_ui(can_remove, |ui| { + remove = crate::icons::button(ui, "x", 20.0, false, tl!("Remove stop")).clicked(); + }); + }); + ui.horizontal(|ui| { + label(ui, tl!("Location:")); let mut loc = s.0 * 100.0; let rl = widgets::value_field(ui, &mut loc, 0.0..=100.0, "%", 70.0); if rl.changed() { - s.0 = (loc / 100.0).clamp(0.0, 1.0); + location = Some(loc / 100.0); changed = true; } e.add(Edit::of(&rl)); - if crate::icons::button(ui, "x", 20.0, false, tl!("Remove stop")).clicked() { - remove = Some(i); - } }); } - if let Some(i) = remove - && stops.len() > 2 - { - stops.remove(i); + if let Some(loc) = location { + selected = move_map_stop(&mut stops, selected, loc).unwrap_or(0); + } + if remove && can_remove { + stops.remove(selected); + selected = selected.min(stops.len() - 1); changed = true; e.add(Edit::discrete(true)); } @@ -1332,6 +1429,8 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { sorted.sort_by(|a, b| a.0.total_cmp(&b.0)); let at = sorted.windows(2).max_by(|a, b| (a[1].0 - a[0].0).total_cmp(&(b[1].0 - b[0].0))).map_or(0.5, |w| (w[0].0 + w[1].0) / 2.0); stops.push((at, sample_stops(&stops, at))); + let index = stops.len() - 1; + selected = move_map_stop(&mut stops, index, at).unwrap_or(0); changed = true; e.add(Edit::discrete(true)); } @@ -1339,6 +1438,7 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { write_stops(v, &stops); e.changed = true; } + ui.data_mut(|d| d.insert_temp(selection, selected)); ui.horizontal(|ui| { for (key, text) in [("reverse", tl!("Reverse")), ("dither", tl!("Dither"))] { let mut b = flag(v, key, false); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 3a591c4333..478b2de68d 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -449,6 +449,140 @@ fn map_stops(a: &Adjustment) -> Vec<(f32, [u8; 3])> { } } +fn map_handle(h: &Harness<'_, PhotocraftApp>, at: f32) -> Pos2 { + let strip = h.get_by_label("Gradient stops").rect(); + Pos2::new(strip.left() + 8.0 + at * (strip.width() - 16.0), strip.top() + 36.0) +} + +#[test] +fn gradient_map_handle_drag_previews_and_commits_once_after_crossing_stops() { + for depth in [8, 16, 32] { + let mut h = app_harness("gradientMap", "rgb", depth); + let (id, _) = layer(&h); + h.state_mut() + .run( + "layer.setAdjustment", + json!({ + "layer": id.0, "stops": [[0, "#000000"], [0.25, "#ff0000"], [0.5, "#00ff00"], [1, "#ffffff"]] + }), + ) + .unwrap(); + h.run_steps(3); + let before = layer(&h).1; + let steps = h.state().session.active().unwrap().history.past_len(); + let start = map_handle(&h, 0.25); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + for location in [0.4, 0.5, 0.6, 0.75] { + let to = map_handle(&h, location); + h.hover_at(to); + h.run_steps(1); + assert!(h.state().live_adjust.is_some(), "{depth}: live preview"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "{depth}: dragging records no history"); + } + let end = map_handle(&h, 0.8); + h.drop_at(end); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 0, 0]), (0.5, [0, 255, 0]), (0.8, [255, 0, 0]), (1.0, [255, 255, 255])]); + assert_eq!(h.ctx.data(|d| d.get_temp::(crate::adjust_editors::layer_mem(id).with("gm-stop-selection"))), Some(2)); + assert!(h.state().live_adjust.is_none()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + let after = layer(&h).1; + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(layer(&h).1, before); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(layer(&h).1, after); + } +} + +#[test] +fn gradient_map_strip_adds_selects_and_removes_stops_without_deleting_the_last_pair() { + let mut h = app_harness("gradientMap", "rgb", 8); + let (id, original) = layer(&h); + let steps = h.state().session.active().unwrap().history.past_len(); + let middle = map_handle(&h, 0.5); + click(&mut h, middle); + let added = map_stops(&layer(&h).1); + assert_eq!(added.len(), 3); + assert!((added[1].0 - 0.5).abs() < 0.001); + assert_eq!(added[1].1, [128, 128, 128]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + let first = map_handle(&h, 0.0); + click(&mut h, first); + assert_eq!(h.ctx.data(|d| d.get_temp::(crate::adjust_editors::layer_mem(id).with("gm-stop-selection"))), Some(0)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "selection is view state"); + click(&mut h, middle); + let remove = h.get_by_label("Remove stop").rect().center(); + click(&mut h, remove); + assert_eq!(layer(&h).1, original); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 2); + let first = map_handle(&h, 0.0); + click(&mut h, first); + let remove = h.get_by_label("Remove stop").rect().center(); + click(&mut h, remove); + assert_eq!(layer(&h).1, original, "keep two stops"); +} + +#[test] +fn gradient_map_drag_clamps_at_the_endpoints_and_reverse_keeps_stop_coordinates() { + let mut h = app_harness("gradientMap", "rgb", 8); + let reverse = label_rect(&h, "Reverse").unwrap().center(); + click(&mut h, reverse); + let first = map_handle(&h, 0.0); + let target = map_handle(&h, 0.3); + drag(&mut h, first, target); + assert!((map_stops(&layer(&h).1)[0].0 - 0.3).abs() < 0.001); + let target = map_handle(&h, 0.3); + drag(&mut h, target, first - vec2(60.0, 0.0)); + assert_eq!(map_stops(&layer(&h).1)[0].0, 0.0); + let last = map_handle(&h, 1.0); + let beyond = last + vec2(60.0, 0.0); + let before = layer(&h).1; + let steps = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, last, beyond); + assert_eq!(layer(&h).1, before); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps, "a clamped no-op makes no history"); +} + +#[test] +fn gradient_map_stop_controls_fit_a_narrow_properties_panel_in_every_theme() { + use crate::adjust_editors::{EditorCx, editor}; + for kind in crate::theme::ThemeKind::ALL { + for width in [240.0, 280.0] { + let mut h = Harness::builder().with_size(vec2(width, 360.0)).build_ui_state( + |ui, values: &mut serde_json::Value| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + let cx = EditorCx { + mem: egui::Id::new("map-layout"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: false, + dynamic_color_sliders: true, + }; + editor(ui, "gradientMap", values, &cx); + }, + json!({"stops": [[0, "#000000"], [1, "#ffffff"]]}), + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(4); + for role in [egui::accesskit::Role::Button, egui::accesskit::Role::ComboBox, egui::accesskit::Role::SpinButton] { + for control in h.query_all_by_role(role) { + let r = control.rect(); + assert!(r.left() >= 0.0 && r.right() <= width && r.width() >= 8.0, "{kind:?} width {width}: {role:?} {r:?}"); + } + } + } + } +} + /// Issue #1588: the Gradient Map preset picker offers the session's gradient library (built-in /// groups beyond Basics and the user's own saved gradients), and picking one writes its stops as /// one history step. From e4298f554be7fe0a20b9754dc3e8383fa44a33d2 Mon Sep 17 00:00:00 2001 From: kayrockscreenprinting Date: Mon, 12 Oct 2026 03:37:15 -0700 Subject: [PATCH 080/523] Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696) * Free Transform: layer styles render with the transformed layer (#3653), work in progress Co-Authored-By: Claude Opus 5.5 * Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) The live preview drew the moving pixels as a bare texture on a mesh, so Stroke, Drop Shadow and every other effect vanished while the box was open (and for type, shapes, groups and smart objects the style was baked into the texture, cut off at the layer's bounds and stretched with the box). A styled layer is now composited for the preview: the moving texels are resampled through the box and the style is laid over them by the CPU compositor's composite_with_effects, the code that draws the layer once the transform is applied. A stroke keeps its width when the box is enlarged and a shadow keeps its direction when it is turned, as after the commit. The composite is made at screen resolution (never finer than the document), with the style's sizes scaled to match, within a pixel budget from a measured cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest. It is rebuilt only when the box, the zoom or the layer changes. Still plain: the selected part of a styled layer, and Warp mode. Co-Authored-By: Claude Opus 5.5 * Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style) Co-Authored-By: Claude Opus 5.5 * Format the type warp texel read Co-Authored-By: Claude Opus 5.5 * Free Transform: Content-Aware Scale's preview has no styled composite Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this branch started; give it the new field so the merge builds. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/image_cmds.rs | 4 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/transform_fx.rs | 693 +++++++++++++++++++++ crates/ui-egui/src/transform_fx_tests.rs | 277 ++++++++ crates/ui-egui/src/transform_tex.rs | 2 +- crates/ui-egui/src/transform_tool.rs | 64 +- crates/ui-egui/src/transform_undo_tests.rs | 6 +- 7 files changed, 1039 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/transform_fx.rs create mode 100644 crates/ui-egui/src/transform_fx_tests.rs diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 93a9990bd9..f477bd220e 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -99,7 +99,9 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) { } } -pub(crate) fn scale_effects(fx: &mut Effects, k: f32) { +/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style › +/// Scale Effects, and previews that draw a style at a reduced resolution). +pub fn scale_effects(fx: &mut Effects, k: f32) { for e in &mut fx.items { match e { Effect::DropShadow(s) | Effect::InnerShadow(s) => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4dcc725697..1115b94962 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,7 @@ mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; +pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; mod type_font_preview; @@ -2270,6 +2271,9 @@ mod pencil_tests; #[cfg(test)] mod pixel_aspect_tests; +#[cfg(test)] +mod transform_fx_tests; + #[cfg(test)] mod transform_undo_tests; diff --git a/crates/ui-egui/src/transform_fx.rs b/crates/ui-egui/src/transform_fx.rs new file mode 100644 index 0000000000..3b56225efa --- /dev/null +++ b/crates/ui-egui/src/transform_fx.rs @@ -0,0 +1,693 @@ +//! Layer styles in the Free Transform live preview (#3653). +//! +//! The box previews the moving pixels as a texture on a mesh (transform_tex.rs), which leaves +//! the layer's style out: Stroke, Drop Shadow and the rest vanished while the box was open. +//! Baking the style into that texture would go wrong as soon as the box scales or turns, because +//! a style isn't transformed with its layer: it is drawn again from the layer's new shape (a +//! stroke keeps its width, a shadow its angle and distance). So a styled layer is composited +//! here instead. The moving texels are resampled through the box, the style is laid over them by +//! the CPU compositor's `composite_with_effects` (the code that draws the layer once the +//! transform is applied), and the result is drawn in place of the mesh. +//! +//! The work follows the screen, not the document: the composite is made at the resolution it is +//! shown at (never finer than the document), with the style's sizes scaled to match, and within +//! a pixel [`budget`] that keeps a rebuild near [`DRAG_MS`] while the pointer is down and near +//! [`REST_MS`] once the box rests. Past the budget the resolution drops instead of the frame +//! rate, and comes back when the pointer is released. It is rebuilt only when the box, the zoom +//! or the layer changes. +//! +//! The composite is drawn over transparency with Normal blending, like the mesh it replaces: a +//! layer's or an effect's blend mode shows against the layers under it only after the commit. + +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::{Color32, ColorImage, TextureHandle, TextureId, TextureOptions}; +use photocraft_algo::transform::Homography; +use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_compose::Buffer; +use photocraft_compose::effects::{self, TextureCtx}; +use photocraft_doc::{Document, Effect, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use crate::transform_tex::PreviewTextures; + +/// What a rebuild should take while the pointer is down (a drag rebuilds every frame), in +/// milliseconds on the machine [`pixel_cost_ns`] was measured on. +pub const DRAG_MS: f64 = 30.0; +/// What a rebuild should take once the box rests. +pub const REST_MS: f64 = 120.0; +/// The fewest work pixels a budget asks for, however costly the style. +pub const MIN_PIXELS: f64 = 0.25e6; +/// The most work pixels in a composite, whatever was asked for. +pub const MAX_PIXELS: f64 = 8.0e6; +/// Work coordinates stay within this, so rectangle arithmetic on them cannot overflow. +const COORD_LIMIT: f64 = 5.0e8; +/// At this many screen pixels per document pixel the composite is shown with crisp texels, +/// like the canvas around it. +const CRISP_ZOOM: f32 = 2.0; + +/// Where and how finely a styled preview is composited. Rectangles are in work pixels: document +/// pixels times `scale`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plan { + /// Work pixels per document pixel, at most 1. + pub scale: f64, + /// The box: the layer's bounds after the transform. + pub bounds: Rect, + /// What is composited and drawn: the bounds grown by the style's reach, inside the canvas. + pub shown: Rect, + /// The moving texels resampled for it: `shown` grown by twice the reach, so every effect + /// over `shown` sees all of the shape it depends on, also past the canvas edge. + pub content: Rect, +} + +impl Plan { + /// `shown` in document pixels: where the composite is drawn. + pub fn doc_rect(&self) -> [f32; 4] { + let k = 1.0 / self.scale; + [self.shown.x0, self.shown.y0, self.shown.x1, self.shown.y1].map(|v| (f64::from(v) * k) as f32) + } +} + +/// Rough cost of compositing one work pixel with `layer`'s style, in nanoseconds: measured per +/// effect at default settings on an Apple M3 Pro, release build. A Stroke costs the most by far +/// (two chamfer distance fields over the whole composite, 46 ns whatever its width); shadows and +/// glows build one blurred map (5 to 7 ns), Satin and Bevel & Emboss a little more (8 to 14 ns) +/// and overlays next to nothing. Resampling, laying the style on and the upload add about 12 ns. +pub fn pixel_cost_ns(layer: &Layer) -> f64 { + let enabled = || layer.effects.items.iter().filter(|e| e.enabled()); + let maps: f64 = enabled() + .map(|e| match e { + Effect::Stroke(_) | Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } | Effect::PatternOverlay { .. } => 1.0, + Effect::Satin(_) | Effect::BevelEmboss(_) => 12.0, + Effect::DropShadow(_) | Effect::InnerShadow(_) | Effect::OuterGlow(_) | Effect::InnerGlow(_) => 6.0, + }) + .sum(); + // Every Stroke reads the same two distance fields. + let fields = if enabled().any(|e| matches!(e, Effect::Stroke(_))) { 46.0 } else { 0.0 }; + 12.0 + maps + fields +} + +/// Work pixels a composite of `layer` may have: what fits in [`DRAG_MS`] while the pointer is +/// down and in [`REST_MS`] once the box rests. +pub fn budget(layer: &Layer, dragging: bool) -> f64 { + let ms = if dragging { DRAG_MS } else { REST_MS }; + (ms * 1.0e6 / pixel_cost_ns(layer)).clamp(MIN_PIXELS, MAX_PIXELS) +} + +/// The moving layer as the compositor styles it at `scale`: its style at that scale with its +/// opacity, fill and Advanced Blending, over plain pixels (the texels are rendered already, with +/// the masks applied). +pub fn work_layer(layer: &Layer, scale: f64) -> Layer { + let mut l = layer.clone(); + l.content = LayerContent::Raster(Surface::new(PixelFormat::RGBA8)); + l.mask = None; + l.vector_mask = None; + l.fill_cache = None; + l.blend = BlendMode::Normal; + photocraft_engine::image_cmds::scale_effects(&mut l.effects, scale as f32); + l.effects.reference = l.effects.reference.map(|(x, y)| (x * scale, y * scale)); + l +} + +/// Plan the composite of `layer` moved onto `quad` (document pixels, clockwise from top-left) in +/// a document of `canvas` pixels, shown at `zoom` screen pixels per document pixel, within +/// `budget` work pixels. `None` when nothing of it is on the canvas or the box isn't finite. +pub fn plan(layer: &Layer, canvas: [u32; 2], quad: [[f64; 2]; 4], zoom: f32, budget: f64) -> Option<(Plan, Layer)> { + if quad.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + let (min, max) = quad.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), q| ([lo[0].min(q[0]), lo[1].min(q[1])], [hi[0].max(q[0]), hi[1].max(q[1])])); + let budget = if budget.is_finite() { budget.clamp(1.0, MAX_PIXELS) } else { MIN_PIXELS }; + let mut scale = if zoom.is_finite() && zoom > 0.0 { f64::from(zoom).min(1.0) } else { 1.0 }; + // The reach of the style is in work pixels too, so the area isn't simply the scale squared: + // shrink until it fits (a few rounds settle it). + for round in 0..6 { + let work = work_layer(layer, scale); + let reach = effects::margin(&work); + let edge = |v: f64, up: bool| { + let v = (v * scale).clamp(-COORD_LIMIT, COORD_LIMIT); + (if up { v.ceil() } else { v.floor() }) as i32 + }; + let bounds = Rect::new(edge(min[0], false), edge(min[1], false), edge(max[0], true), edge(max[1], true)); + let canvas = Rect::new(0, 0, edge(f64::from(canvas[0]), true), edge(f64::from(canvas[1]), true)); + let shown = bounds.inflate(reach).intersect(&canvas); + if bounds.is_empty() || shown.is_empty() { + return None; + } + let content = shown.inflate(2 * reach); + let area = f64::from(content.width()) * f64::from(content.height()); + if area <= budget { + return Some((Plan { scale, bounds, shown, content }, work)); + } + if round == 5 { + break; + } + scale *= (budget / area).sqrt() * 0.98; + if !(scale.is_finite() && scale > 0.0) { + return None; + } + } + None +} + +/// One row of `out` per `y`, in parallel on native targets. +fn rows(out: &mut [T], width: usize, y0: i32, row: impl Fn(i32, &mut [T]) + Sync) { + bands(out, width, |i, o| row(y0.saturating_add(i as i32), o)); +} + +/// The moving texels (`image`, premultiplied, of which `uv` covers the box's source rectangle) +/// drawn over `rect` as straight-alpha floats. `to_unit` takes a work pixel to the unit square +/// of the source rectangle, and `inside` is a work point within the box. The edges of the +/// rectangle fade over one texel, as a resampled layer's do; `nearest` keeps texels whole. +pub fn resample(image: &ColorImage, uv: [f32; 2], to_unit: &Homography, inside: [f64; 2], rect: Rect, nearest: bool) -> Buffer { + let mut out = Buffer::transparent(rect); + let [iw, ih] = image.size; + let (tw, th) = (f64::from(uv[0]) * iw as f64, f64::from(uv[1]) * ih as f64); + let m = to_unit.0; + // The side of the horizon the box is on: a projective map sends the far side elsewhere. + let side = (m[6] * inside[0] + m[7] * inside[1] + m[8]).signum(); + if iw == 0 || ih == 0 || !(tw > 0.0 && th > 0.0) || !side.is_finite() { + return out; + } + let texel = |x: i64, y: i64| -> [f32; 4] { + let (x, y) = (x.clamp(0, iw as i64 - 1) as usize, y.clamp(0, ih as i64 - 1) as usize); + image.pixels.get(y * iw + x).map_or([0.0; 4], |c| c.to_array().map(f32::from)) + }; + let fade = |t: f64, n: f64| (t + 0.5).min(n - t + 0.5).clamp(0.0, 1.0) as f32; + let straight = |p: [f32; 4]| -> [f32; 4] { + let c = Color32::from_rgba_premultiplied(p[0].round() as u8, p[1].round() as u8, p[2].round() as u8, p[3].round() as u8); + let [r, g, b, _] = c.to_srgba_unmultiplied(); + // The alpha keeps its fraction: a soft edge would band at 8 bits. + [f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, (p[3] / 255.0).clamp(0.0, 1.0)] + }; + let width = rect.width() as usize; + rows(&mut out.px, width, rect.y0, |y, row| { + let py = f64::from(y) + 0.5; + for (i, o) in row.iter_mut().enumerate() { + let px = f64::from(rect.x0) + i as f64 + 0.5; + let w = m[6] * px + m[7] * py + m[8]; + if w * side <= 1e-12 { + continue; + } + let (tx, ty) = ((m[0] * px + m[1] * py + m[2]) / w * tw, (m[3] * px + m[4] * py + m[5]) / w * th); + if !(tx.is_finite() && ty.is_finite()) { + continue; + } + if nearest { + if tx >= 0.0 && ty >= 0.0 && tx < tw && ty < th { + *o = straight(texel(tx as i64, ty as i64)); + } + continue; + } + let cover = fade(tx, tw) * fade(ty, th); + if cover <= 0.0 { + continue; + } + let (fx, fy) = (tx - 0.5, ty - 0.5); + let (x0, y0) = (fx.floor(), fy.floor()); + let (ax, ay) = ((fx - x0) as f32, (fy - y0) as f32); + let (x0, y0) = (x0 as i64, y0 as i64); + let (a, b, c, d) = (texel(x0, y0), texel(x0 + 1, y0), texel(x0, y0 + 1), texel(x0 + 1, y0 + 1)); + let mut p = [0.0f32; 4]; + for (k, v) in p.iter_mut().enumerate() { + let (top, bottom) = (a[k] + (b[k] - a[k]) * ax, c[k] + (d[k] - c[k]) * ax); + *v = (top + (bottom - top) * ay) * cover; + } + if p[3] > 0.0 { + *o = straight(p); + } + } + }); + out +} + +/// The texels a composite reads and how they map onto the box. +pub struct Source<'a> { + /// The moving texels at some level of their mip chain, premultiplied. + pub image: &'a ColorImage, + /// The extent of `image` covering the box's source rectangle. + pub uv: [f32; 2], + /// The box (document pixels, clockwise from top-left). + pub quad: [[f64; 2]; 4], + /// Nearest Neighbor interpolation. + pub nearest: bool, +} + +/// Composite the moving texels with `work`'s style over `plan.shown`, on transparency. `work` +/// and `plan` come from [`plan`]; `doc` gives the global light and the patterns. +pub fn compose(doc: &Document, work: &Layer, plan: &Plan, source: &Source) -> Option { + let s = plan.scale; + let to_quad = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], source.quad.map(|q| [q[0] * s, q[1] * s]))?; + let to_unit = to_quad.inverse()?; + let inside = source.quad.iter().fold([0.0; 2], |c, q| [c[0] + q[0] * s / 4.0, c[1] + q[1] * s / 4.0]); + let content = resample(source.image, source.uv, &to_unit, inside, plan.content, source.nearest); + let shape: Vec = content.px.iter().map(|p| p[3]).collect(); + let anchor = work.effects.reference.unwrap_or((f64::from(plan.bounds.x0), f64::from(plan.bounds.y0))); + let maps = effects::build_maps(work, shape, plan.content, &doc.global_light, &TextureCtx { rect: plan.content, patterns: &doc.patterns, anchor }); + // The maps hold what every pixel needs of its surroundings, so the style is laid over the + // content band by band, in parallel, as the compositor does tile by tile. + let shown = plan.shown; + let width = shown.width() as usize; + let mut out = Buffer::transparent(shown); + bands(&mut out.px, width * BAND_ROWS, |i, px| { + let y0 = shown.y0.saturating_add((i * BAND_ROWS) as i32); + let band = Rect::new(shown.x0, y0, shown.x1, y0.saturating_add((px.len() / width.max(1)) as i32)); + let mut over = Buffer::transparent(band); + effects::composite_with_effects(work, &crop(&content, band), &mut over, &maps, plan.bounds, &doc.patterns); + for (o, p) in px.iter_mut().zip(over.px) { + *o = p; + } + }); + Some(out) +} + +/// Rows composited per parallel band. +const BAND_ROWS: usize = 64; + +/// `rect` of `buf` (transparent where `buf` doesn't reach). +fn crop(buf: &Buffer, rect: Rect) -> Buffer { + let mut out = Buffer::transparent(rect); + let part = rect.intersect(&buf.rect); + if part.is_empty() { + return out; + } + let (sw, dw, n) = (buf.rect.width() as usize, rect.width() as usize, part.width() as usize); + for y in part.y0..part.y1 { + let s0 = (y - buf.rect.y0) as usize * sw + (part.x0 - buf.rect.x0) as usize; + let d0 = (y - rect.y0) as usize * dw + (part.x0 - rect.x0) as usize; + if let (Some(src), Some(dst)) = (buf.px.get(s0..s0 + n), out.px.get_mut(d0..d0 + n)) { + dst.copy_from_slice(src); + } + } + out +} + +/// `out` in chunks of `len`, each with its index, in parallel on native targets. +fn bands(out: &mut [T], len: usize, band: impl Fn(usize, &mut [T]) + Sync) { + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + out.par_chunks_mut(len.max(1)).enumerate().for_each(|(i, o)| band(i, o)); + } + #[cfg(target_arch = "wasm32")] + for (i, o) in out.chunks_mut(len.max(1)).enumerate() { + band(i, o); + } +} + +/// `buf` as premultiplied texels. +fn image(buf: &Buffer) -> ColorImage { + let (w, h) = (buf.rect.width() as usize, buf.rect.height() as usize); + let mut px = vec![Color32::TRANSPARENT; w * h]; + rows(&mut px, w, 0, |y, row| { + let src = buf.px.get(y as usize * w..).unwrap_or(&[]); + for (o, p) in row.iter_mut().zip(src) { + let v = p.map(|c| (c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8); + *o = Color32::from_rgba_unmultiplied(v[0], v[1], v[2], v[3]); + } + }); + ColorImage::new([w, h], px) +} + +/// What a styled preview is drawn from this frame. +pub struct Frame<'a> { + /// The document: its size, global light and patterns. + pub doc: &'a Document, + /// The moving layer as it is now: its style, opacity, fill and Advanced Blending. + pub layer: &'a Layer, + /// Changes whenever `doc` does. + pub revision: u64, + pub texels: &'a PreviewTextures, + pub quad: [[f64; 2]; 4], + /// How much the box enlarges its source (1 = same size). + pub quad_scale: f32, + pub nearest: bool, + /// Screen pixels per document pixel. + pub zoom: f32, + /// The pointer is down: composite within the smaller [`budget`]. + pub dragging: bool, +} + +impl Frame<'_> { + /// The plan for this frame, the layer it composites and the texel level it reads. + pub fn plan(&self) -> Option<(Plan, Layer, u32)> { + let (plan, work) = plan(self.layer, [self.doc.size.width, self.doc.size.height], self.quad, self.zoom, budget(self.layer, self.dragging))?; + let level = PreviewTextures::choose(plan.scale as f32, self.quad_scale, self.nearest).0; + Some((plan, work, level)) + } + + /// The composite a [`Frame::plan`] describes. + pub fn compose(&self, plan: &Plan, work: &Layer, level: u32) -> Option { + let (texels, uv): (Arc, [f32; 2]) = self.texels.level_image(level)?; + compose(self.doc, work, plan, &Source { image: &texels, uv, quad: self.quad, nearest: self.nearest }) + } +} + +#[derive(Clone, PartialEq)] +struct Key { + revision: u64, + quad: [u64; 8], + plan: Plan, + level: u32, + nearest: bool, + crisp: bool, +} + +struct Shown { + key: Key, + tex: TextureHandle, + rect: [f32; 4], +} + +#[derive(Default)] +struct State { + shown: Option, + /// Composites made so far. + builds: u64, +} + +/// The styled composite of a Free Transform session's moving layer, kept until the box, the +/// zoom or the layer changes. +pub struct StyledPreview { + name: String, + state: Mutex, +} + +impl StyledPreview { + /// `name` keys the texture in egui. + pub fn new(name: String) -> Self { + Self { name, state: Mutex::new(State::default()) } + } + + /// How many times the composite was made: once per change of the box, the zoom or the layer. + pub fn builds(&self) -> u64 { + self.state.lock().unwrap_or_else(PoisonError::into_inner).builds + } + + /// The composite for `frame` and the document rectangle it covers (x0, y0, x1, y1), + /// compositing it again if anything it depends on changed. `None` when there is nothing to + /// draw this way: the caller falls back to the plain texels. + pub fn texture(&self, ctx: &egui::Context, frame: &Frame) -> Option<(TextureId, [f32; 4])> { + let (plan, work, level) = frame.plan()?; + let crisp = plan.scale >= 1.0 && frame.zoom >= CRISP_ZOOM; + let q = frame.quad.map(|q| q.map(f64::to_bits)); + let key = Key { + revision: frame.revision, + quad: [q[0][0], q[0][1], q[1][0], q[1][1], q[2][0], q[2][1], q[3][0], q[3][1]], + plan, + level, + nearest: frame.nearest, + crisp, + }; + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(s) = state.shown.as_ref().filter(|s| s.key == key) { + return Some((s.tex.id(), s.rect)); + } + let out = frame.compose(&plan, &work, level)?; + let options = if crisp { TextureOptions::NEAREST } else { TextureOptions::LINEAR }; + let tex = ctx.load_texture(self.name.clone(), image(&out), options); + let (id, rect) = (tex.id(), plan.doc_rect()); + state.shown = Some(Shown { key, tex, rect }); + state.builds += 1; + Some((id, rect)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::Color; + use photocraft_doc::{Contour, FxCommon, FxPaint, Shadow, StrokeFx, StrokePosition}; + + const BLUE: Color32 = Color32::from_rgb(0, 0, 255); + + fn stroke(size: f32) -> Effect { + Effect::Stroke(StrokeFx { + common: FxCommon::new(BlendMode::Normal, 1.0), + size, + position: StrokePosition::Outside, + paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), + }) + } + + fn shadow(distance: f32) -> Effect { + Effect::DropShadow(Shadow { + common: FxCommon::new(BlendMode::Normal, 1.0), + color: Color::rgb(0.0, 1.0, 0.0), + // Lit from the top-left: the shadow falls to the bottom-right. + angle: 135.0, + use_global_light: false, + distance, + spread: 1.0, + size: 0.0, + contour: Contour::Linear, + anti_alias: false, + noise: 0.0, + knocks_out: true, + }) + } + + fn styled(effects: Vec) -> Layer { + let mut l = Layer::raster("styled", PixelFormat::RGBA8); + l.effects.items = effects; + l + } + + fn doc() -> Document { + Document::new("t", photocraft_geom::Size::new(200, 200), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8) + } + + fn rect_quad(r: [f64; 4]) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] + } + + /// The composite of a blue `w`×`h` block of texels moved onto `quad`, and its plan. + fn composite(layer: &Layer, w: usize, h: usize, quad: [[f64; 2]; 4], zoom: f32) -> (Buffer, Plan) { + let texels = ColorImage::new([w, h], vec![BLUE; w * h]); + let (plan, work) = plan(layer, [200, 200], quad, zoom, budget(layer, false)).expect("on the canvas"); + let out = compose(&doc(), &work, &plan, &Source { image: &texels, uv: [1.0, 1.0], quad, nearest: false }).expect("composited"); + (out, plan) + } + + /// The composite at a document point. + fn at(out: &Buffer, plan: &Plan, x: f64, y: f64) -> [f32; 4] { + let (x, y) = ((x * plan.scale).floor() as i32, (y * plan.scale).floor() as i32); + if out.rect.contains(x, y) { out.get(x, y) } else { [0.0; 4] } + } + + fn is(p: [f32; 4], rgb: [f32; 3]) -> bool { + p[3] > 0.99 && p.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 0.02) + } + + const RED: [f32; 3] = [1.0, 0.0, 0.0]; + const GREEN: [f32; 3] = [0.0, 1.0, 0.0]; + const FILL: [f32; 3] = [0.0, 0.0, 1.0]; + + #[test] + fn the_style_is_drawn_around_the_moved_pixels() { + // Moved, not resized: the block at 60..100 × 80..100 with a 4 px stroke around it. + let l = styled(vec![stroke(4.0)]); + let (out, plan) = composite(&l, 40, 20, rect_quad([60.0, 80.0, 100.0, 100.0]), 1.0); + assert_eq!(plan.scale, 1.0); + assert!(is(at(&out, &plan, 80.0, 90.0), FILL), "the pixels themselves"); + assert!(is(at(&out, &plan, 60.0, 80.0), FILL) && is(at(&out, &plan, 99.0, 99.0), FILL), "exact at the corners: no resampling blur"); + for (x, y) in [(57.0, 90.0), (102.0, 90.0), (80.0, 77.0), (80.0, 102.0)] { + assert!(is(at(&out, &plan, x, y), RED), "stroke at {x},{y}: {:?}", at(&out, &plan, x, y)); + } + for (x, y) in [(54.0, 90.0), (105.0, 90.0), (80.0, 74.0), (80.0, 105.0)] { + assert_eq!(at(&out, &plan, x, y)[3], 0.0, "nothing past the stroke at {x},{y}"); + } + } + + #[test] + fn a_stroke_keeps_its_width_when_the_box_is_enlarged() { + // The 20×20 block enlarged four times: the stroke stays 4 px wide, not 16. + let l = styled(vec![stroke(4.0)]); + let (out, plan) = composite(&l, 20, 20, rect_quad([60.0, 60.0, 140.0, 140.0]), 1.0); + assert!(is(at(&out, &plan, 100.0, 100.0), FILL)); + assert!(is(at(&out, &plan, 57.0, 100.0), RED) && is(at(&out, &plan, 142.0, 100.0), RED)); + assert_eq!(at(&out, &plan, 53.0, 100.0)[3], 0.0, "{:?}", at(&out, &plan, 53.0, 100.0)); + assert_eq!(at(&out, &plan, 146.0, 100.0)[3], 0.0); + } + + #[test] + fn a_shadow_keeps_its_direction_when_the_box_is_turned() { + // Turned a half turn (the quad's corners swapped round): the shadow still falls to the + // bottom-right of the block, not to the top-left with the pixels. + let l = styled(vec![shadow(10.0)]); + let upright = rect_quad([80.0, 80.0, 120.0, 120.0]); + let turned = [upright[2], upright[3], upright[0], upright[1]]; + for quad in [upright, turned] { + let (out, plan) = composite(&l, 40, 40, quad, 1.0); + assert!(is(at(&out, &plan, 100.0, 100.0), FILL)); + assert!(is(at(&out, &plan, 124.0, 124.0), GREEN), "{:?}", at(&out, &plan, 124.0, 124.0)); + assert_eq!(at(&out, &plan, 76.0, 76.0)[3], 0.0, "no shadow on the lit side"); + } + } + + #[test] + fn a_zoomed_out_view_composites_at_screen_resolution() { + // At 50 % the work is a quarter of the pixels and the 4 px stroke is 2 work pixels, + // still 4 document pixels where it is drawn. + let l = styled(vec![stroke(4.0)]); + let (out, plan) = composite(&l, 40, 40, rect_quad([60.0, 60.0, 100.0, 100.0]), 0.5); + assert_eq!(plan.scale, 0.5); + assert_eq!(plan.bounds, Rect::new(30, 30, 50, 50)); + assert_eq!(out.rect, plan.shown); + assert!(is(at(&out, &plan, 80.0, 80.0), FILL)); + assert!(is(at(&out, &plan, 57.0, 80.0), RED) && is(at(&out, &plan, 103.0, 80.0), RED)); + assert_eq!(at(&out, &plan, 53.0, 80.0)[3], 0.0); + let r = plan.doc_rect(); + assert!(r[0] <= 56.0 && r[2] >= 104.0, "{r:?}"); + // Zoomed in, the work is never finer than the document. + assert_eq!(composite(&l, 40, 40, rect_quad([60.0, 60.0, 100.0, 100.0]), 8.0).1.scale, 1.0); + } + + #[test] + fn the_part_past_the_canvas_is_left_out_but_still_shapes_the_style() { + // The block hangs over the left edge: nothing is composited past the canvas, and no + // stroke is drawn along the canvas edge, where the layer goes on. + let l = styled(vec![stroke(4.0)]); + let (out, plan) = composite(&l, 40, 20, rect_quad([-20.0, 80.0, 20.0, 100.0]), 1.0); + assert_eq!(plan.shown.x0, 0); + assert!(is(at(&out, &plan, 0.0, 90.0), FILL), "{:?}", at(&out, &plan, 0.0, 90.0)); + assert!(is(at(&out, &plan, 22.0, 90.0), RED)); + // Wholly off the canvas: nothing to composite. + assert!(plan_of(&l, rect_quad([-90.0, 0.0, -50.0, 20.0])).is_none()); + } + + fn plan_of(l: &Layer, quad: [[f64; 2]; 4]) -> Option { + plan(l, [200, 200], quad, 1.0, budget(l, false)).map(|p| p.0) + } + + #[test] + fn opacity_and_fill_apply_as_in_the_compositor() { + // Fill 0 %: the pixels go, the stroke stays. Opacity 50 %: all of it at half strength. + let mut l = styled(vec![stroke(4.0)]); + l.fill_opacity = 0.0; + let (out, plan) = composite(&l, 40, 40, rect_quad([60.0, 60.0, 100.0, 100.0]), 1.0); + assert_eq!(at(&out, &plan, 80.0, 80.0)[3], 0.0); + assert!(is(at(&out, &plan, 57.0, 80.0), RED)); + l.fill_opacity = 1.0; + l.opacity = 0.5; + let (out, plan) = composite(&l, 40, 40, rect_quad([60.0, 60.0, 100.0, 100.0]), 1.0); + assert!((at(&out, &plan, 80.0, 80.0)[3] - 0.5).abs() < 0.01 && (at(&out, &plan, 57.0, 80.0)[3] - 0.5).abs() < 0.01); + } + + #[test] + fn the_work_stays_within_the_budget() { + // A box over a huge canvas at 100 %: the scale drops until the composite fits. + let l = styled(vec![stroke(8.0), shadow(40.0)]); + let quad = rect_quad([0.0, 0.0, 12_000.0, 8_000.0]); + for budget in [budget(&l, true), budget(&l, false)] { + let (p, work) = plan(&l, [12_000, 8_000], quad, 1.0, budget).expect("planned"); + let area = f64::from(p.content.width()) * f64::from(p.content.height()); + assert!(area <= budget && area > budget * 0.5, "{area} of {budget}"); + assert!(p.scale < 1.0); + // The style shrank with it. + let Some(Effect::Stroke(s)) = work.effects.items.first() else { panic!("stroke") }; + assert!((f64::from(s.size) - 8.0 * p.scale).abs() < 1e-3); + } + } + + #[test] + fn hostile_boxes_are_refused_not_composited() { + let l = styled(vec![stroke(4.0)]); + let ok = rect_quad([60.0, 60.0, 100.0, 100.0]); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut q = ok; + q[2][0] = bad; + assert!(plan_of(&l, q).is_none()); + } + // Astronomically large or far away: planned within the budget or refused, never a huge + // allocation or an overflow. + for q in [rect_quad([-1e300, -1e300, 1e300, 1e300]), rect_quad([1e18, 1e18, 1e18 + 40.0, 1e18 + 40.0]), rect_quad([0.0, 0.0, 1e12, 1.0])] { + if let Some(p) = plan_of(&l, q) { + assert!(f64::from(p.content.width()) * f64::from(p.content.height()) <= budget(&l, false), "{p:?}"); + } + } + // A collapsed box, a zero or NaN zoom and an absurd budget. + assert!(plan_of(&l, [[80.0, 80.0]; 4]).is_none()); + for zoom in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert_eq!(plan(&l, [200, 200], ok, zoom, budget(&l, false)).map(|p| p.0.scale), Some(1.0)); + } + for budget in [f64::NAN, f64::INFINITY, -5.0, 0.0] { + if let Some((p, _)) = plan(&l, [200, 200], ok, 1.0, budget) { + assert!(f64::from(p.content.width()) * f64::from(p.content.height()) <= MAX_PIXELS); + } + } + // A degenerate quad that passes the plan (a bow tie collapsed onto a line) composites + // to nothing instead of dividing by zero. + let line = [[60.0, 60.0], [100.0, 100.0], [60.0, 60.0], [100.0, 100.0]]; + if let Some((p, work)) = plan(&l, [200, 200], line, 1.0, budget(&l, false)) { + let texels = ColorImage::new([4, 4], vec![BLUE; 16]); + assert!(compose(&doc(), &work, &p, &Source { image: &texels, uv: [1.0, 1.0], quad: line, nearest: false }).is_none()); + } + } + + /// The style is laid on in parallel bands: the result is the one a single pass gives. + #[test] + fn bands_join_without_a_seam() { + let l = styled(vec![stroke(5.0), shadow(12.0)]); + let quad = [[40.0, 20.0], [150.0, 40.0], [130.0, 180.0], [20.0, 150.0]]; + let texels = ColorImage::new([30, 40], (0..1200).map(|i| if i % 7 == 0 { Color32::TRANSPARENT } else { BLUE }).collect()); + let (plan, work) = plan(&l, [200, 200], quad, 1.0, budget(&l, false)).expect("planned"); + assert!(plan.shown.height() as usize > 2 * BAND_ROWS, "{:?}", plan.shown); + let source = Source { image: &texels, uv: [1.0, 1.0], quad, nearest: false }; + let banded = compose(&doc(), &work, &plan, &source).expect("composited"); + // The same in one pass. + let to_unit = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], quad).and_then(|h| h.inverse()).expect("invertible"); + let content = resample(&texels, [1.0, 1.0], &to_unit, [85.0, 97.5], plan.content, false); + let shape = content.px.iter().map(|p| p[3]).collect(); + let anchor = (f64::from(plan.bounds.x0), f64::from(plan.bounds.y0)); + let maps = effects::build_maps(&work, shape, plan.content, &doc().global_light, &TextureCtx { rect: plan.content, patterns: &[], anchor }); + let mut whole = Buffer::transparent(plan.shown); + effects::composite_with_effects(&work, &crop(&content, plan.shown), &mut whole, &maps, plan.bounds, &[]); + assert_eq!(banded.rect, whole.rect); + assert!(banded.px == whole.px, "banded and whole composites differ"); + assert!(whole.px.iter().any(|p| is(*p, RED)) && whole.px.iter().any(|p| is(*p, GREEN)) && whole.px.iter().any(|p| is(*p, FILL))); + } + + #[test] + fn the_budget_follows_what_the_style_costs() { + // A drag gets a quarter of the time a resting box does, and a Stroke (two distance + // fields) far fewer pixels than a shadow. + let (stroked, shadowed) = (styled(vec![stroke(4.0)]), styled(vec![shadow(10.0)])); + assert!(budget(&stroked, true) < budget(&stroked, false)); + assert!((budget(&shadowed, false) / budget(&shadowed, true) - REST_MS / DRAG_MS).abs() < 1e-9); + assert!(budget(&stroked, true) * 3.0 < budget(&shadowed, true), "{} vs {}", budget(&stroked, true), budget(&shadowed, true)); + // Two strokes share their distance fields; an effect switched off costs nothing. + assert!(pixel_cost_ns(&styled(vec![stroke(4.0), stroke(9.0)])) < pixel_cost_ns(&stroked) + 2.0); + let mut off = styled(vec![stroke(4.0), shadow(10.0)]); + if let Some(Effect::Stroke(s)) = off.effects.items.first_mut() { + s.common.enabled = false; + } + assert_eq!(pixel_cost_ns(&off), pixel_cost_ns(&shadowed)); + // However heavy or light the style, the budget stays within its limits. + let heavy = styled((0..200).map(|_| shadow(10.0)).collect()); + assert_eq!(budget(&heavy, true), MIN_PIXELS); + assert_eq!(budget(&styled(Vec::new()), false), MAX_PIXELS); + } + + #[test] + fn resampling_handles_empty_texels_and_nearest_neighbor() { + let unit = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], rect_quad([0.0, 0.0, 8.0, 8.0])).and_then(|h| h.inverse()).expect("invertible"); + let r = Rect::new(-2, -2, 10, 10); + let empty = ColorImage::new([0, 0], Vec::new()); + assert!(resample(&empty, [1.0, 1.0], &unit, [4.0, 4.0], r, false).px.iter().all(|p| p[3] == 0.0)); + // A 2×2 checker enlarged four times: Nearest Neighbor keeps hard texels, bilinear blends. + let checker = ColorImage::new([2, 2], vec![Color32::WHITE, Color32::BLACK, Color32::BLACK, Color32::WHITE]); + let hard = resample(&checker, [1.0, 1.0], &unit, [4.0, 4.0], r, true); + assert_eq!(hard.get(3, 0), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(hard.get(4, 0), [0.0, 0.0, 0.0, 1.0]); + assert_eq!(hard.get(-1, 0)[3], 0.0); + let soft = resample(&checker, [1.0, 1.0], &unit, [4.0, 4.0], r, false); + let mid = soft.get(3, 0); + assert!(mid[0] > 0.2 && mid[0] < 0.8, "{mid:?}"); + // The uv extent limits what is read: half the texels, so only the left column shows. + let half = resample(&checker, [0.5, 1.0], &unit, [4.0, 4.0], r, true); + assert_eq!(half.get(7, 0), [1.0, 1.0, 1.0, 1.0]); + } +} diff --git a/crates/ui-egui/src/transform_fx_tests.rs b/crates/ui-egui/src/transform_fx_tests.rs new file mode 100644 index 0000000000..0eb860dadd --- /dev/null +++ b/crates/ui-egui/src/transform_fx_tests.rs @@ -0,0 +1,277 @@ +//! Free Transform previews a styled layer with its style (#3653), through the real session and +//! canvas: at every bit depth and colour mode, for pixel layers and groups, following edits made +//! while the box is open, and matching what the commit leaves. + +use photocraft_compose::Buffer; +use photocraft_doc::{Layer, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::transform_fx::Plan; + +/// A 200 × 160 document with a 40 × 20 blue block at (60, 60) on its own layer, wearing a 4 px +/// red outside Stroke. +fn app(mode: &str, depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 160, "mode": mode, "depth": depth})).unwrap(); + app.run("layer.new.layer", json!({"name": "styled"})).unwrap(); + app.run("select.rect", json!({"x": 60, "y": 60, "width": 40, "height": 20})).unwrap(); + app.run("edit.fill", json!({"color": "#0000ff"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("layer.layerStyle.stroke", json!({"size": 4, "color": "#ff0000", "position": "outside"})).unwrap(); + app.sync_views(); + app +} + +fn begin(app: &mut PhotocraftApp) { + crate::transform_tool::begin(app, &egui::Context::default()).unwrap(); +} + +/// What the box draws for the styled layer at `zoom`: `None` when it draws the bare pixels. +fn composite(app: &PhotocraftApp, zoom: f32) -> Option<(Buffer, Plan)> { + let (_, frame) = crate::transform_tool::styled_frame(app, zoom, false)?; + let (plan, work, level) = frame.plan()?; + Some((frame.compose(&plan, &work, level)?, plan)) +} + +/// The composite at a document point. +fn at(c: &(Buffer, Plan), x: f64, y: f64) -> [f32; 4] { + let (x, y) = ((x * c.1.scale).floor() as i32, (y * c.1.scale).floor() as i32); + if c.0.rect.contains(x, y) { c.0.get(x, y) } else { [0.0; 4] } +} + +fn set_quad(app: &mut PhotocraftApp, quad: [[f64; 2]; 4]) { + app.ui.transform.as_mut().unwrap().quad = quad; +} + +fn active(app: &PhotocraftApp) -> LayerId { + app.session.active().unwrap().active_layer.unwrap() +} + +fn edit_layer(app: &mut PhotocraftApp, f: impl FnOnce(&mut Layer)) { + let id = active(app); + app.session + .edit("edit", |doc, _| { + f(doc.layer_mut(id).unwrap()); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn the_style_shows_while_the_box_is_open_at_every_depth_and_mode() { + for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16), ("cmyk", 8), ("cmyk", 16), ("lab", 8)] { + let mut app = app(mode, depth); + begin(&mut app); + let c = composite(&app, 1.0).unwrap_or_else(|| panic!("{mode}/{depth}: styled")); + let (fill, stroke) = (at(&c, 80.0, 70.0), at(&c, 57.0, 70.0)); + assert!(fill[3] > 0.99 && stroke[3] > 0.99, "{mode}/{depth}: {fill:?} {stroke:?}"); + assert!(fill.iter().zip(stroke).any(|(a, b)| (a - b).abs() > 0.1), "{mode}/{depth}: the stroke has its own colour: {fill:?} {stroke:?}"); + if mode == "rgb" { + assert!(stroke[0] > 0.9 && stroke[2] < 0.1 && fill[2] > 0.9 && fill[0] < 0.1, "{depth}: {fill:?} {stroke:?}"); + } + assert_eq!(at(&c, 53.0, 70.0)[3], 0.0, "{mode}/{depth}: nothing past the stroke"); + // Twice the size about the middle: the block is 40..120 × 50..90 and the stroke is + // still 4 px, drawn round the new edge. + set_quad(&mut app, [[40.0, 50.0], [120.0, 50.0], [120.0, 90.0], [40.0, 90.0]]); + let c = composite(&app, 1.0).unwrap(); + assert!(at(&c, 80.0, 70.0)[3] > 0.99 && at(&c, 37.0, 70.0)[3] > 0.99 && at(&c, 122.0, 70.0)[3] > 0.99, "{mode}/{depth}"); + assert!(at(&c, 37.0, 70.0).iter().zip(stroke).all(|(a, b)| (a - b).abs() < 0.02), "{mode}/{depth}: the same stroke"); + assert_eq!(at(&c, 33.0, 70.0)[3], 0.0, "{mode}/{depth}: the stroke did not grow with the box"); + assert_eq!(at(&c, 126.0, 70.0)[3], 0.0, "{mode}/{depth}"); + } +} + +/// The preview is the layer as the commit leaves it: same stroke, same shadow, same place. +#[test] +fn the_preview_matches_the_committed_layer() { + for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8)] { + let mut app = app(mode, depth); + app.run("layer.layerStyle.dropShadow", json!({"distance": 9, "size": 4, "opacity": 80, "add": true})).unwrap(); + begin(&mut app); + // One and a half times the size, turned 20° about the middle of the block (80, 70). + let (sin, cos) = 20f64.to_radians().sin_cos(); + let quad = [[-30.0, -15.0], [30.0, -15.0], [30.0, 15.0], [-30.0, 15.0]].map(|[x, y]: [f64; 2]| [80.0 + x * cos - y * sin, 70.0 + x * sin + y * cos]); + set_quad(&mut app, quad); + let (shown, plan) = composite(&app, 1.0).unwrap(); + crate::transform_tool::commit(&mut app); + assert!(app.ui.transform.is_none(), "{mode}/{depth}: committed"); + // The committed layer alone, over transparency. + let st = app.session.active().unwrap(); + let mut solo = (*st.doc).clone(); + let id = st.active_layer.unwrap(); + for l in solo.layers.iter_mut() { + l.visible = l.id == id; + } + let committed = photocraft_compose::render(&solo, plan.shown); + let premultiplied = |p: &[f32; 4]| [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]; + let (mut sum, mut off) = (0.0f64, 0usize); + for (a, b) in shown.px.iter().zip(&committed.px) { + let d = premultiplied(a).iter().zip(premultiplied(b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + sum += f64::from(d); + off += usize::from(d > 0.25); + } + let n = shown.px.len(); + assert!(n > 4000, "{mode}/{depth}: {n}"); + // The commit resamples bicubic and the preview bilinear, so soft edges differ a little + // (a mean of 0.002 here); a stroke of another width or a shadow falling elsewhere would + // differ over whole bands. + assert!(sum / n as f64 <= 0.006, "{mode}/{depth}: mean difference {}", sum / n as f64); + assert!(off * 500 <= n, "{mode}/{depth}: {off} of {n} pixels differ"); + // Something is there to compare: fill, stroke and shadow. + assert!(committed.px.iter().filter(|p| p[3] > 0.5).count() > 2000, "{mode}/{depth}"); + } +} + +/// Groups (like type, shapes and smart objects) preview from a flattened render of the layer, +/// which is read without the layer's own style and opacity: the box applies them itself. +#[test] +fn a_styled_group_previews_with_its_style_once() { + let mut app = app("rgb", 8); + app.session + .edit("group", |doc, active| { + let id = active.unwrap(); + let at = doc.layers.iter().position(|l| l.id == id).unwrap(); + let mut child = doc.layers.remove(at); + let style = std::mem::take(&mut child.effects.items); + let mut group = Layer::group("group", vec![child]); + group.effects.items = style; + group.opacity = 0.5; + *active = Some(group.id); + doc.layers.insert(at, group); + Ok(()) + }) + .unwrap(); + begin(&mut app); + let pv = app.transform_preview.as_ref().unwrap(); + // The texels are the bare pixels over their own bounds: no stroke, no opacity yet. + assert_eq!(pv.texture.size(), [40, 20]); + let (texels, _) = pv.texture.level_image(0).unwrap(); + assert!(texels.pixels.iter().all(|p| *p == egui::Color32::from_rgb(0, 0, 255)), "{:?}", texels.pixels.first()); + // The composite has the stroke round them, all of it at the group's 50 %. + let c = composite(&app, 1.0).unwrap(); + let (fill, stroke) = (at(&c, 80.0, 70.0), at(&c, 57.0, 70.0)); + assert!((fill[3] - 0.5).abs() < 0.01 && fill[2] > 0.9, "{fill:?}"); + assert!((stroke[3] - 0.5).abs() < 0.01 && stroke[0] > 0.9, "{stroke:?}"); + assert_eq!(at(&c, 53.0, 70.0)[3], 0.0); +} + +#[test] +fn edits_made_while_the_box_is_open_show_in_the_styled_preview() { + let mut app = app("rgb", 8); + begin(&mut app); + assert!(composite(&app, 1.0).is_some()); + // Opacity. + edit_layer(&mut app, |l| l.opacity = 0.3); + let c = composite(&app, 1.0).unwrap(); + assert!((at(&c, 57.0, 70.0)[3] - 0.3).abs() < 0.01 && (at(&c, 80.0, 70.0)[3] - 0.3).abs() < 0.01); + // Fill 0 %: the pixels go and the stroke stays (the outline-only look). + edit_layer(&mut app, |l| { + l.opacity = 1.0; + l.fill_opacity = 0.0; + }); + let c = composite(&app, 1.0).unwrap(); + assert_eq!(at(&c, 80.0, 70.0)[3], 0.0); + assert!(at(&c, 57.0, 70.0)[3] > 0.99); + // A wider stroke. + app.run("layer.layerStyle.stroke", json!({"size": 10, "color": "#ff0000", "position": "outside"})).unwrap(); + assert!(at(&composite(&app, 1.0).unwrap(), 52.0, 70.0)[3] > 0.99); + // The style switched off, then the layer hidden: the box draws the bare pixels (or nothing). + edit_layer(&mut app, |l| l.effects.enabled = false); + assert!(composite(&app, 1.0).is_none()); + edit_layer(&mut app, |l| l.effects.enabled = true); + assert!(composite(&app, 1.0).is_some()); + edit_layer(&mut app, |l| l.visible = false); + assert!(composite(&app, 1.0).is_none()); +} + +#[test] +fn layers_without_a_style_and_other_boxes_keep_the_plain_preview() { + // No style: nothing to composite, also if one is added once the box is open (its texels + // were not read for it). + let mut plain = app("rgb", 8); + edit_layer(&mut plain, |l| l.effects.items.clear()); + begin(&mut plain); + assert!(composite(&plain, 1.0).is_none()); + plain.run("layer.layerStyle.stroke", json!({"size": 4, "color": "#ff0000"})).unwrap(); + assert!(composite(&plain, 1.0).is_none()); + // The selected part of a styled layer: its style belongs to that part and the rest together. + let mut part = app("rgb", 8); + part.run("select.rect", json!({"x": 60, "y": 60, "width": 20, "height": 20})).unwrap(); + begin(&mut part); + assert_eq!(part.ui.transform.as_ref().unwrap().rect, [60.0, 60.0, 80.0, 80.0]); + assert!(composite(&part, 1.0).is_none()); + // Transform Selection moves an outline, not the layer. + let mut outline = app("rgb", 8); + outline.run("select.rect", json!({"x": 60, "y": 60, "width": 20, "height": 20})).unwrap(); + crate::transform_tool::begin_selection(&mut outline, &egui::Context::default()).unwrap(); + assert!(composite(&outline, 1.0).is_none()); + // Warp mode draws its own mesh. + let mut warp = app("rgb", 8); + crate::transform_tool::begin_warp(&mut warp, &egui::Context::default()).unwrap(); + assert!(composite(&warp, 1.0).is_none()); +} + +#[test] +fn the_work_follows_the_zoom_and_never_the_document_size() { + let mut app = app("rgb", 8); + begin(&mut app); + // Zoomed out to 25 %: a sixteenth of the pixels. + let (full, half) = (composite(&app, 1.0).unwrap(), composite(&app, 0.25).unwrap()); + assert_eq!(full.1.scale, 1.0); + assert_eq!(half.1.scale, 0.25); + assert!(half.0.px.len() * 8 < full.0.px.len(), "{} vs {}", half.0.px.len(), full.0.px.len()); + assert!(at(&half, 80.0, 70.0)[3] > 0.99 && at(&half, 57.0, 70.0)[3] > 0.99 && at(&half, 50.0, 70.0)[3] == 0.0); + // Zoomed in: the document's own pixels, no finer. + assert_eq!(composite(&app, 16.0).unwrap().1.scale, 1.0); + // A box dragged out to an absurd size stays within the budget. + set_quad(&mut app, [[-4.0e6, -4.0e6], [4.0e6, -4.0e6], [4.0e6, 4.0e6], [-4.0e6, 4.0e6]]); + if let Some((buf, _)) = composite(&app, 1.0) { + assert!(buf.px.len() as f64 <= crate::transform_fx::MAX_PIXELS); + } + // A box that isn't a box: the bare pixels, no panic. + set_quad(&mut app, [[f64::NAN; 2]; 4]); + assert!(composite(&app, 1.0).is_none()); + set_quad(&mut app, [[70.0, 70.0]; 4]); + assert!(composite(&app, 1.0).is_none()); +} + +/// Through the real canvas: the composite is made once per change, not once per frame, the box +/// still drags, undoes and commits as before, and the committed layer keeps its style. +#[test] +fn the_canvas_draws_the_styled_preview_and_reuses_it_between_frames() { + let mut h = crate::transform_undo_tests::harness(); + h.state_mut().run("layer.layerStyle.stroke", json!({"size": 4, "color": "#00ff00", "position": "outside"})).unwrap(); + h.run_steps(2); + let steps = h.state().session.active().unwrap().history.entries().len(); + crate::transform_undo_tests::begin(&mut h); + let builds = |h: &egui_kittest::Harness<'static, PhotocraftApp>| crate::transform_tool::styled_frame(h.state(), 1.0, false).unwrap().0.builds(); + assert_eq!(builds(&h), 1, "composited when the box opened"); + h.run_steps(5); + assert_eq!(builds(&h), 1, "and kept while nothing changes"); + // Drag the bottom-right corner out: the composite follows the box. + crate::transform_undo_tests::drag(&mut h, [200.0, 160.0], [260.0, 196.0]); + let after_drag = builds(&h); + assert!(after_drag > 1, "{after_drag}"); + let quad = h.state().ui.transform.as_ref().unwrap().quad; + assert!(quad[2][0] > 250.0, "{quad:?}"); + h.run_steps(5); + assert_eq!(builds(&h), after_drag, "at rest again"); + // Zooming rebuilds it for the new scale. + h.state_mut().ui.views[0].zoom = 0.5; + h.run_steps(2); + assert_eq!(builds(&h), after_drag + 1); + // Undo steps the box back (not the document), and the composite follows. + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(2); + assert_eq!(h.state().ui.transform.as_ref().unwrap().quad[2], [200.0, 160.0]); + assert_eq!(h.state().session.active().unwrap().history.entries().len(), steps); + assert_eq!(builds(&h), after_drag + 2); + // Enter commits: one history step, the style still on the layer. + h.key_press(egui::Key::Enter); + h.run_steps(3); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + let st = app.session.active().unwrap(); + assert!(photocraft_compose::effects::has_effects(st.doc.layer(st.active_layer.unwrap()).unwrap())); +} diff --git a/crates/ui-egui/src/transform_tex.rs b/crates/ui-egui/src/transform_tex.rs index e1ddb504bf..bd84704542 100644 --- a/crates/ui-egui/src/transform_tex.rs +++ b/crates/ui-egui/src/transform_tex.rs @@ -90,7 +90,7 @@ impl PreviewTextures { } /// Level `n` of the mip chain (built on demand by 2×2 box filtering) and its uv extent. - fn level_image(&self, n: u32) -> Option<(Arc, [f32; 2])> { + pub(crate) fn level_image(&self, n: u32) -> Option<(Arc, [f32; 2])> { let mut imgs = self.images.lock().unwrap_or_else(PoisonError::into_inner); while imgs.len() <= n as usize { let prev = imgs.last()?; diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 6d1c22886a..84d1031bb6 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -64,6 +64,9 @@ pub struct TransformPreview { pub opacity: f32, /// The layers stacked over the moving one, drawn over its pixels ([`Above`]). above: Option, + /// A layer with a style: its pixels composited with the style as the box has them now, + /// drawn instead of the bare pixels (transform_fx.rs, #3653). + styled: Option, gesture: Option, /// Warp-mode drag in progress. warp_drag: Option, @@ -213,7 +216,10 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> // The layers covering the moving pixels draw over them instead of under them. let above = Above::new(ctx, format!("transform-{session}-above"), &doc, id, max_side); above.hide_in(&mut pd.layers); - let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side); + // A style is drawn again from the pixels as the box moves them, so they are read without it. + // Not for the selected part of a layer: its style belongs to that part and the rest together. + let styled = lifted.is_none() && photocraft_compose::effects::has_effects(layer); + let (image, uv) = preview_image(&doc, id, lifted.as_ref(), b, max_side, styled); let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv); app.transform_preview = Some(TransformPreview { session, @@ -221,6 +227,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> texture, opacity: layer.opacity * layer.fill_opacity, above: Some(above), + styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), follow: Some(Follow { revision, hidden: lifted.is_none() }), gesture: None, warp_drag: None, @@ -330,6 +337,7 @@ pub(crate) fn begin_content_aware( tool: app.ui.tool, path: None, content_aware: Some(live), + styled: None, }); app.ui.transform = Some(TransformSession { session, @@ -430,6 +438,7 @@ fn begin_lone( texture, opacity: 0.6, above: None, + styled: None, follow: None, gesture: None, warp_drag: None, @@ -479,6 +488,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: doc, texture, opacity: 1.0, + styled: None, gesture: None, warp_drag: None, split_tool: None, @@ -562,6 +572,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( texture, opacity: 1.0, above: None, + styled: None, follow: None, gesture: None, warp_drag: None, @@ -681,7 +692,8 @@ fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { && let Some(pv) = app.transform_preview.as_mut() { let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); - let (image, uv) = preview_image(&plain, id, None, b, max_side); + // Read as the session's texels were: without the style when the box draws it itself. + let (image, uv) = preview_image(&plain, id, None, b, max_side, pv.styled.is_some()); pv.texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv); } let warp = @@ -717,13 +729,15 @@ pub fn leave_warp(app: &mut PhotocraftApp) { /// Texels of the moving pixels over `b`: full resolution unless the long side exceeds the GPU's /// `max_side` (#91: this used to be capped at 2048 px, so big layers previewed blurry), plus the -/// uv extent covering `b`. +/// uv extent covering `b`. `bare` leaves the layer's own style, opacity and fill out of a +/// flattened render: the styled preview applies them to the moved pixels itself. fn preview_image( doc: &Document, id: LayerId, lifted: Option<&photocraft_raster::Surface>, b: photocraft_geom::Rect, max_side: usize, + bare: bool, ) -> (egui::ColorImage, [f32; 2]) { let layer = doc.layer(id); // Linked masks move with the pixels, so the moving texels carry them (#205). @@ -740,6 +754,11 @@ fn preview_image( l.visible = false; } } + if let Some(l) = solo.layer_mut(id).filter(|_| bare) { + l.effects.enabled = false; + l.opacity = 1.0; + l.fill_opacity = 1.0; + } let buf = photocraft_compose::render(&solo, b); let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8); let flat: Vec = buf.px.iter().flat_map(|p| *p).collect(); @@ -1912,7 +1931,17 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform draw_warp(painter, xf, t, w, pv, guide); return; } - if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { + let ctx = painter.ctx(); + let styled = styled_frame(app, xf.zoom * ctx.pixels_per_point(), ctx.input(|i| i.pointer.any_down())).and_then(|(s, frame)| s.texture(ctx, &frame)); + if let Some((tex, r)) = styled { + let mut mesh = egui::Mesh::with_texture(tex); + for (x, y, u, v) in [(r[0], r[1], 0.0, 0.0), (r[2], r[1], 1.0, 0.0), (r[2], r[3], 1.0, 1.0), (r[0], r[3], 0.0, 1.0)] { + mesh.vertices.push(egui::epaint::Vertex { pos: pos2(x, y), uv: pos2(u, v), color: Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(clip_to_canvas(mesh, canvas_size(pv), |p| xf.to_screen(p.x, p.y))); + } else if let Some(h) = Homography::rect_to_quad([0.0, 0.0, 1.0, 1.0], t.quad) { // A 24×24 grid keeps perspective previews straight. let n = 24; let zoom = xf.zoom * painter.ctx().pixels_per_point(); @@ -1984,6 +2013,29 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } } +/// What the styled preview of the moving layer is drawn from (transform_fx.rs), at `zoom` screen +/// pixels per document pixel. `None` for a layer without a style (also once it is switched off +/// while the box is open) or a hidden one: the bare pixels are drawn instead. The style, opacity +/// and fill are the layer's as they are now, so edits made while the box is open show. +pub(crate) fn styled_frame(app: &PhotocraftApp, zoom: f32, dragging: bool) -> Option<(&crate::transform_fx::StyledPreview, crate::transform_fx::Frame<'_>)> { + let (t, pv) = (app.ui.transform.as_ref()?, app.transform_preview.as_ref()?); + let styled = pv.styled.as_ref().filter(|_| t.warp.is_none())?; + let st = app.session.active().filter(|st| st.doc.id == pv.doc.id)?; + let layer = st.doc.layer(LayerId(t.layer)).filter(|l| l.visible && photocraft_compose::effects::has_effects(l))?; + let frame = crate::transform_fx::Frame { + doc: &st.doc, + layer, + revision: st.revision, + texels: &pv.texture, + quad: t.quad, + quad_scale: quad_scale(t), + nearest: t.interpolation == "nearest", + zoom, + dragging, + }; + Some((styled, frame)) +} + /// The value shown beside the pointer while the box is dragged, like Photoshop's Show /// Transformation Values: the box's angle while rotating, its scale while resizing, the offset /// while moving. @@ -4318,7 +4370,7 @@ mod tests { let st = app.session.active().unwrap(); let id = st.active_layer.unwrap(); let b = photocraft_geom::Rect::new(8, 8, 24, 24); - let (img, _) = preview_image(&st.doc, id, None, b, 4096); + let (img, _) = preview_image(&st.doc, id, None, b, 4096, false); // Texel x = doc x − 8: x 10 is hidden by the mask, x 20 shows. assert_eq!(texel_alpha(&img, 12, 4), 255); assert_eq!(texel_alpha(&img, 2, 4), if linked { 0 } else { 255 }, "linked={linked}"); @@ -4333,7 +4385,7 @@ mod tests { let id = st.active_layer.unwrap(); let (lifted, _) = photocraft_engine::transform_cmds::split_selected(st.doc.layer(id).unwrap().surface().unwrap(), &sel).unwrap(); let b = photocraft_geom::Rect::new(8, 8, 20, 24); - let (img, _) = preview_image(&st.doc, id, Some(&lifted), b, 4096); + let (img, _) = preview_image(&st.doc, id, Some(&lifted), b, 4096, false); assert_eq!(texel_alpha(&img, 2, 4), 0, "masked"); assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed"); } diff --git a/crates/ui-egui/src/transform_undo_tests.rs b/crates/ui-egui/src/transform_undo_tests.rs index 7db8cce561..2944456c88 100644 --- a/crates/ui-egui/src/transform_undo_tests.rs +++ b/crates/ui-egui/src/transform_undo_tests.rs @@ -12,7 +12,7 @@ use crate::state::Tool; /// A 400 × 300 document with a 100 × 60 red rectangle on its own layer at (100, 100), the Move /// tool active, at 100 %. -fn harness() -> Harness<'static, PhotocraftApp> { +pub(crate) fn harness() -> Harness<'static, PhotocraftApp> { harness_tool(Tool::Move) } @@ -63,7 +63,7 @@ fn screen(h: &Harness<'static, PhotocraftApp>, p: [f64; 2]) -> Pos2 { } /// Press at document point `from`, drag to `to` in a few steps and release. -fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f64; 2], to: [f64; 2]) { +pub(crate) fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f64; 2], to: [f64; 2]) { let (a, b) = (screen(h, from), screen(h, to)); h.event(egui::Event::PointerMoved(a)); h.run_steps(1); @@ -77,7 +77,7 @@ fn drag(h: &mut Harness<'static, PhotocraftApp>, from: [f64; 2], to: [f64; 2]) { h.run_steps(3); } -fn begin(h: &mut Harness<'static, PhotocraftApp>) { +pub(crate) fn begin(h: &mut Harness<'static, PhotocraftApp>) { let ctx = h.ctx.clone(); crate::menus::invoke(h.state_mut(), &ctx, "edit.freeTransform", json!({})).unwrap(); h.run_steps(2); From 0dbc28866796a34e6ec3ec44b3418727964f27f8 Mon Sep 17 00:00:00 2001 From: Scott Macdonald Date: Mon, 12 Oct 2026 06:49:25 -0400 Subject: [PATCH 081/523] fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678) * fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent. Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices. Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8). Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or enlarge the source still take the exact single sample, so identity and unscaled warps are bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh warp's scale varies across the surface: one reduction factor over-blurs the parts that stay large or under-filters the parts that shrink most. Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop exports of real mockups (RMSE of the placed design): 1.78% -> 0.81% (White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged. Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at 1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period. Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7): 1/3 size: 55 ms -> 87 ms 1/1 size: 240 ms -> 239 ms (no subsampling: identical path) Co-authored-by: Claude Opus 5.5 * Use as_chunks for the opaque-pixel scan (clippy 1.99) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/algo/src/warp.rs | 107 ++++++++++++++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 14 deletions(-) diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index 36d4a313dc..2ee8b191c9 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -6,7 +6,9 @@ //! interpolated barycentrically, then the source is sampled there (premultiplied, nearest / //! bilinear / Catmull-Rom bicubic, like Free Transform). Cells are a few source pixels across, so //! the piecewise-affine inverse is within a small fraction of a pixel of the true one; affine -//! maps (the identity included) are reproduced exactly. Where the warp folds over itself the +//! maps (the identity included) are reproduced exactly. Where the warp shrinks the source, each +//! pixel instead averages a grid of bilinear subsamples over its source footprint (about one per +//! source pixel), so thin lines don't alias. Where the warp folds over itself the //! later (lower-right) cell wins. Work is split per destination tile (parallel on native). use photocraft_color::PixelFormat; @@ -25,6 +27,9 @@ fn catmull_rom(t: f64) -> [f64; 4] { const CELL: f64 = 4.0; /// Most cells along one axis (bounds memory and time on huge layers). const MAX_CELLS: usize = 512; +/// Most subsamples per axis when a warp shrinks the source; also the longest source step per +/// destination pixel the filter follows (bounds the footprint each tile reads). +const MAX_SS: f64 = 8.0; /// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document: /// through `warp` (source space) and then the affine `t` (source → document) in one resampling @@ -172,14 +177,37 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let w = t.width() as usize; let h = t.height() as usize; - // Source position per destination pixel (NaN = not covered). + // Subsamples along one destination axis, from the source step `j` it makes: one where + // the warp keeps or enlarges the source, else about one per source pixel the output pixel + // spans (an area average, as the transform path gets from its pre-reduction; a single + // bicubic sample aliases thin lines when shrinking). The subsamples are bilinear: the + // box does the filtering, and it is ~4x cheaper than bicubic at no loss. + let count = |j: [f64; 2]| if interp == Interp::Nearest { 1 } else { j[0].hypot(j[1]).round().clamp(1.0, MAX_SS) as usize }; + // Source position per destination pixel (NaN = not covered) and the triangle it came + // from. Per triangle: its source step per destination pixel along x and y (`[dx_u, dx_v, + // dy_u, dy_v]`, the inverse map's Jacobian, constant over a triangle; zero when + // point-sampled) and the subsamples along each. let mut uv = vec![[f64::NAN; 2]; w * h]; - for tri in tris { + let mut owner = vec![0u32; w * h]; + let mut jac: Vec<([f64; 4], usize, usize)> = Vec::with_capacity(tris.len()); + for (k, tri) in tris.iter().enumerate() { let [(p0, s0), (p1, s1), (p2, s2)] = tri.map(|v| verts[v]); let det = (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1]); if det.abs() < 1e-12 { + jac.push(([0.0; 4], 1, 1)); continue; } + let (d1, d2) = ([s1[0] - s0[0], s1[1] - s0[1]], [s2[0] - s0[0], s2[1] - s0[1]]); + let (l1x, l1y, l2x, l2y) = ((p2[1] - p0[1]) / det, (p0[0] - p2[0]) / det, (p0[1] - p1[1]) / det, (p1[0] - p0[0]) / det); + // Capped, so a sliver triangle can't ask for an unbounded footprint. + let cap = |j: [f64; 2]| { + let l = j[0].hypot(j[1]); + if l > MAX_SS { [j[0] * MAX_SS / l, j[1] * MAX_SS / l] } else { j } + }; + let jx = cap([l1x * d1[0] + l2x * d2[0], l1x * d1[1] + l2x * d2[1]]); + let jy = cap([l1y * d1[0] + l2y * d2[0], l1y * d1[1] + l2y * d2[1]]); + let (nx, ny) = (count(jx), count(jy)); + jac.push(if nx * ny == 1 { ([0.0; 4], 1, 1) } else { ([jx[0], jx[1], jy[0], jy[1]], nx, ny) }); let bx0 = (p0[0].min(p1[0]).min(p2[0]) - 0.5).floor().max(f64::from(t.x0)) as i32; let by0 = (p0[1].min(p1[1]).min(p2[1]) - 0.5).floor().max(f64::from(t.y0)) as i32; let bx1 = (p0[0].max(p1[0]).max(p2[0]) + 0.5).ceil().min(f64::from(t.x1)) as i32; @@ -197,16 +225,18 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } let o = (y - t.y0) as usize * w + (x - t.x0) as usize; uv[o] = [l0 * s0[0] + l1 * s1[0] + l2 * s2[0], l0 * s0[1] + l1 * s1[1] + l2 * s2[1]]; + owner[o] = k as u32; } } } - // Source footprint of the covered pixels. + // Source footprint of the covered pixels, including the spread of their subsamples. let (mut fx0, mut fy0, mut fx1, mut fy1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for q in uv.iter().filter(|q| !q[0].is_nan()) { - fx0 = fx0.min(q[0]); - fy0 = fy0.min(q[1]); - fx1 = fx1.max(q[0]); - fy1 = fy1.max(q[1]); + for (q, j) in uv.iter().zip(&owner).filter(|(q, _)| !q[0].is_nan()).map(|(q, &k)| (q, jac[k as usize].0)) { + let (ex, ey) = (0.5 * (j[0].abs() + j[2].abs()), 0.5 * (j[1].abs() + j[3].abs())); + fx0 = fx0.min(q[0] - ex); + fy0 = fy0.min(q[1] - ey); + fx1 = fx1.max(q[0] + ex); + fy1 = fy1.max(q[1] + ey); } if fx0 > fx1 { return None; @@ -233,11 +263,9 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 let mut outp = vec![0.0f32; w * h * n]; let mut any = false; let mut acc = [0.0f64; 8]; - for (o, q) in uv.iter().enumerate() { - if q[0].is_nan() { - continue; - } - let (u, v) = (q[0] - 0.5, q[1] - 0.5); + let mut one = [0.0f64; 8]; + // One premultiplied sample at source position (u, v) (pixel centres at integers). + let point = |interp: Interp, u: f64, v: f64, acc: &mut [f64; 8]| { acc[..n].fill(0.0); match interp { Interp::Nearest => { @@ -276,6 +304,31 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 } } } + }; + for (o, (q, &k)) in uv.iter().zip(&owner).enumerate() { + if q[0].is_nan() { + continue; + } + let (j, nx, ny) = jac[k as usize]; + if nx * ny == 1 { + point(interp, q[0] - 0.5, q[1] - 0.5, &mut acc); + } else { + acc[..n].fill(0.0); + for sy in 0..ny { + let t = (sy as f64 + 0.5) / ny as f64 - 0.5; + for sx in 0..nx { + let s = (sx as f64 + 0.5) / nx as f64 - 0.5; + point(Interp::Bilinear, q[0] + s * j[0] + t * j[2] - 0.5, q[1] + s * j[1] + t * j[3] - 0.5, &mut one); + for (c, v) in acc.iter_mut().zip(&one).take(n) { + *c += v; + } + } + } + let k = 1.0 / (nx * ny) as f64; + for c in acc.iter_mut().take(n) { + *c *= k; + } + } let al = acc[a].clamp(0.0, 1.0); // Float noise (a vertex a hair off the pixel grid) must not leave invisible // colour behind in transparent pixels. @@ -407,6 +460,32 @@ mod tests { assert!(c.y0 < r.y0 - 2 && c.x0 < r.x0 && c.x1 > r.x1, "the top rises and spreads past the box: {c:?}"); } + #[test] + fn a_shrinking_mesh_warp_is_prefiltered() { + // A warped smart object placed at ~1/5 size: a grid of 1 px lines every 4 px must come + // out its area average (9/16 white) everywhere, not point-sampled moiré (crinkled, + // doubled or missing lines), like the affine path's `large_downscale_is_prefiltered`. + for s in [SampleType::U8, SampleType::U16, SampleType::F32] { + let (w, h) = (400, 320); + let mut src = Surface::new(PixelFormat::new(ColorMode::Rgb, s, true)); + let px: Vec = + (0..h).flat_map(|y| (0..w).map(move |x| if x % 4 == 0 || y % 4 == 0 { 0.0 } else { 1.0 })).flat_map(|v| [v, v, v, 1.0]).collect(); + let r = Rect::new(0, 0, w, h); + src.write_region(r, &px); + let warp = Warp::preset(WarpStyle::Arc, 15.0, [0.0, 0.0, f64::from(w), f64::from(h)]); + let t = photocraft_geom::Affine { m: [0.2, 0.0, 0.0, 0.2, 7.0, 5.0] }; + let out = place_source(&src, r, &t, Some(&warp)).unwrap(); + let b = out.content_bounds(); + let o = out.read_region(b); + let opaque: Vec = o.as_chunks::<4>().0.iter().filter(|p| p[3] >= 0.999).map(|p| p[0]).collect(); + assert!(opaque.len() > 2000, "{s:?}: {} opaque pixels", opaque.len()); + let worst = opaque.iter().map(|v| (v - 0.5625).abs()).fold(0.0f32, f32::max); + // An area average over the ~5 px footprint still covers one or two lines per axis + // (up to 0.2 off); point sampling lands wholly on or off a line (up to 0.56 off). + assert!(worst < 0.25, "{s:?}: worst deviation from the area average {worst}"); + } + } + #[test] fn gray_masks_keep_their_default() { let mut m = Surface::with_default(PixelFormat::GRAY8, &[1.0]); From 4705fc59805c3a848a0d4a24781514cb875db5bb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:01:20 +0700 Subject: [PATCH 082/523] feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700) * feat(io): open Krita .kra composites as editable raster documents (#3692) * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 7 ++- crates/io/src/kra.rs | 108 ++++++++++++++++++++++++++++++++ crates/io/src/lib.rs | 10 +++ 3 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 crates/io/src/kra.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 8342baa257..0b1a366a69 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -20,9 +20,9 @@ mod recovery_tests; /// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", - "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", + "pgm", "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", + "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -43,6 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { ("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), ("OpenRaster", open_filter_extensions(&["ora"])), + ("Krita", open_filter_extensions(&["kra"])), ("SVG", open_filter_extensions(SVG_EXTS)), ] } diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs new file mode 100644 index 0000000000..a3af8b119c --- /dev/null +++ b/crates/io/src/kra.rs @@ -0,0 +1,108 @@ +//! Krita .kra import: the merged PNG is an editable raster layer, not a replacement +//! for Krita's native layer tree. Never write this document back to the .kra source. +//! The ZIP reader checks entry bounds, sizes and CRC before the PNG decoder runs. + +use photocraft_codecs::{self as codecs, DecodeOptions, Format}; +use photocraft_format::zip::ZipReader; +use photocraft_raster::Interrupt; + +use crate::{ImportResult, IoError}; + +const MIMETYPE: &[u8] = b"application/x-krita"; +const MAX_ENTRY: usize = 512 << 20; +const MAX_PIXELS_BYTES: u64 = 1 << 30; + +fn invalid(message: impl Into) -> IoError { + IoError::Kra(message.into()) +} + +/// Krita writes its mimetype as the first, uncompressed ZIP entry. +pub(crate) fn is_kra(bytes: &[u8]) -> bool { + crate::ora::zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE) +} + +/// Import Krita's official merged preview as a flattened, editable PhotoCraft document. +/// Do not silently pretend this imported a layered or lossless Krita document. +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + ctl.check().map_err(|_| IoError::Cancelled)?; + let archive = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; + let mime = archive.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; + if mime.trim_ascii() != MIMETYPE { + return Err(invalid("the archive's mimetype is not application/x-krita")); + } + ctl.progress(0.10); + let merged = archive.read_by_name("mergedimage.png", MAX_ENTRY).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; + if codecs::detect(&merged) != Some(Format::Png) { + return Err(invalid("mergedimage.png is not a PNG image")); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut opts = DecodeOptions::default(); + opts.limits.max_width = crate::ora::MAX_SIDE; + opts.limits.max_height = crate::ora::MAX_SIDE; + opts.limits.max_alloc = MAX_PIXELS_BYTES; + let image = codecs::decode_with(&merged, &opts).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; + ctl.check().map_err(|_| IoError::Cancelled)?; + let mut result = crate::flat::image_to_document(name, &image)?; + result.source_read_only = true; + result.warnings.push("Krita layers, masks and editable document structure were not imported; only mergedimage.png is available".into()); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType}; + use photocraft_format::zip::ZipWriter; + + fn png() -> Vec { + let image = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap(); + codecs::encode(&image, Format::Png, &EncodeOptions::default()).unwrap() + } + + fn archive(mime: &[u8], with_merged: bool) -> Vec { + let mut zip = ZipWriter::new(); + zip.add("mimetype", mime).unwrap(); + zip.add("maindoc.xml", b"").unwrap(); + if with_merged { + zip.add("mergedimage.png", &png()).unwrap(); + } + zip.finish().unwrap() + } + + #[test] + fn imports_krita_composite_without_claiming_to_preserve_layers() { + let bytes = archive(MIMETYPE, true); + assert!(is_kra(&bytes)); + // The mimetype is authoritative even without a .kra extension. + for name in ["scene.kra", "download"] { + let r = crate::import(name, &bytes).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (2, 1)); + assert_eq!(r.document.name, name); + assert_eq!(r.document.layers.len(), 1); + assert_eq!(r.document.layers[0].name, "Layer 0", "alpha-bearing composite"); + let pixels = r.document.layers[0].surface().unwrap(); + assert!(pixels.rgba(0, 0)[0] > 0.99); + assert!(pixels.rgba(1, 0)[2] > 0.99); + assert!((pixels.rgba(1, 0)[3] - 128.0 / 255.0).abs() < 0.01); + assert!(r.source_read_only, ".kra must not be overwritten by a flat save"); + assert!(!r.preview_only, "the flattened image is editable"); + assert!(r.warnings.iter().any(|w| w.contains("layers") && w.contains("not imported"))); + } + } + + #[test] + fn malformed_or_non_krita_archives_do_not_open_as_krita() { + let wrong = archive(b"image/openraster", true); + assert!(!is_kra(&wrong)); + assert!(import("wrong.kra", &wrong, &Interrupt::NONE).is_err()); + assert!(import("missing.kra", &archive(MIMETYPE, false), &Interrupt::NONE).is_err()); + assert!(import("broken.kra", b"not a zip", &Interrupt::NONE).is_err()); + } + + #[test] + fn a_png_with_a_misleading_kra_extension_remains_a_png() { + let r = crate::import("renamed.kra", &png()).unwrap(); + assert_eq!(r.document.size.width, 2); + assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 9ea361994b..0a9acbc19a 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,6 +18,7 @@ //! * Paint.NET PDN3 documents open as editable bitmap layers (import only). //! * OpenRaster (`.ora`) documents open and save with their layers, groups, blend modes, //! opacity and visibility; see `ora`. +//! * Krita (`.kra`) documents open as their merged composite; source layers are not imported. //! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively //! with no source save path, what isn't imported listed in the warnings; a file //! whose native data can't be read opens as its embedded preview; see `affinity`. @@ -41,6 +42,7 @@ pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +mod kra; pub mod linked; mod multichannel_map; mod ora; @@ -80,6 +82,9 @@ pub enum IoError { /// OpenRaster read failure. #[error("OpenRaster: {0}")] Ora(String), + /// Krita document decode failure. + #[error("Krita: {0}")] + Kra(String), /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), @@ -268,6 +273,11 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if ora::is_ora(bytes) || has_extension(name, "ora") { return ora::import(name, bytes, ctl); } + // Krita files are ZIP containers carrying a merged PNG. A recognized raster signature + // still wins over a misleading .kra suffix (as with SVG imports). + if kra::is_kra(bytes) || (has_extension(name, "kra") && photocraft_codecs::detect(bytes).is_none()) { + return kra::import(name, bytes, ctl); + } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { return pdn::import(name, bytes, ctl); } From 6547ca0b8459ef37e1dfb373cc00a81740ed74a4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 07:02:10 -0400 Subject: [PATCH 083/523] fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031) - DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged. - Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths, EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while they match the channels), except the ones that only describe layers, plus the document-level tagged blocks (4-byte padded). Path writing and global-block padding are shared with the layered writer. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/multichannel.rs | 15 ++- crates/io/src/channel_map.rs | 10 +- crates/io/src/multichannel_map.rs | 60 ++++++++++-- crates/io/src/psd_export.rs | 127 ++++++++++++++------------ crates/io/tests/multichannel.rs | 142 +++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 70 deletions(-) create mode 100644 crates/io/tests/multichannel.rs diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 63bec04bf6..88c0b2f359 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -3,7 +3,8 @@ //! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity //! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is -//! how Photoshop previews spot channels. +//! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document +//! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. use photocraft_doc::Document; use photocraft_geom::Rect; @@ -15,7 +16,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); for ch in &doc.channels { - let Some((ink, solidity)) = ch.spot else { continue }; + let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); if ch.surface.content_bounds().intersect(&rect).is_empty() { continue; } @@ -65,7 +66,15 @@ mod tests { assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); - // Alpha channels and other modes are not inks. + // An alpha-type channel prints as a black ink. + let mut k = Surface::new(fmt); + k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]); + doc.channels.push(AlphaChannel::new("Alpha", k)); + let r = crate::flatten(&doc); + // 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step. + assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]); + assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); + // Other modes have no inks. doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); } diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index d3630bf35a..bafde26f30 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -1,10 +1,10 @@ //! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007). //! //! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then -//! one 14-byte record per extra channel: colour space (u16), four u16 colour components, opacity -//! (u16, 0..100), kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") areas, -//! 2 = spot channel) and one padding byte. Resource 1007 has the same records without the -//! version and without the spot kind. +//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity +//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") +//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot +//! kind, each followed by a padding byte (14 bytes). use photocraft_color::{Color, ColorMode}; use photocraft_doc::{AlphaChannel, ColorIndicates}; @@ -59,6 +59,8 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan match rec[12] { 2 => c.spot = Some((color, opacity)), k => { + // An alpha channel, not an ink (a Multichannel import starts every channel as one). + c.spot = None; c.color = color; c.opacity = opacity; c.indicates = if k == 0 { ColorIndicates::SelectedAreas } else { ColorIndicates::MaskedAreas }; diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index d20a70efcd..db31e743ef 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -9,13 +9,21 @@ use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; use photocraft_geom::Rect; -use photocraft_psd::file::LayerInfoPlacement; +use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement}; use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource}; -use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile}; +use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile, TaggedBlock}; use photocraft_raster::Surface; use crate::pixels::{encode_be, psd_depth}; +/// Grid and guides. +const GRID_AND_GUIDES: u16 = 1032; +/// Alpha identifiers: one u32 per extra channel. +const ALPHA_IDENTIFIERS: u16 = 1053; +/// Resources that only describe layers: target layer (1024), layer groups (1026), layer +/// selection ids (1069) and layer groups enabled (1072). A Multichannel document has no layers. +const LAYER_ONLY_RESOURCES: [u16; 4] = [ids::LAYER_STATE, ids::LAYER_GROUP_INFO, 1069, 1072]; + /// Value of sample `i` of a big-endian plane. fn decode(plane: &[u8], i: usize, s: SampleType) -> f32 { match s { @@ -44,7 +52,8 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St s.write_region(canvas, &vals); s.prune(); let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Channel {}", k + 1)); - // Without DisplayInfo the channels print in black, as Photoshop shows them. + // Without DisplayInfo the channels print in black, as Photoshop shows them. DisplayInfo + // turns alpha-type channels (selected / masked areas) back into alpha channels. doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) }); } if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) { @@ -97,17 +106,54 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< let image_data = ImageData::encode(Compression::Rle, &planes, &header) .or_else(|_| ImageData::encode(Compression::Raw, &planes, &header)) .unwrap_or(ImageData { compression: Compression::Raw, data: planes }); - let mut resources = vec![ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes())]; + let mut resources = vec![ + ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()), + ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()), + ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()), + ]; + // Grid and guides: Photoshop writes it in every file, guides or not. + resources.push(ImageResource::new(GRID_AND_GUIDES, crate::psd_export::guides_resource(doc))); if !channels.is_empty() { let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect(); resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names))); resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names))); resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false))); } + resources.extend(crate::psd_export::path_resources(doc, &mut warnings)); + // The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it. + let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0); if let Some(x) = &doc.metadata.xmp { - resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec())); + resources.push(ImageResource::new(ids::XMP, photocraft_codecs::export_xmp(x, ppi).as_bytes().to_vec())); + } + if let Some(e) = &doc.metadata.exif { + resources.push(ImageResource::new(ids::EXIF, photocraft_codecs::export_exif(e, ppi).into_owned())); + } + // Every other imported resource (print settings, IPTC, the layer-id seed, ...), except the + // ones that only describe layers and any already written above. + let written: Vec = resources.iter().map(|r| r.id).collect(); + for (id, name, data) in &doc.metadata.psd_resources { + if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) { + continue; + } + // Alpha identifiers: one u32 per channel, only while they still match the channels. + if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) { + continue; + } + let mut r = ImageResource::new(*id, data.to_vec()); + r.name = crate::psd_export::legacy_name(name); + resources.push(r); + } + if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) { + resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data)); } resources.push(version_info_resource(true)); + // Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a + // kept layer block would describe layers the document doesn't have. + let global_blocks: Vec = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc)) + .into_iter() + .filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32")) + .map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data)) + .collect(); let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth); let file = PsdFile { header, @@ -115,8 +161,8 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< resources, layer_info: None, layer_info_placement: LayerInfoPlacement::Section, - global_layer_mask: None, - global_blocks: Vec::new(), + global_layer_mask: (!global_blocks.is_empty()).then(GlobalLayerMask::default), + global_blocks, layer_mask_trailing: Vec::new(), image_data, }; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 1c43a36457..3d8d637eb4 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1053,7 +1053,7 @@ fn effects_unchanged(l: &Layer) -> Option { Some(m == l.effects.enabled && items == l.effects.items) } -fn legacy_name(s: &str) -> Vec { +pub(crate) fn legacy_name(s: &str) -> Vec { s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect() } @@ -1079,7 +1079,7 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec { v } -fn guides_resource(doc: &Document) -> Vec { +pub(crate) fn guides_resource(doc: &Document) -> Vec { let mut v = Vec::new(); v.extend_from_slice(&1u32.to_be_bytes()); v.extend_from_slice(&576u32.to_be_bytes()); @@ -1095,6 +1095,72 @@ fn guides_resource(doc: &Document) -> Vec { v } +/// Path resources: saved paths (2000+), the work path (1025) and the clipping path name (2999), +/// the last only when the imported one doesn't already name the document's clipping path. +pub(crate) fn path_resources(doc: &Document, warnings: &mut Vec) -> Vec { + use crate::vector_map::{CLIPPING_PATH, WORK_PATH, path_from_resource, path_to_records}; + let mut out = Vec::new(); + let (w, h) = (doc.size.width, doc.size.height); + let max = (*crate::vector_map::SAVED_PATHS.end() - *crate::vector_map::SAVED_PATHS.start() + 1) as usize; + if doc.paths.len() > max { + warnings.push(format!("only {max} saved paths fit in PSD; the rest were dropped")); + } + for (i, p) in doc.paths.iter().take(max).enumerate() { + let data = match &p.psd_raw { + Some(r) if path_from_resource(r, w, h).as_ref() == Some(&p.path) => r.to_vec(), + _ => path_to_records(&p.path, w, h).to_bytes(), + }; + let mut r = ImageResource::new(crate::vector_map::SAVED_PATHS.start() + i as u16, data); + r.name = legacy_name(&p.name); + out.push(r); + } + if let Some(wp) = &doc.work_path { + out.push(ImageResource::new(WORK_PATH, path_to_records(wp, w, h).to_bytes())); + } + let raw_clip = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == CLIPPING_PATH); + let raw_name = raw_clip.map(|(_, _, d)| { + let n = usize::from(d.first().copied().unwrap_or(0)); + d.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default() + }); + if let Some(c) = &doc.clipping_path + && raw_name.as_deref() != Some(c.name.as_str()) + { + // Pascal name, then flatness as 16.16 fixed (best effort; see vector_map docs). + let mut data = vec![0u8]; + let name = legacy_name(&c.name); + data[0] = name.len() as u8; + data.extend_from_slice(&name); + data.extend_from_slice(&((c.flatness.max(0.0) * 65536.0) as u32).to_be_bytes()); + out.push(ImageResource::new(CLIPPING_PATH, data)); + } + out +} + +/// Whether an imported (unmodelled) resource still describes the document and is written back. +pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bool { + // Measurement scale / count resources: only while they still describe the document. + if !crate::annotations_map::keep_resource(doc, id) { + return false; + } + // A preserved clipping-path resource is only valid while it names the current one. + if id == crate::vector_map::CLIPPING_PATH { + let n = usize::from(data.first().copied().unwrap_or(0)); + let raw_name = data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); + return doc.clipping_path.as_ref().is_some_and(|c| c.name == raw_name); + } + true +} + +/// A document-level (global) tagged block. Photoshop pads these to a multiple of 4, and readers +/// such as psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes) +/// misaligned every block after it (#200). +pub(crate) fn global_block(signature: [u8; 4], key: [u8; 4], data: &[u8]) -> TaggedBlock { + let mut tb = TaggedBlock::new(key, data.to_vec()); + tb.signature = signature; + tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]); + tb +} + fn cmyk_of(fmt: &PixelFormat) -> bool { fmt.mode == ColorMode::Cmyk } @@ -1467,43 +1533,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c data.push(0); resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data)); } - // Paths: saved paths (2000+), the work path (1025), the clipping path name (2999). - { - use crate::vector_map::{CLIPPING_PATH, WORK_PATH, path_from_resource, path_to_records}; - let (w, h) = (doc.size.width, doc.size.height); - let max = (*crate::vector_map::SAVED_PATHS.end() - *crate::vector_map::SAVED_PATHS.start() + 1) as usize; - if doc.paths.len() > max { - ex.warnings.push(format!("only {max} saved paths fit in PSD; the rest were dropped")); - } - for (i, p) in doc.paths.iter().take(max).enumerate() { - let data = match &p.psd_raw { - Some(r) if path_from_resource(r, w, h).as_ref() == Some(&p.path) => r.to_vec(), - _ => path_to_records(&p.path, w, h).to_bytes(), - }; - let mut r = ImageResource::new(crate::vector_map::SAVED_PATHS.start() + i as u16, data); - r.name = legacy_name(&p.name); - resources.push(r); - } - if let Some(wp) = &doc.work_path { - resources.push(ImageResource::new(WORK_PATH, path_to_records(wp, w, h).to_bytes())); - } - let raw_clip = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == CLIPPING_PATH); - let raw_name = raw_clip.map(|(_, _, d)| { - let n = usize::from(d.first().copied().unwrap_or(0)); - d.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default() - }); - if let Some(c) = &doc.clipping_path - && raw_name.as_deref() != Some(c.name.as_str()) - { - // Pascal name, then flatness as 16.16 fixed (best effort; see vector_map docs). - let mut data = vec![0u8]; - let name = legacy_name(&c.name); - data[0] = name.len() as u8; - data.extend_from_slice(&name); - data.extend_from_slice(&((c.flatness.max(0.0) * 65536.0) as u32).to_be_bytes()); - resources.push(ImageResource::new(CLIPPING_PATH, data)); - } - } + resources.extend(path_resources(doc, &mut ex.warnings)); if let Some(groups) = link_group_resource(&doc.layers) { resources.push(ImageResource::new(ids::LAYER_GROUP_INFO, groups)); } @@ -1548,13 +1578,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - let mut tb = TaggedBlock::new(key, data.to_vec()); - tb.signature = sig; - // Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as - // psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes) - // misaligned every block after it (#200). - tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]); - global_blocks.push(tb); + global_blocks.push(global_block(sig, key, &data)); } if let Some(txt2) = txt2 { let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone()); @@ -1576,18 +1600,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if *id == crate::slices_map::SLICES && slices_resource.is_some() { continue; } - // Measurement scale / count resources: only while they still describe the document. - if !crate::annotations_map::keep_resource(doc, *id) { + if !keep_imported_resource(doc, *id, data) { continue; } - // A preserved clipping-path resource is only valid while it names the current one. - if *id == crate::vector_map::CLIPPING_PATH { - let n = usize::from(data.first().copied().unwrap_or(0)); - let raw_name = data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); - if doc.clipping_path.as_ref().is_none_or(|c| c.name != raw_name) { - continue; - } - } let mut r = ImageResource::new(*id, data.to_vec()); r.name = legacy_name(name); resources.push(r); diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/multichannel.rs new file mode 100644 index 0000000000..d6016070d1 --- /dev/null +++ b/crates/io/tests/multichannel.rs @@ -0,0 +1,142 @@ +//! Multichannel PSDs: alpha-type channels and the document's resources and global blocks survive +//! an import → export → import round trip. + +use photocraft_doc::{ColorIndicates, Knot, NamedPath, Path, PathOp, Subpath}; +use photocraft_io::*; +use photocraft_psd::resources::ImageResource; +use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; + +/// DisplayInfo (1077) record: RGB colour, opacity (0..100) and kind (0/1 alpha, 2 spot). +fn display_record(out: &mut Vec, rgb: [u16; 3], opacity: u16, kind: u8) { + out.extend_from_slice(&0u16.to_be_bytes()); + for v in [rgb[0], rgb[1], rgb[2], 0] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.extend_from_slice(&opacity.to_be_bytes()); + out.push(kind); +} + +/// A two-channel Multichannel file: a cyan spot ink and an alpha channel (masked areas, red 50 %). +fn file(depth: u16) -> PsdFile { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 9, 5); + let mut info = 1u32.to_be_bytes().to_vec(); + display_record(&mut info, [0, 65535, 65535], 0, 2); + display_record(&mut info, [65535, 0, 0], 50, 1); + f.resources.push(ImageResource::new(1077, info)); + f +} + +fn roundtrip(d: &photocraft_doc::Document) -> (PsdFile, photocraft_doc::Document) { + let out = export(d, "x.psd", &ExportOptions::default()).unwrap(); + let back = PsdFile::from_bytes(&out.bytes).unwrap(); + let (doc, _) = psd_to_document(&back); + (back, doc) +} + +#[test] +fn alpha_type_channels_import_as_alpha_and_round_trip() { + for depth in [8, 16] { + let f = file(depth); + let (d, _) = psd_to_document(&f); + assert_eq!(d.channels.len(), 2); + assert_eq!(d.channels[0].spot.map(|(c, _)| c.to_rgb()), Some([0.0, 1.0, 1.0])); + let a = &d.channels[1]; + assert_eq!(a.spot, None, "an alpha-type channel is not a spot ink ({depth}-bit)"); + assert_eq!(a.color.to_rgb(), [1.0, 0.0, 0.0]); + assert!((a.opacity - 0.5).abs() < 1e-6); + assert_eq!(a.indicates, ColorIndicates::MaskedAreas); + + // It still prints as black ink, so the composite is what a black spot channel gives. + let mut as_ink = d.clone(); + as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); + assert_eq!(photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + + // Saved back unchanged: same DisplayInfo, same planes, same channels on reopening. + let (back, d2) = roundtrip(&d); + assert_eq!(back.resource(1077).map(|r| r.data.clone()), f.resource(1077).map(|r| r.data.clone())); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap()); + assert_eq!(d2.channels[1].spot, None); + assert_eq!(d2.channels[1].indicates, ColorIndicates::MaskedAreas); + assert!((d2.channels[1].opacity - 0.5).abs() < 1e-6); + assert!(d2.channels[0].spot.is_some()); + } +} + +#[test] +fn resources_and_global_blocks_survive_a_save() { + let mut f = file(8); + let iptc = vec![0x1c, 2, 0, 0, 2, 0, 4]; + let print_flags = vec![0, 0, 0, 0, 0, 0, 0, 1, 0]; + let seed = 7u32.to_be_bytes().to_vec(); + let alpha_ids = [0u32, 1].iter().flat_map(|v| v.to_be_bytes()).collect::>(); + f.resources.push(ImageResource::new(1028, iptc.clone())); + f.resources.push(ImageResource::new(1011, print_flags.clone())); + f.resources.push(ImageResource::new(1044, seed.clone())); + f.resources.push(ImageResource::new(1053, alpha_ids.clone())); + // A target-layer index describes layers, which a Multichannel document doesn't have. + f.resources.push(ImageResource::new(1024, vec![0, 0])); + let fmsk = vec![0, 0, 0, 0, 0, 50]; + f.global_blocks.push(TaggedBlock::new(*b"FMsk", fmsk.clone())); + let (mut d, _) = psd_to_document(&f); + d.guides.horizontal.push(2.0); + d.guides.vertical.push(3.0); + d.global_light.angle = 60.0; + let path = + Path::new(vec![Subpath { closed: true, op: PathOp::Combine, knots: vec![Knot::corner(1.0, 1.0), Knot::corner(8.0, 1.0), Knot::corner(4.0, 4.0)] }]); + d.paths.push(NamedPath { name: "Outline".into(), path, psd_raw: None }); + + let (back, d2) = roundtrip(&d); + let res = |id: u16| back.resource(id).map(|r| r.data.clone()); + assert_eq!(res(1028), Some(iptc)); + assert_eq!(res(1011), Some(print_flags)); + assert_eq!(res(1044), Some(seed)); + assert_eq!(res(1053), Some(alpha_ids)); + assert_eq!(res(1024), None); + let blocks: Vec<_> = back.global_blocks.iter().filter(|b| &b.key == b"FMsk").collect(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].data, fmsk); + assert_eq!(d2.guides.horizontal, vec![2.0]); + assert_eq!(d2.guides.vertical, vec![3.0]); + assert_eq!(d2.global_light.angle, 60.0); + // The file's own saved path and the new one. + let names: Vec<&str> = d2.paths.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Path 1", "Outline"]); + let anchors = |p: &Path| p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| (k.anchor.x, k.anchor.y))).collect::>(); + let (got, want) = (anchors(&d2.paths[1].path), anchors(&d.paths[1].path)); + assert_eq!(got.len(), want.len()); + assert!(got.iter().zip(&want).all(|(a, b)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3), "{got:?}"); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap()); + + // Alpha identifiers that no longer match the channels are not written. + d.channels.pop(); + let (back, _) = roundtrip(&d); + assert!(back.resource(1053).is_none()); +} + +/// A Photoshop Multichannel file: its alpha-type channel stays an alpha channel and a save keeps +/// every resource except the thumbnail (regenerated) and every document-level block. +#[cfg(feature = "corpus")] +#[test] +fn corpus_multichannel_round_trip_keeps_channels_and_resources() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/psd-tools/colormodes/4x4_16bit_multichannel.psd"); + assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display()); + let bytes = std::fs::read(&path).unwrap(); + let f = PsdFile::from_bytes(&bytes).unwrap(); + let (d, _) = psd_to_document(&f); + let alpha: Vec<_> = d.channels.iter().filter(|c| c.spot.is_none()).collect(); + assert!(!alpha.is_empty(), "the file's alpha-type channel imports as an alpha channel"); + for a in &alpha { + assert_eq!(a.indicates, ColorIndicates::MaskedAreas); + assert_eq!(a.color.to_rgb(), [1.0, 0.0, 0.0]); + assert!((a.opacity - 0.5).abs() < 1e-6); + } + let (back, d2) = roundtrip(&d); + assert_eq!(back.resource(1077).map(|r| r.data.clone()), f.resource(1077).map(|r| r.data.clone())); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap()); + assert_eq!(d2.channels.iter().filter(|c| c.spot.is_none()).count(), alpha.len()); + let mut lost: Vec = f.resources.iter().map(|r| r.id).filter(|id| back.resource(*id).is_none()).collect(); + lost.retain(|id| *id != 1036); + assert!(lost.is_empty(), "resources lost on save: {lost:?}"); + let keys = |f: &PsdFile| f.global_blocks.iter().map(|b| b.key).collect::>(); + assert_eq!(keys(&back), keys(&f)); +} From b564f2ca9c81c0d51c87d96ee559de75dfd75cfc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 07:04:33 -0400 Subject: [PATCH 084/523] fix(layers): identify the Background by structure, never by its English name (#4036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景", "Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops, straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized file's deep knockout revealed transparency instead of the Background. - `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits` in the doc model replace every name check; `extra_cmds::background_to_layer` is the one "Background → Layer 0" conversion. - PSD import marks the bottom record position-locked when it is transparency-protected and has no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now lock the Background's position like every other place that creates one. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/advanced.rs | 6 +-- crates/compose/src/advanced_tests.rs | 23 ++++++++++ crates/doc/src/lib.rs | 50 ++++++++++++++++++++++ crates/engine/src/adjust_cmds.rs | 1 + crates/engine/src/analysis_cmds.rs | 2 +- crates/engine/src/background_cmds.rs | 15 +++---- crates/engine/src/color_to_alpha_cmds.rs | 4 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/distort_cmds.rs | 16 ++----- crates/engine/src/edit_cmds.rs | 13 +++--- crates/engine/src/eraser_cmds.rs | 21 +++------ crates/engine/src/extra_cmds.rs | 19 ++++---- crates/engine/src/float_cmds.rs | 4 +- crates/engine/src/frame_cmds.rs | 2 +- crates/engine/src/image_cmds.rs | 9 ++-- crates/engine/src/layer_copy_cmds.rs | 2 +- crates/engine/src/layer_multi_cmds.rs | 8 +--- crates/engine/src/lens_cmds.rs | 3 +- crates/engine/src/mode_cmds.rs | 3 +- crates/engine/src/perspective_crop_cmds.rs | 12 +++--- crates/engine/src/pick_cmds.rs | 2 +- crates/engine/src/presets/styles.rs | 8 +--- crates/engine/src/slice_cmds.rs | 2 +- crates/engine/src/smart_cmds.rs | 2 +- crates/engine/src/smart_cmds/unpack.rs | 3 +- crates/engine/src/transform_cmds.rs | 27 ++++++------ crates/engine/src/warp_cmds.rs | 8 ++-- crates/io/src/affinity.rs | 1 + crates/io/src/psd_import.rs | 10 +++++ crates/io/tests/psd_background.rs | 39 +++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/doc_props_ui.rs | 7 +-- crates/ui-egui/src/panels.rs | 2 +- 33 files changed, 213 insertions(+), 115 deletions(-) create mode 100644 crates/io/tests/psd_background.rs diff --git a/crates/compose/src/advanced.rs b/crates/compose/src/advanced.rs index 4a9cb3e89b..6809c9e865 100644 --- a/crates/compose/src/advanced.rs +++ b/crates/compose/src/advanced.rs @@ -84,10 +84,10 @@ pub fn shapeless(layer: &Layer) -> bool { !layer.advanced.transparency_shapes && (layer.advanced.knockout != Knockout::None || effects::has_effects(layer)) } -/// The Background layer of a stack: the bottom layer, a locked, opaque-by-convention pixel layer -/// named "Background" (how PSD import and File › New create it). +/// Whether `l`, the bottom layer of a stack, is the Background: identified by its locks and kind, +/// never by its (localized) name ([`photocraft_doc::Document::background`]). pub fn is_background(l: &Layer) -> bool { - l.name == "Background" && l.locks.transparency && !l.clipped && matches!(l.content, LayerContent::Raster(_)) + l.has_background_traits() } /// What a deep knockout reveals over `rect`: the document's initial backdrop with its Background diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index c4e437a4b6..0ca3bdadb9 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -437,3 +437,26 @@ fn a_knockout_background_does_not_recurse() { let got = render(&d, Rect::from_xywh(0, 0, 1, 1)).px[0]; assert!(got[3] <= 1.0); } + +/// The Background is found by its structure, not its English name: a file from a German or +/// Japanese Photoshop calls it "Hintergrund" or "背景", and a deep knockout still reveals it. A +/// bottom layer merely called "Background", without the Background's locks, is not one. +#[test] +fn deep_knockout_reveals_a_localized_background() { + for depth in DEPTHS { + for name in ["Hintergrund", "背景", "Arrière-plan"] { + let mut d = doc(depth, true); + d.layers[0].name = name.into(); + let green = solid(&d, "green", full(&d), GREEN); + d.layers.push(green); + d.layers.push(knocker(&d, Knockout::Deep)); + check(&d, 2, 2, WHITE, name); + } + let mut d = doc(depth, true); + d.layers[0].locks = Default::default(); + let green = solid(&d, "green", full(&d), GREEN); + d.layers.push(green); + d.layers.push(knocker(&d, Knockout::Deep)); + check(&d, 2, 2, [0.0; 4], "an unlocked layer named Background"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5ff69e581c..354de06d31 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -636,6 +636,13 @@ pub struct Layer { } impl Layer { + /// Whether the layer has the Background's own properties: an unclipped pixel layer with + /// transparency and position locked. Only the bottom layer of a document with them is the + /// Background: use [`Document::background`] / [`Document::is_background`] to identify it. + pub fn has_background_traits(&self) -> bool { + self.locks.transparency && self.locks.position && !self.clipped && matches!(self.content, LayerContent::Raster(_)) + } + pub fn new(name: impl Into, content: LayerContent) -> Self { Layer { id: LayerId::fresh(), @@ -1040,6 +1047,25 @@ impl Document { self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p)) } + /// The Background layer, identified structurally as Photoshop does, never by its name (a file + /// from a German or Japanese Photoshop names it "Hintergrund" or "背景"): the bottom layer of + /// the stack, a pixel layer that is not clipped, with transparency and position locked (a PSD + /// stores it without a transparency channel and transparency-protected; File › New and opaque + /// flat files create it the same way). + pub fn background(&self) -> Option<&Layer> { + self.layers.first().filter(|l| Layer::has_background_traits(l)) + } + + /// [`Self::background`], mutably. + pub fn background_mut(&mut self) -> Option<&mut Layer> { + self.layers.first_mut().filter(|l| Layer::has_background_traits(l)) + } + + /// Whether `id` is the document's Background layer ([`Self::background`]). + pub fn is_background(&self, id: LayerId) -> bool { + self.background().is_some_and(|b| b.id == id) + } + pub fn layer(&self, id: LayerId) -> Option<&Layer> { fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> { layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) }) @@ -1205,6 +1231,30 @@ mod tests { assert_eq!(s.pixel(100, 0), vec![0.0; 4]); } + /// The Background is the locked bottom pixel layer, whatever it is called. + #[test] + fn background_is_structural_not_named() { + let mut d = doc(); + let id = d.layers[0].id; + assert!(d.is_background(id)); + for name in ["Hintergrund", "背景", "Arrière-plan"] { + d.layers[0].name = name.into(); + assert_eq!(d.background().map(|l| l.id), Some(id), "{name}"); + } + // A layer called "Background" without the locks, or above the bottom, is not it. + let mut top = Layer::raster("Background", d.pixel_format()); + top.locks.transparency = true; + top.locks.position = true; + let top_id = top.id; + d.layers.push(top); + assert!(!d.is_background(top_id)); + d.layers[0].locks.position = false; + assert!(d.background().is_none()); + d.layers[0].locks.position = true; + d.layers[0].clipped = true; + assert!(d.background_mut().is_none()); + } + #[test] fn max_group_depth_counts_nesting_without_recursing() { let mut d = doc(); diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 42dd44d767..48f6e56246 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -428,6 +428,7 @@ fn hdr_toning(s: &mut Session, p: &Value) -> Result { let data: Vec = px.iter().flat_map(|q| photocraft_raster::from_rgba(&fmt, *q)).collect(); let mut bg = Layer::raster("Background", fmt); bg.locks.transparency = true; + bg.locks.position = true; crate::pixels_mut(&mut bg)?.write_region(doc.bounds(), &data); *active = Some(bg.id); doc.layers = vec![bg]; diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index b369479ce0..1ba04dfe4c 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -670,7 +670,7 @@ fn straighten(s: &mut Session, p: &Value) -> Result { return Ok(json!({"angle": 0.0})); } let st = s.active().ok_or(EngineError::NoDocument)?; - let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| l.name == "Background" && l.locks.position); + let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id)); let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none()); let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height)); compound(s, "Straighten", |s| { diff --git a/crates/engine/src/background_cmds.rs b/crates/engine/src/background_cmds.rs index 1da5ebc92b..81c47501d3 100644 --- a/crates/engine/src/background_cmds.rs +++ b/crates/engine/src/background_cmds.rs @@ -10,14 +10,13 @@ use photocraft_doc::{Document, Layer, LayerContent, LayerId}; use serde_json::{Value, json}; use crate::commands::CommandSpec; -use crate::extra_cmds::is_background; use crate::{EngineError, Result, Session}; const CMD: &str = "layer.new.backgroundFromLayer"; /// The document's Background layer, if it has one (always the bottom layer). pub(crate) fn background_id(doc: &Document) -> Option { - doc.layers.first().filter(|l| is_background(l)).map(|l| l.id) + doc.background().map(|l| l.id) } /// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the @@ -155,7 +154,7 @@ mod tests { assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0); assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom"); let l = &doc(&s).layers[0]; - assert!(is_background(l) && l.name == "Background", "{depth}"); + assert!(doc(&s).is_background(l.id) && l.name == "Background", "{depth}"); assert_eq!(s.active().unwrap().active_layer, Some(a)); // Its pixels stay; transparency takes the background colour (blue). let red = pixel(&s, 3, 3); @@ -170,10 +169,10 @@ mod tests { assert!(s.undo()); assert_eq!(order(&s), [l0, a, b], "{depth}"); let l = doc(&s).layer(a).unwrap(); - assert!(!is_background(l) && l.name == "A"); + assert!(!doc(&s).is_background(l.id) && l.name == "A"); assert!(s.redo()); assert_eq!(order(&s), [a, l0, b], "{depth}"); - assert!(is_background(&doc(&s).layers[0])); + assert!(doc(&s).is_background(doc(&s).layers[0].id)); } } @@ -191,7 +190,7 @@ mod tests { assert_eq!(d.layers[1].id, l0); assert_eq!(d.layers[2].id, a); let bg = &d.layers[0]; - assert!(is_background(bg) && !bg.visible, "visibility is kept"); + assert!(d.is_background(bg.id) && !bg.visible, "visibility is kept"); assert_eq!(bg.opacity, 1.0); } @@ -211,7 +210,7 @@ mod tests { for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] { assert!(s.execute(CMD, p.clone()).is_err(), "{p}"); } - assert!(!doc(&s).layers.iter().any(is_background)); + assert!(doc(&s).background().is_none()); // A group or an adjustment layer can't become the Background. s.execute("layer.select", json!({"layer": a.0})).unwrap(); s.execute("layer.groupLayers", json!({})).unwrap(); @@ -220,7 +219,7 @@ mod tests { s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); assert!(!s.is_enabled(CMD)); assert!(s.execute(CMD, json!({})).is_err()); - assert!(!doc(&s).layers.iter().any(is_background)); + assert!(doc(&s).background().is_none()); } #[test] diff --git a/crates/engine/src/color_to_alpha_cmds.rs b/crates/engine/src/color_to_alpha_cmds.rs index c3120f4ad8..a6208e8246 100644 --- a/crates/engine/src/color_to_alpha_cmds.rs +++ b/crates/engine/src/color_to_alpha_cmds.rs @@ -92,7 +92,7 @@ fn enabled(s: &Session) -> std::result::Result<(), String> { if locks.pixels || locks.all { return Err(format!("the layer \"{}\" is locked", l.name)); } - if locks.transparency && !crate::extra_cmds::is_background(l) { + if locks.transparency && !d.doc.is_background(l.id) { return Err(format!("the transparency of layer \"{}\" is locked", l.name)); } Ok(()) @@ -217,7 +217,7 @@ mod tests { s.execute("edit.undo", json!({})).unwrap(); assert_eq!(name(&s), "Background"); let d = s.active().unwrap(); - assert!(crate::extra_cmds::is_background(d.doc.layer(d.active_layer.unwrap()).unwrap())); + assert!(d.doc.is_background(d.active_layer.unwrap())); } #[test] diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2ce0b3762f..3554e5690f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1416,7 +1416,7 @@ pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) { pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result { let src = doc.layer(id).ok_or(EngineError::NoLayer(id))?; // A copy of the Background layer is an ordinary, unlocked layer (Photoshop). - let from_background = src.name == "Background" && src.locks.transparency && doc.layers.first().is_some_and(|b| b.id == id); + let from_background = doc.is_background(id); let mut dup = src.duplicate(); dup.name = doc.copy_name(&dup.name); if from_background { diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index 75ebf529bc..c164eaf679 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -213,15 +213,6 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> { } } -/// Unlocks a Background layer for an operation that exposes transparency (as Photoshop does). -fn float_background(l: &mut Layer) { - if l.locks.position && l.name == "Background" { - l.locks.position = false; - l.locks.transparency = false; - l.name = "Layer 0".into(); - } -} - fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> { let locks = l.locks.union(group); if locks.all || locks.pixels || (position && locks.position) { @@ -247,14 +238,15 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, let canvas = doc.bounds(); let selection = doc.selection.clone(); let group = crate::transform_cmds::group_locks(doc, id); + // A Background gains transparency when the distortion moves pixels (as in Photoshop). + if moves { + crate::extra_cmds::background_to_layer(doc, id); + } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let LayerContent::Smart(_) = l.content { let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } - if moves { - float_background(l); - } check_locks(l, group, moves)?; let LayerContent::Raster(surf) = &mut l.content else { return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)"))); diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index f99a81a640..d4de2d548e 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -82,7 +82,7 @@ fn whole_layers(d: &crate::DocState, ids: &[LayerId]) -> Option> { let mut copies = scratch_doc(&d.doc); for id in ids { let mut l = d.doc.layer(*id)?.clone(); - if crate::extra_cmds::is_background(&l) { + if d.doc.is_background(*id) { l.name = "Layer 0".into(); l.locks = Default::default(); } @@ -277,7 +277,8 @@ fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Resu /// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold /// transparency, so there the area is filled with the background colour instead (Photoshop). pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> { - let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?); + doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let bg = doc.is_background(id); let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; if bg { crate::pixels::try_fill_surface(surf, area, background, sel, true)?; @@ -461,7 +462,7 @@ fn merge_visible(s: &mut Session) -> Result { } let lowest = visible[0]; let base = doc.layers[lowest].clone(); - let is_background = base.name == "Background" && base.locks.transparency; + let is_background = lowest == 0 && doc.is_background(base.id); // Composite of visible layers only (hidden ones stay where they are). let mut solo = doc.clone(); solo.layers.retain(|l| l.visible); @@ -1363,7 +1364,7 @@ mod tests { let mut s = fresh(); s.execute(cmd, json!({})).unwrap(); let d = &s.active().unwrap().doc; - assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background"); + assert!(d.is_background(d.layers[0].id), "{cmd}: still the Background"); assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour"); assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched"); } @@ -1609,9 +1610,9 @@ mod tests { let st = s.active().unwrap(); assert_eq!(st.doc.layers.len(), 4); let pasted = &st.doc.layers[st.doc.layers.len() - 2]; - assert!(!crate::extra_cmds::is_background(pasted)); + assert!(!st.doc.is_background(pasted.id)); assert_eq!(pasted.locks, Default::default()); - assert!(crate::extra_cmds::is_background(&st.doc.layers[0]), "the real Background is untouched"); + assert!(st.doc.is_background(st.doc.layers[0].id), "the real Background is untouched"); } /// #2428: a smart object copied in one document pastes into another as a smart object, in diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 98169aff72..3fb546de4f 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -59,19 +59,6 @@ fn active_pixel_layer(s: &Session, cmd: &str) -> Result { s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer")) } -fn is_background(l: &Layer) -> bool { - l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_)) -} - -/// Photoshop converts the Background to a normal layer before erasing to transparency. -fn unlock_background(l: &mut Layer) { - if is_background(l) { - l.name = "Layer 0".into(); - l.locks.transparency = false; - l.locks.position = false; - } -} - fn pixels_locked(doc: &Document, l: &Layer) -> Option { let locks = doc.effective_locks(l.id); (locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))) @@ -114,7 +101,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result { let bg = s.tools.background; let dmg = s.edit("Magic Eraser", |doc, _| { let selection = doc.selection.clone(); - unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?); + // Photoshop converts the Background to a normal layer before erasing to transparency. + doc.layer(id).ok_or(EngineError::NoLayer(id))?; + crate::extra_cmds::background_to_layer(doc, id); let lock = doc.effective_locks(id).transparency; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; @@ -149,15 +138,15 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result { if let Some(e) = pixels_locked(&d.doc, l) { return Err(e); } - if d.doc.effective_locks(id).transparency && !is_background(l) { + if d.doc.effective_locks(id).transparency && !d.doc.is_background(id) { return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); } } let stroke = Stroke { brush, points: pts }; let dmg = s.edit("Background Eraser", |doc, _| { let selection = doc.selection.clone(); + crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - unlock_background(l); let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) })?; diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 84c0e24de1..cebb9ec890 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -43,13 +43,9 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> { d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into()) } -pub(crate) fn is_background(l: &Layer) -> bool { - l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_)) -} - fn has_background(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; - d.doc.layers.first().filter(|l| is_background(l)).map(|_| ()).ok_or_else(|| "the document has no Background layer".into()) + d.doc.background().map(|_| ()).ok_or_else(|| "the document has no Background layer".into()) } /// The layers a layer-style command acts on: as [`crate::layer_multi_cmds::targets`], but with @@ -60,7 +56,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo return Ok(ids); } let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !is_background(l) && fits(l))).collect(); + let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| !d.doc.is_background(l.id) && fits(l))).collect(); if ids.is_empty() { return Err(EngineError::Other("none of the selected layers can take this layer style change".into())); } @@ -441,7 +437,7 @@ pub fn reveal_all_bounds(doc: &Document) -> Rect { fn layer_from_background(s: &mut Session) -> Result { let id = s.edit("Layer From Background", |doc, active| { - let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?; + let l = doc.background_mut().ok_or(EngineError::Other("the document has no Background layer".into()))?; unlock_background(l); *active = Some(l.id); Ok(l.id) @@ -458,7 +454,14 @@ pub(crate) fn unlock_background(l: &mut Layer) { /// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer /// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone. pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) { - if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) { + background_to_layer(doc, id); +} + +/// If `id` is the Background ([`photocraft_doc::Document::background`], whatever its name), turns +/// it into a normal layer ("Layer 0"), as Photoshop does before an edit the Background can't +/// take (moving, transforming, exposing transparency, a style). Other layers are left alone. +pub(crate) fn background_to_layer(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) { + if let Some(l) = doc.background_mut().filter(|l| l.id == id) { unlock_background(l); } } diff --git a/crates/engine/src/float_cmds.rs b/crates/engine/src/float_cmds.rs index 892f754748..aaab22fde2 100644 --- a/crates/engine/src/float_cmds.rs +++ b/crates/engine/src/float_cmds.rs @@ -58,7 +58,7 @@ impl CutParts { // A mask has no alpha: it keeps its format, and the cut leaves background colour. let mut rest = if mask { surf.clone() } else { full.clone() }; let mut piece = Surface::new(with_alpha); - let fill = !copy && (mask || crate::extra_cmds::is_background(l)); + let fill = !copy && (mask || doc.is_background(layer)); // A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas. let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() }); if !b.is_empty() { @@ -165,7 +165,7 @@ fn can_float(s: &Session) -> std::result::Result<(), String> { /// floating? The Background is position-locked, but its selected pixels move (Photoshop). pub fn locked_for_float(doc: &Document, l: &photocraft_doc::Layer) -> bool { let locks = doc.effective_locks(l.id); - locks.all || (locks.position && !crate::extra_cmds::is_background(l)) + locks.all || (locks.position && !doc.is_background(l.id)) } fn float(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/frame_cmds.rs b/crates/engine/src/frame_cmds.rs index df84bdcfd1..c97006e746 100644 --- a/crates/engine/src/frame_cmds.rs +++ b/crates/engine/src/frame_cmds.rs @@ -137,7 +137,7 @@ fn new_frame(s: &mut Session, p: &Value) -> Result { let gid = match content { Some(id) => { let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if !matches!(l.content, LayerContent::Raster(_)) || crate::extra_cmds::is_background(l) { + if !matches!(l.content, LayerContent::Raster(_)) || doc.is_background(id) { return Err(bad("only a pixel layer (not the Background) can go in a frame".into())); } let gid = doc.insert_above(Some(id), Layer::group(name, vec![])); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index f477bd220e..9ccca87ada 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -300,7 +300,7 @@ fn crop_doc(doc: &mut Document, cmd: &str, r: Rect, delete_pixels: bool, extensi // A Background cannot hold transparent pixels: like an opaque Canvas Size extension, the area // an extending Crop adds is filled with the extension (background) colour. if extends - && let Some(bg) = doc.layers.first_mut().filter(|l| crate::extra_cmds::is_background(l)) + && let Some(bg) = doc.background_mut() && let LayerContent::Raster(surf) = &mut bg.content { let old = old_canvas.translate(dx, dy); @@ -381,16 +381,13 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { // A shrink or no-op resize must not silently convert the Background. if ext[3] == 0.0 && (nw > ow as u32 || nh > oh as u32) - && let Some(bg) = doc.layers.first_mut() - && crate::extra_cmds::is_background(bg) + && let Some(bg) = doc.background_mut() { crate::extra_cmds::unlock_background(bg); } // The locked Background layer is extended with the extension colour. if ext[3] > 0.0 - && let Some(bg) = doc.layers.first_mut() - && bg.name == "Background" - && bg.locks.transparency + && let Some(bg) = doc.background_mut() && let LayerContent::Raster(surf) = &mut bg.content { let fmt = surf.format(); diff --git a/crates/engine/src/layer_copy_cmds.rs b/crates/engine/src/layer_copy_cmds.rs index 29eff4c8df..9a42778de2 100644 --- a/crates/engine/src/layer_copy_cmds.rs +++ b/crates/engine/src/layer_copy_cmds.rs @@ -163,7 +163,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?; let mut l = src_layer.duplicate(); // A dragged Background arrives as an ordinary layer (the destination keeps its own). - if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) { + if sdoc.is_background(*id) { l.name = ddoc.next_layer_name("Layer"); l.locks = Default::default(); } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 62006d0832..f0bf4a503e 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -44,7 +44,7 @@ pub(crate) fn mask_targets(s: &Session, p: &serde_json::Value) -> Result = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !crate::extra_cmds::is_background(l))).collect(); + let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| l.mask.is_none() && !d.doc.is_background(l.id))).collect(); if ids.is_empty() { return Err(EngineError::Other("none of the selected layers can take a layer mask".into())); } @@ -163,13 +163,9 @@ pub fn select(s: &mut Session, p: &Value) -> Result { Ok(json!({"selected": selected(s).iter().map(|l| l.0).collect::>()})) } -pub(crate) fn is_locked_background(l: &Layer) -> bool { - l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) -} - fn select_all_layers(s: &mut Session) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; - let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| !is_locked_background(l)).map(|(_, _, l)| l.id).collect(); + let ids: Vec = d.doc.walk().into_iter().filter(|(_, _, l)| !d.doc.is_background(l.id)).map(|(_, _, l)| l.id).collect(); if ids.is_empty() { return Err(EngineError::Other("there are no layers to select".into())); } diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index c0513cf373..31d69300f4 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -287,6 +287,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: let canvas = doc.bounds(); let selection = doc.selection.clone(); let locks = doc.effective_locks(id); + let background = doc.is_background(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let LayerContent::Smart(_) = l.content { let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; @@ -296,7 +297,7 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg: return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } // A Background layer gains transparency when the edge mode exposes it (as in Photoshop). - if float_bg && l.locks.position && l.name == "Background" { + if float_bg && background { l.locks.position = false; l.locks.transparency = false; l.name = "Layer 0".into(); diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index def9440c84..5965303495 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -91,8 +91,9 @@ pub(crate) fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { let canvas = doc.bounds(); let fmt = doc.pixel_format(); let dpi = doc.resolution_dpi; + let background_id = doc.background().map(|l| l.id); for l in doc.layers.iter_mut() { - let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_)); + let background = Some(l.id) == background_id; if background && let Some(surf) = l.surface_mut() { // The Background stays a Background: rotated pixels over the background colour. let src = surf.content_bounds(); diff --git a/crates/engine/src/perspective_crop_cmds.rs b/crates/engine/src/perspective_crop_cmds.rs index c8881e6f70..8acaf42ce2 100644 --- a/crates/engine/src/perspective_crop_cmds.rs +++ b/crates/engine/src/perspective_crop_cmds.rs @@ -14,7 +14,7 @@ use photocraft_algo::transform::{Homography, Interp, convex_quad, quad_size, quad_to_rect, warp_surface_clipped}; use photocraft_color::PixelFormat; -use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, Size}; use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -154,12 +154,13 @@ fn warp_gray(s: &Surface, h: &Homography, interp: Interp, clip: Rect) -> Result< } /// Maps every layer's pixels and masks through `h` into `clip`. -fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32]) -> Result<()> { +/// `background_id` is the id of the document's Background, if it has one. +fn warp_layers(layers: &mut [Layer], h: &Homography, interp: Interp, clip: Rect, bg: &[f32], background_id: Option) -> Result<()> { for l in layers { crate::allocation::checkpoint("a perspective crop")?; - let background = crate::extra_cmds::is_background(l); + let background = background_id == Some(l.id); match &mut l.content { - LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg)?, + LayerContent::Group(g) => warp_layers(&mut g.children, h, interp, clip, bg, None)?, LayerContent::Raster(surf) => { let warped = warp_surface_clipped(surf, surf.content_bounds(), h, interp, clip)?; *surf = if background { @@ -188,7 +189,8 @@ fn apply(doc: &mut Document, q: &[[f64; 2]; 4], h: &Homography, size: (u32, u32) let (w, ht) = size; let clip = Rect::from_xywh(0, 0, w, ht); let bg = photocraft_raster::from_rgba(&doc.pixel_format(), bg); - warp_layers(&mut doc.layers, h, interp, clip, &bg)?; + let background_id = doc.background().map(|l| l.id); + warp_layers(&mut doc.layers, h, interp, clip, &bg, background_id)?; for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = warp_gray(&ch.surface, h, interp, clip)?; } diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index ccb3b4ca11..c8583305da 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -126,7 +126,7 @@ pub fn layers_in_rect(doc: &Document, r: Rect, group: bool) -> Vec { let canvas = doc.bounds(); for (path, _, l) in doc.walk().iter().rev() { if matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) - || crate::layer_multi_cmds::is_locked_background(l) + || doc.is_background(l.id) || doc.effective_locks(l.id).all || !(1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { diff --git a/crates/engine/src/presets/styles.rs b/crates/engine/src/presets/styles.rs index a8c60d6a4c..ee4607cf86 100644 --- a/crates/engine/src/presets/styles.rs +++ b/crates/engine/src/presets/styles.rs @@ -191,13 +191,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { let mode = doc.mode; let effects: Vec<_> = effects.iter().cloned().map(|e| e.in_mode(mode)).collect(); for id in &ids { - let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; // Styles can't sit on the Background: Photoshop turns it into a normal layer first. - if l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, photocraft_doc::LayerContent::Raster(_)) { - l.name = "Layer 0".into(); - l.locks.transparency = false; - l.locks.position = false; - } + crate::extra_cmds::background_to_layer(doc, *id); + let l = doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?; if add { l.effects.items.extend(effects.iter().cloned()); } else { diff --git a/crates/engine/src/slice_cmds.rs b/crates/engine/src/slice_cmds.rs index 0d3cceb6f0..959f132927 100644 --- a/crates/engine/src/slice_cmds.rs +++ b/crates/engine/src/slice_cmds.rs @@ -50,7 +50,7 @@ fn has_layer_for_slice(s: &Session) -> std::result::Result<(), String> { if matches!(l.content, LayerContent::Adjustment(_)) { return Err("adjustment layers have no bounds to slice".into()); } - if d.doc.layers.first().is_some_and(|b| b.id == id && b.name == "Background" && b.locks.transparency && b.locks.position) { + if d.doc.is_background(id) { return Err("the Background layer can't have a layer-based slice".into()); } if d.doc.slices.list.iter().any(|s| s.layer == Some(id)) { diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 5678a8b7d9..e632251982 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -666,7 +666,7 @@ fn any_layer(l: &Layer, f: &dyn Fn(&Layer) -> bool) -> bool { /// blend mode, clipping and label stay on the smart layer. The cache is the exact composite, so /// the document looks the same before and after. pub fn layer_to_smart(doc: &Document, l: &Layer) -> Result { - let background = l.name == "Background" && l.locks.position; + let background = doc.is_background(l.id); let mut inner = l.clone(); inner.visible = true; inner.opacity = 1.0; diff --git a/crates/engine/src/smart_cmds/unpack.rs b/crates/engine/src/smart_cmds/unpack.rs index 8ddfa3d8b5..0fdf3ac3fa 100644 --- a/crates/engine/src/smart_cmds/unpack.rs +++ b/crates/engine/src/smart_cmds/unpack.rs @@ -32,9 +32,10 @@ fn contents(s: &Session, doc: &Document, sm: &SmartObject, intent: Intent, bpc: if src.depth != doc.depth { crate::image_cmds::convert_layers_depth(&mut src.layers, doc.depth); } + let background_id = src.background().map(|l| l.id); let mut layers = std::mem::take(&mut src.layers); for l in &mut layers { - if crate::extra_cmds::is_background(l) { + if Some(l.id) == background_id { l.name = "Layer 0".into(); l.locks = Default::default(); } diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 44ea2e189c..2b35b18333 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -201,10 +201,10 @@ pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks { } /// The locks a transform of `l` meets: its own and `group`'s (those it inherits). The Background's -/// position lock doesn't count: Photoshop turns it into a normal layer first ([`transform_layer`]). -fn transform_locks(l: &Layer, group: Locks) -> Locks { +/// position lock doesn't count: Photoshop turns it into a normal layer first. +fn transform_locks(l: &Layer, group: Locks, background: bool) -> Locks { let mut own = l.locks; - if own.position && l.name == "Background" { + if background { own.position = false; own.transparency = false; } @@ -217,29 +217,24 @@ fn transform_locks(l: &Layer, group: Locks) -> Locks { pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { let Some(l) = doc.layer(id) else { return false }; // An explicit stack rather than recursion: documents can nest deeply. - let mut stack = vec![(l, group_locks(doc, id))]; - while let Some((l, group)) = stack.pop() { - let locks = transform_locks(l, group); + let mut stack = vec![(l, group_locks(doc, id), doc.is_background(id))]; + while let Some((l, group, background)) = stack.pop() { + let locks = transform_locks(l, group, background); if locks.position || locks.all { return true; } if let LayerContent::Group(g) = &l.content { - stack.extend(g.children.iter().map(|c| (c, locks))); + stack.extend(g.children.iter().map(|c| (c, locks, false))); } } false } -/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). +/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background +/// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; - let locks = transform_locks(l, group); - // Photoshop turns the Background into a normal layer before transforming it. - if l.locks.position && l.name == "Background" { - l.locks.position = false; - l.locks.transparency = false; - l.name = "Layer 0".into(); - } + let locks = transform_locks(l, group, false); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } @@ -493,6 +488,8 @@ fn transform(s: &mut Session, p: &Value) -> Result { 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))?; transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?; if !h.is_identity() { diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 49578cbb52..844da50345 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -110,12 +110,9 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { Ok(w) } +/// A Background is turned into a normal layer by the caller first +/// ([`crate::extra_cmds::background_to_layer`]). fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> { - if l.locks.position && l.name == "Background" { - l.locks.position = false; - l.locks.transparency = false; - l.name = "Layer 0".into(); - } let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); @@ -270,6 +267,7 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { let sel = doc.selection.clone(); let is_group = doc.layer(id).is_some_and(Layer::is_group); let group = crate::transform_cmds::group_locks(doc, id); + crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?; let snapshot = doc.clone(); diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index f829326e21..efba868be0 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -151,6 +151,7 @@ impl Builder { bg.prune(); let mut l = Layer::new("Background", LayerContent::Raster(bg)); l.locks.transparency = true; + l.locks.position = true; layers.insert(0, l); } self.doc.layers = layers; diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 137381fc54..ffe2d34eaf 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -706,6 +706,16 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // its own profile, as the compositor and the PSD writer read them. let space = photocraft_compose::cmyk_space(&doc); doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); + // Photoshop's Background: the bottom record, a transparency-protected pixel layer stored + // without a transparency channel (-1). It is also position-locked, whatever its + // (localized) name and whether or not the file carries an `lspf` block. + if let (Some(rec), Some(bottom)) = (file.layers().first(), doc.layers.first_mut()) + && rec.flags.transparency_protected() + && !rec.channels.iter().any(|c| c.id == -1) + && matches!(bottom.content, LayerContent::Raster(_)) + { + bottom.locks.position = true; + } // Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked). if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) diff --git a/crates/io/tests/psd_background.rs b/crates/io/tests/psd_background.rs new file mode 100644 index 0000000000..d24d24409e --- /dev/null +++ b/crates/io/tests/psd_background.rs @@ -0,0 +1,39 @@ +//! A PSD's Background is recognised by its structure (the bottom record, transparency-protected, +//! stored without a transparency channel), whatever its name in the Photoshop language that +//! saved the file. + +use photocraft_io::psd_to_document; +use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen}; + +/// `testgen::layered` with its bottom layer turned into a Photoshop Background named `name`. +fn file(name: &str, transparency_channel: bool) -> PsdFile { + let mut f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle); + let bottom = f.layers_mut().first_mut().unwrap(); + bottom.name = name.bytes().filter(u8::is_ascii).collect(); + bottom.blocks.retain(|b| &b.key != b"luni" && &b.key != b"lspf"); + bottom.blocks.push(TaggedBlock::unicode_name(name)); + bottom.flags.set_transparency_protected(true); + if !transparency_channel { + bottom.channels.retain(|c| c.id != -1); + } + f +} + +#[test] +fn a_localized_background_is_the_background() { + for name in ["Hintergrund", "背景", "Arrière-plan"] { + let (d, _) = psd_to_document(&file(name, false)); + let bg = d.background().unwrap_or_else(|| panic!("{name}: no Background")); + assert_eq!(bg.name, name); + assert!(bg.locks.transparency && bg.locks.position, "{name}"); + assert_eq!(bg.id, d.layers[0].id); + } +} + +#[test] +fn a_protected_layer_with_transparency_is_not_the_background() { + // Lock Transparent Pixels on an ordinary bottom layer: it keeps its transparency channel. + let (d, _) = psd_to_document(&file("Background", true)); + assert!(d.layers[0].locks.transparency); + assert!(d.background().is_none()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 19f1cbf4c0..c9e4c6ce9c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2089,7 +2089,7 @@ const STUDIO_TAB_GAP: f32 = 4.0; fn pro_tab_title(app: &PhotocraftApp, i: usize) -> String { 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); - let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !(l.name == "Background" && l.locks.transparency)); + let active_layer = st.active_layer.and_then(|id| st.doc.layer(id)).filter(|l| !st.doc.is_background(l.id)); let mask = app.session.active_index() == Some(i) && app.ui.mask_target && active_layer.is_some_and(|l| l.mask.is_some()); let model = if mask { tl!("Layer Mask").to_string() } else { mode_label(&st.doc).to_string() }; let layer = active_layer.map(|l| format!("{}, ", l.name)).unwrap_or_default(); diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 2383590c79..8c10b8c442 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -3,15 +3,16 @@ //! Rulers & Grids toggles. Every edit dispatches an engine or view command, so the panel stays thin. use egui::{Align2, Rect, RichText, Sense, Stroke, StrokeKind, pos2, vec2}; -use photocraft_doc::{ColorMode, Document, Layer, LayerContent, LayerId, SampleType}; +use photocraft_doc::{ColorMode, Document, Layer, LayerId, SampleType}; use serde_json::{Value, json}; use crate::theme::Tokens; use crate::{PhotocraftApp, icons, widgets}; -/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack. +/// Photoshop's Background layer: the locked, opaque raster layer at the bottom of the stack, +/// whatever its (localized) name ([`Document::background`]). pub fn is_background(doc: &Document, l: &Layer) -> bool { - l.name == "Background" && l.locks.transparency && matches!(l.content, LayerContent::Raster(_)) && doc.layers.first().is_some_and(|b| b.id == l.id) + doc.is_background(l.id) } /// Does the Properties panel show the Document view? (No layer selected, or the Background layer.) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 25f3653b05..086d1c0604 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2689,7 +2689,7 @@ fn layer_row( let name_color = if l.visible { t.text } else { t.text_faint }; let font = if selected && !t.pro { theme::medium(13.0) } else { egui::FontId::proportional(if t.pro { 12.0 } else { 13.0 }) }; // Photoshop before 2026 set the Background layer's name in italics; 2026 sets it upright. - let italic = !t.pro && l.name == "Background" && l.locks.transparency; + let italic = !t.pro && doc.is_background(l.id); // Photoshop rows show only the name; the kind sub-label is a Studio-theme addition. let is_pixel = t.pro || matches!(l.content, LayerContent::Raster(_)); let name_rect = crate::layer_row_ui::truncated(&painter, &l.name, font, name_color, italic, name_right - x).map(|galley| { From 21f1012ce2ef3d90c2f6e867dd84c0edc13d6401 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:11:41 +0200 Subject: [PATCH 085/523] Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706) A tiny file declaring a huge image reserved the full output buffer before any data was checked. Add Limits::check_plausible (compression-ratio guard) and a fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default max_alloc to 1 GiB. Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 8 ++++++-- crates/codecs/src/codecs/webp.rs | 7 +++++-- crates/codecs/src/options.rs | 29 +++++++++++++++++++++++++++-- crates/codecs/tests/limits.rs | 24 ++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 636238a7bf..2544a1066d 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression}; +use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -29,6 +29,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let (w, h) = info.size(); // Worst case output: 4 channels x 16 bit. limits.check_bytes(w, h, 8)?; + // zlib never beats 1032:1; refuse a file too small for the declared image before the + // output buffer exists. Measured on the stored samples (before palette/bit expansion). + let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8); + Limits::check_plausible(stored, bytes.len(), 1100)?; } let mut reader = decoder.read_info().map_err(map_png_err)?; // APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it. @@ -47,7 +51,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result d => return Err(err(format!("unexpected output bit depth {d:?}"))), }; let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?; - let mut buf = vec![0u8; size]; + let mut buf = alloc_zeroed::(size)?; let out = reader.next_frame(&mut buf).map_err(map_png_err)?; buf.truncate(out.buffer_size()); // Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index c26c10c8cd..36473041dd 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -9,7 +9,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits}; +use crate::options::{EncodeOptions, Limits, alloc_zeroed}; const F: Format = Format::WebP; @@ -23,8 +23,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let (w, h) = dec.dimensions(); let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; limits.check(w, h, layout, SampleType::U8)?; + // VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the + // ratio is generous; it still stops a header-only file from asking for a gigabyte. + Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; - let mut buf = vec![0u8; size]; + let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 79b288f34c..9337f1e6f1 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -21,8 +21,10 @@ pub struct Limits { impl Default for Limits { fn default() -> Self { // 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large - // documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). - let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + // documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit + // targets (wasm32 has a 4 GiB address space shared with the document, history and + // the decoder's own buffers) 1 GiB is the most one buffer can safely ask for. + let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 }; Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc } } } @@ -60,6 +62,19 @@ impl Limits { Ok(()) } + /// Decompression-bomb guard for formats that compress: refuse a file too small to hold + /// `declared_bytes` of (stored, pre-expansion) image data, given that the compression + /// never beats `max_ratio`:1. Called before the output buffer is allocated, so a + /// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of + /// slack keeps tiny images and headers with large metadata unaffected. + pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> { + let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20); + if declared_bytes > capacity { + return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data"))); + } + Ok(()) + } + pub(crate) fn alloc_usize(&self) -> usize { usize::try_from(self.max_alloc).unwrap_or(usize::MAX) } @@ -149,3 +164,13 @@ impl Default for EncodeOptions { } } } + +/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes +/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot +/// stop). +pub(crate) fn alloc_zeroed(len: usize) -> Result, CodecError> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?; + v.resize(len, T::default()); + Ok(v) +} diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/limits.rs index 3613b0871d..6969fea922 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/limits.rs @@ -93,6 +93,30 @@ fn png_bomb_header_rejected_before_alloc() { assert!(is_limit(decode(&b))); } +#[test] +fn png_header_at_the_default_limit_needs_a_plausible_file_size() { + // 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits, + // but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before + // the 8 GiB output buffer is allocated. + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&32768u32.to_be_bytes()); + ihdr.extend_from_slice(&32768u32.to_be_bytes()); + ihdr.extend_from_slice(&[16, 6, 0, 0, 0]); + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + b.extend(png_chunk(b"IHDR", &ihdr)); + b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])); + b.extend(png_chunk(b"IEND", &[])); + assert!(is_limit(decode(&b))); +} + +#[test] +fn png_highly_compressible_image_still_decodes() { + let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap(); + let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(b.len() < 100_000, "{}", b.len()); + assert_eq!(decode(&b).unwrap().width(), 3000); +} + const PNG_TEXT_BUDGET: usize = 64 << 10; fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result { From 7893a6e04d71aa04cc96b4064ae90981581ea57b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:12:00 +0200 Subject: [PATCH 086/523] PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707) Co-authored-by: Claude Opus 5.5 --- crates/psd/src/file.rs | 4 +++ crates/psd/src/layer.rs | 60 ++++++++++++++++++++++++++++++++++++----- crates/psd/src/tiff.rs | 8 +++--- 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/crates/psd/src/file.rs b/crates/psd/src/file.rs index 3c621bb25a..330940e7a4 100644 --- a/crates/psd/src/file.rs +++ b/crates/psd/src/file.rs @@ -137,7 +137,11 @@ impl PsdFile { pub fn to_bytes(&self) -> Result> { let v = self.header.version; let psb = v.is_psb(); + // Reserve the pixel data up front: appending it to an empty Vec reallocates and copies + // repeatedly. Everything else (headers, resources, records) is small by comparison. + let hint = self.layer_info.as_ref().map_or(0, LayerInfo::channel_data_len).saturating_add(self.image_data.data.len()); let mut out = Vec::new(); + out.try_reserve(hint).map_err(|_| PsdError::LimitExceeded("not enough memory to serialize the file"))?; self.header.write(&mut out); out.put_len(self.color_mode_data.len() as u64, false)?; out.put(&self.color_mode_data); diff --git a/crates/psd/src/layer.rs b/crates/psd/src/layer.rs index cee6758268..5450ed7e67 100644 --- a/crates/psd/src/layer.rs +++ b/crates/psd/src/layer.rs @@ -684,15 +684,25 @@ impl LayerInfo { /// block in 4-byte units (psd-tools) misread the blocks after a body that /// is only padded to even. pub fn pad_to(&mut self, version: Version, align: u64) -> Result<()> { - let align = align.max(1); - let len = self.unpadded_len(version)?; - self.padding = Some(vec![0; ((align - len % align) % align) as usize]); + self.padding = Some(vec![0; self.pad_len(version, align)?]); Ok(()) } - /// Serializes the body (without a length field). - pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { - let start = out.len(); + /// Number of zero bytes [`Self::pad_to`] would set, without touching `self`. + pub(crate) fn pad_len(&self, version: Version, align: u64) -> Result { + let align = align.max(1); + let len = self.unpadded_len(version)?; + Ok(((align - len % align) % align) as usize) + } + + /// Bytes of channel data and their compression fields: the part of the body worth reserving up + /// front (the records are small next to the pixels). Saturates instead of overflowing. + pub(crate) fn channel_data_len(&self) -> usize { + self.layers.iter().flat_map(|l| &l.channels).filter(|c| c.compression.is_some()).fold(0usize, |n, c| n.saturating_add(2).saturating_add(c.data.len())) + } + + /// Serializes the body without any trailing padding (the padding field is ignored). + pub(crate) fn write_unpadded(&self, out: &mut Vec, version: Version) -> Result<()> { let n = i16::try_from(self.layers.len()).map_err(|_| PsdError::LimitExceeded("more than 32767 layers"))?; out.put_i16(if self.merged_alpha { -n } else { n }); for l in &self.layers { @@ -706,6 +716,13 @@ impl LayerInfo { } } } + Ok(()) + } + + /// Serializes the body (without a length field). + pub(crate) fn write_body(&self, out: &mut Vec, version: Version) -> Result<()> { + let start = out.len(); + self.write_unpadded(out, version)?; match &self.padding { Some(p) => out.put(p), None => { @@ -1022,6 +1039,37 @@ mod tests { assert_eq!(info.layers[0].decode_channel(0, 8, Version::Psd).unwrap(), Vec::::new()); } + /// Padding without cloning the layers writes the same bytes as `pad_to` on a clone, and the + /// channel data length hint is exact. + #[test] + fn unpadded_write_matches_pad_to_on_a_clone() { + use crate::{ColorMode, Compression, testgen}; + for version in [Version::Psd, Version::Psb] { + for depth in [8u16, 16, 32] { + for compression in [Compression::Raw, Compression::Rle] { + let file = testgen::layered(version, ColorMode::Rgb, depth, compression); + let mut info = file.layer_info.clone().unwrap(); + for align in [1u64, 2, 4, 8] { + let mut cloned = info.clone(); + cloned.pad_to(version, align).unwrap(); + let mut want = Vec::new(); + cloned.write_body(&mut want, version).unwrap(); + let pad = info.pad_len(version, align).unwrap(); + let mut got = Vec::new(); + info.write_unpadded(&mut got, version).unwrap(); + assert!(got.len() >= info.channel_data_len()); + got.resize(got.len() + pad, 0); + assert_eq!(got, want, "{version:?} {depth} {compression:?} align {align}"); + } + info.padding = Some(vec![7, 7]); + let mut with_padding = Vec::new(); + info.write_body(&mut with_padding, version).unwrap(); + assert!(with_padding.ends_with(&[7, 7])); + } + } + } + } + #[test] fn nondefault_padding_kept() { let info = LayerInfo { padding: Some(vec![0, 0]), ..Default::default() }; diff --git a/crates/psd/src/tiff.rs b/crates/psd/src/tiff.rs index 0d8f36888a..7c8b1940d5 100644 --- a/crates/psd/src/tiff.rs +++ b/crates/psd/src/tiff.rs @@ -155,10 +155,12 @@ impl ImageSourceData { let version = self.version; let mut blocks = Vec::with_capacity(self.global_blocks.len() + 2); if let Some(info) = &self.layer_info { - let mut info = info.clone(); - info.pad_to(version, 4)?; + // Pad to 4 without cloning the layers (and all their pixel data) just to set `padding`. + let pad = info.pad_len(version, 4)?; let mut data = Vec::new(); - info.write_body(&mut data, version)?; + data.try_reserve(info.channel_data_len().saturating_add(pad)).map_err(|_| PsdError::LimitExceeded("not enough memory for the layer data"))?; + info.write_unpadded(&mut data, version)?; + data.resize(data.len() + pad, 0); blocks.push(padded_block(self.layer_info_key, data)); } if let Some(m) = &self.global_layer_mask { From 476f9838765f619dd3c5d93ddac8e6a5d2e10f5d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:12:13 +0200 Subject: [PATCH 087/523] Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711) Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/tiff_ifd.rs | 50 +++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/codecs/tiff_ifd.rs b/crates/codecs/src/codecs/tiff_ifd.rs index 45fe9ee753..199d44ecc7 100644 --- a/crates/codecs/src/codecs/tiff_ifd.rs +++ b/crates/codecs/src/codecs/tiff_ifd.rs @@ -145,6 +145,14 @@ impl<'a> File<'a> { Some(if self.le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) }) } + /// The declared entry count of the directory at `at`, before its table is parsed. + pub fn entry_count(&self, at: u64) -> Option { + if at < self.header_len() { + return None; + } + if self.big { self.u64_at(at) } else { self.u16_at(at).map(u64::from) } + } + /// The directory at `at`: its whole table, up to and including the next-IFD pointer, must /// be inside the file and after the header. pub fn ifd(&self, at: u64) -> Option { @@ -355,6 +363,10 @@ struct Walk<'a, 'f> { seen: HashSet, pages: Vec, complete: bool, + /// Directory entries that may still be parsed. Real directory tables are disjoint, so their + /// total size is bounded by the file; overlapping tables (a hostile file) would otherwise + /// make every one of thousands of directories cost a full 65,535-entry parse. + entry_budget: u64, } impl Walk<'_, '_> { @@ -366,6 +378,15 @@ impl Walk<'_, '_> { self.complete = false; return; } + let Some(n) = self.f.entry_count(at) else { + self.complete = false; + return; + }; + if n > self.entry_budget { + self.complete = false; + return; + } + self.entry_budget -= n; let Some(ifd) = self.f.ifd(at) else { self.complete = false; return; @@ -391,10 +412,37 @@ pub fn tiff_info(bytes: &[u8]) -> Result { } pub(crate) fn info_of(f: &File<'_>) -> Result { - let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true }; + let mut w = Walk { f, seen: HashSet::new(), pages: Vec::new(), complete: true, entry_budget: (f.b.len() as u64) / 12 + 64 }; w.chain(f.first_ifd, None, 0); if w.pages.is_empty() { return Err(CodecError::malformed(Format::Tiff, "the first image directory is missing or cut off")); } Ok(TiffInfo { big_tiff: f.big, little_endian: f.le, pages: w.pages, complete: w.complete }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// 10,000 directories 4 bytes apart, each declaring 65,535 overlapping entries: the walk must + /// stop after the file-size entry budget instead of parsing ~6.5e8 entries. + #[test] + fn overlapping_directory_tables_exhaust_the_entry_budget() { + let first = 8usize; + let table = 2 + 65_535 * 12 + 4; + let mut b = vec![0u8; first + table + 4 * 10_000]; + b[..8].copy_from_slice(&[b'I', b'I', 42, 0, first as u8, 0, 0, 0]); + for i in 0..10_000usize { + let at = first + 4 * i; + b[at..at + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + let next_cell = at + 2 + 65_535 * 12; + let next = (first + 4 * (i + 1)) as u32; + if let Some(cell) = b.get_mut(next_cell..next_cell + 4) { + cell.copy_from_slice(&next.to_le_bytes()); + } + } + let info = tiff_info(&b).expect("the first directory is readable"); + assert!(!info.complete); + assert!(info.pages.len() < 200, "{} directories parsed", info.pages.len()); + } +} From e156a9ed85821fbd094d9700c03be11e3c4d2a8b Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 12 Oct 2026 13:12:34 +0200 Subject: [PATCH 088/523] perf(compose): select Normal RGB kernel once per buffer (#3715) --- crates/compose/src/lib.rs | 12 +++ crates/compose/src/psblend.rs | 31 ++++++-- crates/compose/src/tests.rs | 141 ++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 7 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a7c0576103..01087f4937 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1674,6 +1674,18 @@ fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity /// [`blend_into`] mixing coverage in a `gamma` space (type layers). fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { + // The blend space is constant in this loop. Some TLS implementations synchronise even a + // boolean read, so select the common Normal kernel once instead of once per pixel. + if mode == BlendMode::Normal && gamma == 1.0 && !psblend::LAB_MIX.with(|l| l.get()) { + for (i, b) in backdrop.px.iter_mut().enumerate() { + let s = src.px[i]; + if s[3] <= 0.0 { + continue; + } + *b = psblend::composite_normal_rgb(*b, s, opacity); + } + return; + } let rect = backdrop.rect; let w = rect.width() as i32; for (i, b) in backdrop.px.iter_mut().enumerate() { diff --git a/crates/compose/src/psblend.rs b/crates/compose/src/psblend.rs index afd8412347..4cff6f3082 100644 --- a/crates/compose/src/psblend.rs +++ b/crates/compose/src/psblend.rs @@ -210,13 +210,7 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: return [r[0], r[1], r[2], ao]; } if mode == BlendMode::Normal { - // Fast path: B(Cb, Cs) = Cs. - if ao <= 0.0 { - return [0.0; 4]; - } - let kb = ab * (1.0 - as_); - let inv = 1.0 / ao; - return [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao]; + return normal_rgb(backdrop, source, as_, ao); } let cb = [backdrop[0], backdrop[1], backdrop[2]]; let cs = [source[0], source[1], source[2]]; @@ -232,6 +226,29 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: out } +/// Normal mixing outside Lab, for callers that select the blend space once per buffer. +#[inline] +pub(crate) fn composite_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let ab = backdrop[3]; + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let ao = as_ + ab * (1.0 - as_); + normal_rgb(backdrop, source, as_, ao) +} + +#[inline] +fn normal_rgb(backdrop: [f32; 4], source: [f32; 4], as_: f32, ao: f32) -> [f32; 4] { + // B(Cb, Cs) = Cs. Keep the scalar composite's operation order and straight alpha. + if ao <= 0.0 { + return [0.0; 4]; + } + let kb = backdrop[3] * (1.0 - as_); + let inv = 1.0 / ao; + [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao] +} + /// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by /// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45 /// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index c56b7de49d..869c316dd7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -40,6 +40,147 @@ fn normal_layer_over_background() { assert!(close4(px(&d, 5, 1), [1.0; 4])); } +// The pre-optimisation Normal formula, independent of the production helper. Its operation +// order matters: an algebraically equivalent premultiplied mix can round differently. +fn original_normal_rgb(backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] { + let ab = backdrop[3]; + let as_ = source[3] * opacity; + if as_ <= 0.0 { + return backdrop; + } + let ao = as_ + ab * (1.0 - as_); + if ao <= 0.0 { + return [0.0; 4]; + } + let kb = ab * (1.0 - as_); + let inv = 1.0 / ao; + [(kb * backdrop[0] + as_ * source[0]) * inv, (kb * backdrop[1] + as_ * source[1]) * inv, (kb * backdrop[2] + as_ * source[2]) * inv, ao] +} + +#[test] +fn normal_rgb_kernel_preserves_scalar_bits() { + let subnormal = f32::from_bits(1); + let backdrops = [[0.0, -0.0, 2.0, 0.0], [0.2, 0.4, 0.6, 0.5], [2.0, -0.25, 4.0, 1.0], [-1.0, 0.01, 1.0, -0.5], [1.0, -0.0, f32::MIN_POSITIVE, subnormal]]; + let sources = [ + [0.9, 0.1, 0.3, 1.0], + [0.25, 2.0, -1.0, 0.5], + [subnormal, 1e-20, 1.0, subnormal], + [0.5, 0.2, 0.7, -0.0], + [0.5, 0.2, 0.7, -0.5], + [f32::NAN, 0.2, 0.7, 0.0], + ]; + for b in backdrops { + for s in sources { + for opacity in [-1.0, -0.0, 0.0, 0.5, 1.0, 2.0] { + let want = original_normal_rgb(b, s, opacity); + let got = psblend::composite_normal_rgb(b, s, opacity); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{b:?} / {s:?} / {opacity}"); + } + } + } +} + +fn normal_rgb_test_buffers(fmt: PixelFormat) -> (Buffer, Buffer) { + let rect = Rect::new(-3, -2, 5, 0); + let mut bottom = Surface::new(fmt); + let mut top = Surface::new(fmt); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = ((y - rect.y0) * rect.width() as i32 + x - rect.x0) as usize; + let t = i as f32 / 16.0; + let b = [2.0 * t, 0.4, -0.25 + t, [0.0, 0.25, 0.75, 1.0][i % 4]]; + let s = [0.9 - t, 0.1 + t, 0.3, [0.0, -0.0, 0.5, 1.0][i % 4]]; + bottom.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, b)); + top.write_pixel(x, y, &photocraft_raster::from_rgba(&fmt, s)); + } + } + (surface_to_buffer(&bottom, rect), surface_to_buffer(&top, rect)) +} + +// Keep the old buffer dispatch as an oracle for the specialised loop and all of its fallbacks. +fn original_blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) { + let rect = backdrop.rect; + let w = rect.width() as i32; + for (i, b) in backdrop.px.iter_mut().enumerate() { + let mut s = src.px[i]; + if s[3] <= 0.0 { + continue; + } + let mut mode = mode; + if mode == BlendMode::Dissolve { + let x = rect.x0 + (i as i32 % w); + let y = rect.y0 + (i as i32 / w); + s[3] = if dissolve_noise(x, y) < s[3] * opacity { 1.0 } else { 0.0 }; + mode = BlendMode::Normal; + *b = blend::composite(mode, *b, s, 1.0); + continue; + } + *b = blend::composite_gamma(mode, *b, s, opacity, gamma); + } +} + +fn assert_buffer_bits(got: &Buffer, want: &Buffer) { + assert_eq!(got.rect, want.rect); + assert_eq!(got.px.len(), want.px.len()); + for (i, (got, want)) in got.px.iter().zip(&want.px).enumerate() { + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "pixel {i}: {got:?} vs {want:?}"); + } +} + +#[test] +fn normal_rgb_buffer_dispatch_and_fallbacks_preserve_bits() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let (bottom, top) = normal_rgb_test_buffers(fmt); + for lab in [false, true] { + psblend::with_lab_mix(lab, || { + psblend::with_hdr(fmt.sample == SampleType::F32, || { + for mode in BlendMode::LAYER_MODES { + for gamma in [1.0, psblend::TEXT_GAMMA] { + for opacity in [0.0, 0.5, 1.0] { + let mut got = bottom.clone(); + let mut want = bottom.clone(); + original_blend_into_g(&mut want, &top, mode, opacity, gamma); + blend_into_g(&mut got, &top, mode, opacity, gamma); + assert_buffer_bits(&got, &want); + } + } + } + }); + }); + } + } +} + +#[test] +fn normal_rgb_buffer_preserves_alpha_skip_and_nested_scopes() { + let rect = Rect::new(-1, -1, 2, 0); + let bottom = Buffer::filled(rect, [-0.0, 0.4, 2.0, 0.75]); + let top = Buffer { rect, px: vec![[f32::NAN, 0.3, 0.1, -0.5], [0.2, 0.3, 0.1, -0.0], [0.2, 0.3, 0.1, f32::NAN]] }; + psblend::with_lab_mix(true, || { + psblend::with_hdr(true, || { + psblend::with_lab_mix(false, || { + psblend::with_hdr(false, || { + let mut got = bottom.clone(); + let mut want = bottom.clone(); + original_blend_into_g(&mut want, &top, BlendMode::Normal, -1.0, 1.0); + blend_into_g(&mut got, &top, BlendMode::Normal, -1.0, 1.0); + assert_buffer_bits(&got, &want); + assert_eq!(got.px[0].map(f32::to_bits), bottom.px[0].map(f32::to_bits)); + assert_eq!(got.px[1].map(f32::to_bits), bottom.px[1].map(f32::to_bits)); + }); + assert!(psblend::HDR.with(|h| h.get())); + }); + assert!(psblend::LAB_MIX.with(|l| l.get())); + assert!(psblend::HDR.with(|h| h.get())); + let mut got = bottom.clone(); + let mut want = bottom.clone(); + original_blend_into_g(&mut want, &top, BlendMode::Normal, 0.5, 1.0); + blend_into_g(&mut got, &top, BlendMode::Normal, 0.5, 1.0); + assert_buffer_bits(&got, &want); + }); + }); +} + #[test] fn opacity_and_fill_multiply() { let mut d = doc_white(4, 4); From 38236f90c24a1fd7f89094a4a1193be0690f08e1 Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 12 Oct 2026 13:12:47 +0200 Subject: [PATCH 089/523] perf(compose): allow empty vector masks in preview proxies (#3716) --- crates/compose/src/proxy.rs | 167 +++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 5560fae374..4fe3b879fb 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -83,15 +83,52 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { } /// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in -/// document pixels) and vector masks (paths in document coordinates) would not scale with it. +/// document pixels) and nonempty vector masks (paths in document coordinates) would not scale +/// with it. Empty vector masks have constant coverage, including inversion and density. +/// Nonempty paths can still anchor fill layouts even when their mask density is zero. pub fn proxy_faithful(doc: &Document) -> bool { - doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled)) + doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled || v.path.is_empty())) } #[cfg(test)] mod tests { use super::*; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; + use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask}; + use photocraft_doc::{Effect, Fill, GradientStyle, LayerMask}; + + fn two_colors(size: Size, depth: SampleType) -> Document { + let mut doc = Document::new("mask", size, ColorMode::Rgb, depth); + let format = doc.pixel_format(); + for (name, color) in [("blue", [0.0, 0.0, 1.0, 1.0]), ("red", [1.0, 0.0, 0.0, 1.0])] { + doc.layers.push(Layer::new(name, LayerContent::Raster(Surface::with_default(format, &color)))); + } + doc + } + + fn assert_same_pixels(actual: &crate::Buffer, expected: &crate::Buffer) { + assert_same_pixels_within(actual, expected, 1e-6); + } + + fn assert_same_pixels_within(actual: &crate::Buffer, expected: &crate::Buffer, tolerance: f32) { + assert_eq!(actual.rect, expected.rect); + assert_eq!(actual.px.len(), expected.px.len()); + for (a, b) in actual.px.iter().zip(&expected.px) { + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() <= tolerance), "{a:?} != {b:?}"); + } + } + + fn u16_coverage(value: f32) -> f32 { + // Feathered masks use the existing U16 combined-mask surface, including its default. + (value.clamp(0.0, 1.0) * 65535.0 + 0.5).floor() / 65535.0 + } + + fn opacity_reference(doc: &Document, coverage: f32) -> Document { + let mut reference = doc.clone(); + reference.layers[1].vector_mask = None; + reference.layers[1].opacity *= coverage; + reference + } #[test] fn downsample_picks_every_kth_pixel() { @@ -134,4 +171,130 @@ mod tests { assert_eq!((s.pixel(7, 15)[0], s.pixel(8, 0)[0]), (1.0, 0.0)); assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none()); } + + #[test] + fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() { + let mut doc = two_colors(Size::new(32, 16), SampleType::F32); + doc.layers[1].vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::default()]))); + assert!(proxy_faithful(&doc)); + // A lone knot has no fillable segments, but still carries document coordinates. + doc.layers[1].vector_mask.as_mut().unwrap().path.subpaths[0].knots.push(Knot::corner(12.0, 8.0)); + assert!(!proxy_faithful(&doc)); + doc.layers[1].vector_mask.as_mut().unwrap().enabled = false; + assert!(proxy_faithful(&doc)); + doc.layers[1].vector_mask = Some(VectorMask::new(Path::default())); + doc.layers[1].effects.items.push(Effect::default_drop_shadow()); + doc.layers[1].effects.enabled = true; + assert!(!proxy_faithful(&doc)); + doc.layers[1].effects.items[0].set_enabled(false); + assert!(proxy_faithful(&doc)); + } + + #[test] + fn empty_vector_masks_keep_constant_coverage_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = two_colors(Size::new(32, 16), depth); + for path in [Path::default(), Path::new(vec![Subpath::default()])] { + for inverted in [false, true] { + for density in [0.0, 0.25, 1.0] { + for feather in [0.0, 4.0] { + let mut mask = VectorMask::new(path.clone()); + mask.path.inverted = inverted; + mask.density = density; + mask.feather = feather; + doc.layers[1].vector_mask = Some(mask); + assert!(proxy_faithful(&doc)); + let coverage = if inverted { 1.0 - density } else { 1.0 }; + let coverage = if feather > 0.0 { u16_coverage(coverage) } else { coverage }; + let expected = [coverage, 0.0, 1.0 - coverage, 1.0]; + let full = crate::render(&doc, doc.bounds()); + for pixel in &full.px { + assert!(pixel.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-6), "{depth:?} {inverted} {density} {feather}: {pixel:?}"); + } + let proxy = proxy_document(&doc, 4); + assert_eq!(proxy.layers[1].vector_mask, doc.layers[1].vector_mask); + let reference = proxy_document(&opacity_reference(&doc, coverage), 4); + assert_same_pixels(&crate::render(&proxy, proxy.bounds()), &crate::render(&reference, reference.bounds())); + } + } + } + } + } + } + + #[test] + fn empty_vector_mask_multiplies_a_feathered_pixel_mask_on_the_proxy() { + let mut doc = two_colors(Size::new(32, 16), SampleType::F32); + let mut surface = Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[0.25]); + surface.fill_rect(Rect::new(0, 0, 16, 16), &[0.75]); + doc.layers[1].mask = Some(LayerMask { surface, density: 0.5, feather: 2.0, ..LayerMask::reveal_all() }); + let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() }); + mask.density = 0.75; + mask.feather = 8.0; + doc.layers[1].vector_mask = Some(mask); + assert!(proxy_faithful(&doc)); + let reference = opacity_reference(&doc, 0.25); + for k in [1, 2, 4] { + let (actual, expected) = (proxy_document(&doc, k), proxy_document(&reference, k)); + // Q(0.25 * pixel) versus 0.25 * Q(pixel): U16 rounding differs by at most + // 0.625 / 65535. Allow one U16 level plus a small float arithmetic allowance. + assert_same_pixels_within(&crate::render(&actual, actual.bounds()), &crate::render(&expected, expected.bounds()), 1.0 / 65535.0 + 1e-6); + } + } + + #[test] + fn empty_vector_masks_keep_the_pixel_mask_gradient_frame() { + let mut doc = two_colors(Size::new(32, 16), SampleType::F32); + doc.layers[1].content = LayerContent::Fill(Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false)); + let mut pixel = LayerMask::hide_all(); + pixel.surface.fill_rect(Rect::new(8, 4, 24, 12), &[1.0]); + doc.layers[1].mask = Some(pixel); + for inverted in [false, true] { + let mut mask = VectorMask::new(Path { inverted, ..Path::default() }); + mask.density = 0.5; + mask.feather = 3.0; + doc.layers[1].vector_mask = Some(mask); + assert!(proxy_faithful(&doc)); + assert_eq!(crate::fill_frame(&doc.layers[1], doc.bounds()), Rect::new(8, 4, 24, 12)); + let actual = proxy_document(&doc, 4); + assert_eq!(crate::fill_frame(&actual.layers[1], actual.bounds()), Rect::new(2, 1, 6, 3)); + let reference = proxy_document(&opacity_reference(&doc, u16_coverage(if inverted { 0.5 } else { 1.0 })), 4); + assert_same_pixels(&crate::render(&actual, actual.bounds()), &crate::render(&reference, reference.bounds())); + } + } + + #[test] + fn zero_density_geometry_still_blocks_the_proxy_and_anchors_a_gradient() { + let mut doc = Document::new("gradient", Size::new(64, 32), ColorMode::Rgb, SampleType::F32); + let fill = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false); + let mut layer = Layer::new("gradient", LayerContent::Fill(fill)); + let path = Path::new(vec![Subpath::polygon(&[(16.0, 8.0), (48.0, 8.0), (48.0, 24.0), (16.0, 24.0)])]); + let mut mask = VectorMask::new(path); + mask.density = 0.0; + layer.vector_mask = Some(mask); + doc.layers.push(layer); + assert!(!proxy_faithful(&doc)); + assert_eq!(crate::fill_frame(&doc.layers[0], doc.bounds()), Rect::new(16, 8, 48, 24)); + // Density zero reveals all pixels, but the unscaled path would still move the fill. + let forced_proxy = proxy_document(&doc, 4); + let exact = crate::render_reduced(&doc, 16, 8); + let wrong = crate::render(&forced_proxy, forced_proxy.bounds()); + assert!(exact.px.iter().zip(&wrong.px).any(|(a, b)| (a[0] - b[0]).abs() > 0.1)); + } + + #[test] + fn large_thumbnail_keeps_inverted_empty_mask_coverage() { + // Default-only surfaces exercise the large-document branch without allocating 17 MP. + let mut doc = two_colors(Size::new(4200, 4200), SampleType::F32); + let mut mask = VectorMask::new(Path { inverted: true, ..Path::default() }); + mask.density = 0.75; + mask.feather = 8.0; + doc.layers[1].vector_mask = Some(mask); + assert!(doc.size.area() > crate::PROXY_THUMBNAIL_PIXELS); + assert!(proxy_faithful(&doc)); + let actual = crate::thumbnail_buffer(&doc, 4); + assert_eq!(actual.rect, Rect::new(0, 0, 4, 4)); + let coverage = u16_coverage(0.25); + assert_eq!(actual.px, vec![[coverage, 0.0, 1.0 - coverage, 1.0]; 16]); + } } From e54adc250c5c706b1c3b1d6f633e38d8257c41ca Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:12:55 +0700 Subject: [PATCH 090/523] feat(codecs): decode legacy PCX images and palettes (#3692) (#3701) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/services.rs | 4 +- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/pcx.rs | 118 ++++++++++++++++++++++++++++++++ crates/codecs/src/format.rs | 16 ++++- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/pcx.rs | 100 +++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 crates/codecs/src/codecs/pcx.rs create mode 100644 crates/codecs/tests/pcx.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 0b1a366a69..f6695717be 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", - "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", + "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index ea70ccc2b8..22845d930b 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod fits; pub(crate) mod gif_frames; pub(crate) mod heif; pub(crate) mod jpeg; +pub(crate) mod pcx; pub(crate) mod png; pub(crate) mod pnm; pub(crate) mod tiff; diff --git a/crates/codecs/src/codecs/pcx.rs b/crates/codecs/src/codecs/pcx.rs new file mode 100644 index 0000000000..43a3e97302 --- /dev/null +++ b/crates/codecs/src/codecs/pcx.rs @@ -0,0 +1,118 @@ +//! ZSoft PCX (Paintbrush) reader: strictly bounded scanline RLE, 16/256-colour +//! palettes and 8-bit three-plane RGB. Read-only: modern saves should use PNG. + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +const HEADER: usize = 128; + +fn bad(reason: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Pcx, reason) +} +fn le16(b: &[u8], i: usize) -> u16 { + u16::from_le_bytes([b[i], b[i + 1]]) +} + +/// PCX lacks a strong magic number. Require plausible complete header fields before +/// identifying it, rather than classifying every file starting with 0x0a as PCX. +pub(crate) fn looks_like_pcx(b: &[u8]) -> bool { + if b.len() < HEADER || b[0] != 0x0a || !matches!(b[1], 0 | 2 | 3 | 5) || b[2] != 1 { + return false; + } + let bits = b[3]; + let planes = b[65]; + let stride = u32::from(le16(b, 66)); + let (x0, y0, x1, y1) = (le16(b, 4), le16(b, 6), le16(b, 8), le16(b, 10)); + if x1 < x0 || y1 < y0 || !matches!(bits, 1 | 2 | 4 | 8) || !(1..=4).contains(&planes) { + return false; + } + let width = u32::from(x1 - x0) + 1; + let min_stride = (width * u32::from(bits)).div_ceil(8); + stride >= min_stride && stride > 0 && (bits != 8 || matches!(planes, 1 | 3)) && (bits == 8 || u32::from(bits) * u32::from(planes) <= 4) +} + +/// Read one compressed plane's row. RLE packets never cross a scanline. +fn read_row(b: &[u8], pos: &mut usize, out: &mut [u8]) -> Result<(), CodecError> { + let mut n = 0; + while n < out.len() { + let code = *b.get(*pos).ok_or_else(|| bad("truncated pixel RLE"))?; + *pos += 1; + let (count, value) = if code >= 0xc0 { + let count = usize::from(code & 0x3f); + let value = *b.get(*pos).ok_or_else(|| bad("truncated RLE packet"))?; + *pos += 1; + (count, value) + } else { + (1, code) + }; + if count == 0 || count > out.len() - n { + return Err(bad("RLE packet crosses a scanline or has zero length")); + } + out[n..n + count].fill(value); + n += count; + } + Ok(()) +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !looks_like_pcx(b) { + return Err(bad("invalid PCX header or unsupported pixel layout")); + } + let (w, h) = (u32::from(le16(b, 8) - le16(b, 4)) + 1, u32::from(le16(b, 10) - le16(b, 6)) + 1); + limits.check(w, h, ChannelLayout::Rgb, SampleType::U8)?; + let (bits, planes, stride) = (usize::from(b[3]), usize::from(b[65]), usize::from(le16(b, 66))); + let row_len = stride.checked_mul(planes).ok_or_else(|| bad("row size overflow"))?; + let total = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(3)).ok_or_else(|| bad("pixel size overflow"))?; + // The last 769 bytes are the optional VGA palette for 8-bit indexed images. + let vga = bits == 8 && planes == 1 && b.len() >= HEADER + 769 && b[b.len() - 769] == 0x0c; + if bits == 8 && planes == 1 && !vga && le16(b, 68) != 2 { + return Err(bad("8-bit indexed PCX has no 256-colour palette")); + } + let image_end = if vga { b.len() - 769 } else { b.len() }; + let mut row = vec![0u8; row_len]; + let mut pixels = vec![0u8; total]; + let mut p = HEADER; + let width = w as usize; + for y in 0..h as usize { + for plane in row.chunks_mut(stride) { + read_row(&b[..image_end], &mut p, plane)?; + } + for x in 0..width { + let rgb = if bits == 8 && planes == 3 { + [row[x], row[stride + x], row[2 * stride + x]] + } else { + let index = if bits == 8 { + usize::from(row[x]) + } else { + let bit = x * bits; + let shift = 8 - bits - (bit % 8); + let mask = (1usize << bits) - 1; + (0..planes).fold(0usize, |v, plane| { + let sample = (usize::from(row[plane * stride + bit / 8]) >> shift) & mask; + v | sample << (plane * bits) + }) + }; + if vga { + let o = image_end + 1 + index * 3; + [b[o], b[o + 1], b[o + 2]] + } else if bits == 8 { + let g = index as u8; + [g, g, g] // palette-info 2: gray scale + } else { + let o = 16 + index * 3; + [b[o], b[o + 1], b[o + 2]] + } + }; + pixels[(y * width + x) * 3..(y * width + x + 1) * 3].copy_from_slice(&rgb); + } + } + let mut image = Image::from_u8(w, h, ChannelLayout::Rgb, pixels)?; + let (xdpi, ydpi) = (le16(b, 12), le16(b, 14)); + if xdpi > 0 && ydpi > 0 { + image.meta.dpi = Some((f32::from(xdpi), f32::from(ydpi))); + } + Ok(image) +} diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 3d3fa5426e..ddb7587141 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -15,6 +15,8 @@ pub enum Format { Ico, /// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`). Pnm, + /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. + Pcx, Qoi, OpenExr, /// Radiance RGBE. @@ -56,6 +58,10 @@ pub struct FormatCaps { /// Formats that are enabled but intentionally not symmetric, with the reason. /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ + ( + Format::Pcx, + "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", + ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ @@ -78,7 +84,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 15] = [ + pub const ALL: [Format; 16] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -88,6 +94,7 @@ impl Format { Format::Tga, Format::Ico, Format::Pnm, + Format::Pcx, Format::Qoi, Format::OpenExr, Format::Hdr, @@ -117,6 +124,7 @@ impl Format { Format::Tga => "TGA", Format::Ico => "ICO", Format::Pnm => "Netpbm", + Format::Pcx => "PCX", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", @@ -139,6 +147,7 @@ impl Format { Format::Tga => &["tga", "icb", "vda", "vst"], Format::Ico => &["ico"], Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], + Format::Pcx => &["pcx"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], @@ -159,6 +168,7 @@ impl Format { Format::Tga => "image/x-tga", Format::Ico => "image/x-icon", Format::Pnm => "image/x-portable-anymap", + Format::Pcx => "image/x-pcx", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", @@ -207,6 +217,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base }, Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base }, Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, + Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -266,6 +277,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if crate::codecs::pcx::looks_like_pcx(b) { + return Some(Format::Pcx); + } if looks_like_tga(b) { return Some(Format::Tga); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 424ec0b8aa..8278ac0d6b 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, png, pnm, tiff, via_image, webp}; +use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -74,6 +74,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => tiff::decode(bytes, l), Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), + Format::Pcx => pcx::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), @@ -216,6 +217,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result tiff::encode(image, plan, opts), Format::WebP => webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), + Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), diff --git a/crates/codecs/tests/pcx.rs b/crates/codecs/tests/pcx.rs new file mode 100644 index 0000000000..89661cb8eb --- /dev/null +++ b/crates/codecs/tests/pcx.rs @@ -0,0 +1,100 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, SampleType, caps, decode, decode_as, detect, encode, from_extension}; + +fn header(bits: u8, planes: u8, width: u16, height: u16, stride: u16) -> Vec { + let mut b = vec![0u8; 128]; + b[0] = 0x0a; // ZSoft + b[1] = 5; // PC Paintbrush 3.0+ + b[2] = 1; // RLE + b[3] = bits; + b[8..10].copy_from_slice(&(width - 1).to_le_bytes()); + b[10..12].copy_from_slice(&(height - 1).to_le_bytes()); + b[12..14].copy_from_slice(&300u16.to_le_bytes()); + b[14..16].copy_from_slice(&150u16.to_le_bytes()); + b[65] = planes; + b[66..68].copy_from_slice(&stride.to_le_bytes()); + b[68..70].copy_from_slice(&1u16.to_le_bytes()); + b +} + +fn rle_row(out: &mut Vec, row: &[u8]) { + for &value in row { + if value >= 0xc0 { + out.extend_from_slice(&[0xc1, value]); + } else { + out.push(value); + } + } +} + +#[test] +fn pcx_is_readable_but_not_writable() { + assert_eq!(from_extension("archive.PCX"), Some(Format::Pcx)); + assert!(caps(Format::Pcx).read && !caps(Format::Pcx).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgb, vec![0, 0, 0]).unwrap(); + assert!(matches!(encode(&img, Format::Pcx, &Default::default()), Err(CodecError::Unsupported { .. }))); +} + +#[test] +fn decodes_three_plane_rgb_and_keeps_dpi() { + let mut b = header(8, 3, 2, 1, 2); + for row in [&[255, 0][..], &[0, 255][..], &[0, 0][..]] { + rle_row(&mut b, row); + } + assert_eq!(detect(&b), Some(Format::Pcx)); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgb); + assert_eq!(im.sample_type(), SampleType::U8); + assert_eq!(im.dimensions(), (2, 1)); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0]); + assert_eq!(im.meta.dpi, Some((300.0, 150.0))); +} + +#[test] +fn decodes_256_colour_vga_palette() { + let mut b = header(8, 1, 2, 1, 2); + rle_row(&mut b, &[1, 2]); + b.push(0x0c); + let mut palette = vec![0; 256 * 3]; + palette[3..6].copy_from_slice(&[255, 0, 0]); + palette[6..9].copy_from_slice(&[0, 255, 0]); + b.extend(palette); + assert_eq!(decode(&b).unwrap().data(), &[255, 0, 0, 0, 255, 0]); +} + +#[test] +fn decodes_4bit_and_monochrome_palettes() { + let mut four = header(4, 1, 2, 1, 2); + four[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]); + four[16 + 6..16 + 9].copy_from_slice(&[0, 0, 255]); + rle_row(&mut four, &[0x12, 0]); + assert_eq!(decode(&four).unwrap().data(), &[255, 0, 0, 0, 0, 255]); + + let mut one = header(1, 1, 2, 1, 2); + one[16 + 3..16 + 6].copy_from_slice(&[255, 0, 0]); + rle_row(&mut one, &[0x80, 0]); + assert_eq!(decode(&one).unwrap().data(), &[255, 0, 0, 0, 0, 0]); +} + +#[test] +fn rejects_truncated_and_overshooting_rle_without_panicking() { + let mut b = header(8, 3, 2, 1, 2); + assert!(decode_as(Format::Pcx, &b).is_err(), "header alone is truncated"); + b.extend([0xc5, 0xff]); // a five-pixel run cannot cross a two-byte row + assert!(decode_as(Format::Pcx, &b).is_err()); + let mut b = header(8, 3, 2, 1, 2); + b.extend([0xc1]); // missing run value + assert!(decode_as(Format::Pcx, &b).is_err()); + let mut b = header(8, 3, 2, 1, 2); + b[66..68].copy_from_slice(&0u16.to_le_bytes()); + assert_eq!(detect(&b), None, "invalid stride is not a PCX signature"); +} + +#[test] +fn refuses_headers_that_exceed_allocation_budget() { + let mut b = header(8, 3, 3000, 3000, 3000); + // The default decoder rejects the allocation before reading any scanline. + let opts = photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() }; + assert!(matches!(photocraft_codecs::decode_as_with(Format::Pcx, &b, &opts), Err(CodecError::LimitExceeded(_)))); + b[2] = 0; + assert_eq!(detect(&b), None, "unsupported uncompressed encoding"); +} From 0c2ca488aeac6f10ebd6f7af5a4569040931c052 Mon Sep 17 00:00:00 2001 From: Nicolas Falesy <112726756+nicolasfalesy@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:13:06 -0400 Subject: [PATCH 091/523] fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When drawing faux-bold glyphs, the offset dilation was composed as `Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o` first (`self ∘ o`), the translation offset was added after the glyph's scaling transform, leaving faux bold dilation unscaled by horizontal and vertical font scaling. Applying the glyph transform to the offset transform (`glyph.mul(&offset)`) ensures dilation is performed in font-local coordinates and scaled proportionally with anisotropic glyph scaling, matching Photoshop. --- crates/text/src/render.rs | 2 +- crates/text/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 6068ae4a69..acd9d51132 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -72,7 +72,7 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c let offsets: &[(f64, f64)] = if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] }; for &(ox, oy) in offsets { - let local = Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r]).mul(&glyph); + let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); match warp { Some(w) => { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8df4d81b0e..75b775b783 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -381,6 +381,41 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling. +#[test] +fn faux_bold_scales_with_glyph_scaling() { + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 40.0, ..Default::default() }; + let ink = |e: &mut TextEngine, s: CharStyle| { + let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 50.0), ..styled("Hi", s) }, 72.0, PixelFormat::RGBA8); + (alpha_sum(&r.surface, r.rect), r.rect) + }; + let (_plain, prect) = ink(&mut e, base.clone()); + let (_bold, brect) = ink(&mut e, CharStyle { faux_bold: true, ..base.clone() }); + let unscaled_extra_w = brect.width() - prect.width(); + let unscaled_extra_h = brect.height() - prect.height(); + + // With 2x horizontal scale, the horizontal dilation scales with the glyph: + let hscaled = CharStyle { size_pt: 40.0, horizontal_scale: 2.0, ..Default::default() }; + let (_, phrect) = ink(&mut e, hscaled.clone()); + let (_, bhrect) = ink(&mut e, CharStyle { faux_bold: true, ..hscaled }); + let hscaled_extra_w = bhrect.width() - phrect.width(); + assert!( + hscaled_extra_w >= unscaled_extra_w * 2 - 1 && hscaled_extra_w <= unscaled_extra_w * 2 + 1, + "hscaled extra width {hscaled_extra_w} vs unscaled {unscaled_extra_w}" + ); + + // With 2x vertical scale, the vertical dilation scales with the glyph: + let vscaled = CharStyle { size_pt: 40.0, vertical_scale: 2.0, ..Default::default() }; + let (_, pvrect) = ink(&mut e, vscaled.clone()); + let (_, bvrect) = ink(&mut e, CharStyle { faux_bold: true, ..vscaled }); + let vscaled_extra_h = bvrect.height() - pvrect.height(); + assert!( + vscaled_extra_h >= unscaled_extra_h * 2 - 1 && vscaled_extra_h <= unscaled_extra_h * 2 + 1, + "vscaled extra height {vscaled_extra_h} vs unscaled {unscaled_extra_h}" + ); +} + #[test] fn multicolor_runs() { let mut e = TextEngine::new(); From 89312de597dbad53ca7e502da0008344d6bb6036 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 13:13:32 +0200 Subject: [PATCH 092/523] Answer a repeat auth with "already authenticated" (#3723) A second `auth` frame on an authenticated TCP connection fell through to the method dispatcher and came back as an unknown method, in both the headless serve server and the desktop control server. A shared `security::repeat_auth_reply` now answers it with `already authenticated` (keeping the request id) and the connection keeps serving. Fixes #3668 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/control_server.rs | 48 ++++++++++++++++++++++++++- crates/automation/src/rpc.rs | 34 +++++++++++++++++-- crates/automation/src/security.rs | 23 +++++++++++++ docs/control-protocol.md | 2 ++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index 932e31ee60..b1282419d8 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -9,7 +9,7 @@ use std::time::Instant; use photocraft_automation::budgets::write_reply; use photocraft_automation::security::{ - ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, + ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error}; @@ -79,6 +79,12 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: } continue; } + if let Some(reply) = repeat_auth_reply(&line) { + if write_reply(&mut out, &reply).is_err() || out.flush().is_err() { + break; + } + continue; + } let reply = match serde_json::from_str::(&line) { Ok(msg) => { let id = msg.get("id").cloned().unwrap_or(Value::Null); @@ -202,4 +208,44 @@ mod tests { handler.join().unwrap(); server.join().unwrap(); } + + #[test] + fn repeat_auth_reports_already_authenticated_without_dispatching() { + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + serve(stream, TOKEN, tx, egui::Context::default()); + }); + let handler = std::thread::spawn(move || { + let req = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(req.method, "test.after", "a repeat auth must not reach the UI dispatcher"); + req.reply.send(json!({"ok": true, "result": "still serving"})).unwrap(); + }); + let mut stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + for id in ["auth", "again"] { + writeln!(stream, "{}", json!({"id": id, "method": "auth", "params": {"token": TOKEN}})).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + } + let again: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(again["id"], "again"); + assert_eq!(again["ok"], false); + assert_eq!(again["error"], "already authenticated"); + writeln!(stream, "{}", json!({"id": 9, "method": "test.after"})).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + let after: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(after["id"], 9); + assert_eq!(after["result"], "still serving"); + drop(reader); + drop(stream); + handler.join().unwrap(); + server.join().unwrap(); + } } diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index d513bfa889..875850460c 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -30,7 +30,7 @@ use serde_json::{Value, json}; use crate::budgets::{BatchReplyBudget, write_reply}; use crate::security::{ ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line, - read_bounded_line, + read_bounded_line, repeat_auth_reply, }; use crate::{AutomationError, Headless}; @@ -288,7 +288,7 @@ fn serve_tcp_connection(h: &Mutex, stream: std::net::TcpStream, token: LineRead::Line => {} } let reply = if authenticated { - respond(h, &line) + repeat_auth_reply(&line).unwrap_or_else(|| respond(h, &line)) } else { let (reply, ok) = authentication_reply(&line, token); authenticated = ok; @@ -780,6 +780,36 @@ mod tests { assert!(v["result"].as_array().unwrap().iter().any(|m| m == "batch")); } + #[test] + fn tcp_repeat_auth_reports_already_authenticated_and_keeps_serving() { + use std::io::{BufReader, Write as _}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + let h = Arc::new(Mutex::new(Headless::new())); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = serve_tcp("127.0.0.1:0", h, TOKEN.into(), move |a| tx.send(a).unwrap()); + }); + let addr = rx.recv().unwrap(); + let mut s = std::net::TcpStream::connect(addr).unwrap(); + let mut reader = BufReader::new(s.try_clone().unwrap()); + let mut line = String::new(); + for id in ["auth", "again"] { + writeln!(s, r#"{{"id":"{id}","method":"auth","params":{{"token":"{TOKEN}"}}}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + } + let again: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(again["id"], "again"); + assert_eq!(again["ok"], false); + assert_eq!(again["error"], "already authenticated"); + writeln!(s, r#"{{"id":"m","method":"methods"}}"#).unwrap(); + line.clear(); + reader.read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], "m"); + assert_eq!(v["ok"], true); + } + #[test] fn tcp_rejects_requests_before_authentication() { use std::io::{BufReader, Write as _}; diff --git a/crates/automation/src/security.rs b/crates/automation/src/security.rs index 075427c03f..4659a3ff17 100644 --- a/crates/automation/src/security.rs +++ b/crates/automation/src/security.rs @@ -118,6 +118,18 @@ pub fn authentication_reply(line: &str, expected_token: &str) -> (Value, bool) { } } +/// Answer a repeat `auth` frame on an already authenticated connection. Returns `None` for any +/// other frame, which the caller dispatches as usual. +/// Without this, a second `auth` fell through to the method dispatcher as an unknown method. +pub fn repeat_auth_reply(line: &str) -> Option { + let req: Value = serde_json::from_str(line).ok()?; + if req.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) { + return None; + } + let id = req.get("id").cloned().unwrap_or(Value::Null); + Some(json!({"id": id, "ok": false, "error": "already authenticated"})) +} + fn read_token_file(path: &Path) -> Result { let token = std::fs::read_to_string(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?; let token = token.trim().to_owned(); @@ -294,6 +306,17 @@ mod tests { assert_eq!(reply["result"]["authenticated"], true); } + #[test] + fn repeat_auth_on_an_authenticated_connection_says_so() { + let line = json!({"id": 3, "method": AUTH_METHOD, "params": {"token": "anything"}}).to_string(); + let reply = repeat_auth_reply(&line).unwrap(); + assert_eq!(reply["id"], 3); + assert_eq!(reply["ok"], false); + assert_eq!(reply["error"], "already authenticated"); + assert!(repeat_auth_reply(r#"{"id":4,"method":"methods"}"#).is_none()); + assert!(repeat_auth_reply("{not json").is_none()); + } + #[test] fn token_file_round_trips_between_server_and_client() { let path = std::env::temp_dir().join(format!("photocraft-control-token-{}-{}.txt", std::process::id(), generate_token().unwrap())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c73a0be6a6..0d974c095f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,6 +23,8 @@ The first request on every TCP connection must authenticate. No other method is {"id": "auth", "method": "auth", "params": {"token": "<64 hexadecimal characters>"}} ``` +Authenticate once per connection. A later `auth` frame on the same connection gets `{"ok": false, "error": "already authenticated"}` and the connection keeps serving. + After the successful authentication reply, each request is one JSON line: ```json From 6751a460032f4619d8eabd3ddec6fb890a040c94 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:13:45 +0200 Subject: [PATCH 093/523] Codecs: integer fast paths for Image::convert (#3717) (#3729) U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route. Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/image.rs | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 01f5d4cad0..92d8fb5823 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -512,6 +512,8 @@ impl Image { out.extend_from_slice(&one); } out + } else if let Some(fast) = self.convert_int_direct(layout, sample) { + fast } else if layout == self.layout { quantize(&self.to_normalized(), sample) } else { @@ -531,6 +533,55 @@ impl Image { } } + /// Integer fast path of [`Image::convert`] for U8/U16 sources and targets whose result is + /// a pure channel copy: same colour model (alpha added as opaque, or dropped) or gray to + /// RGB. Bit-identical to the generic f32 route (see the equivalence tests) but without + /// the two intermediate f32 buffers. Returns `None` for everything else. + fn convert_int_direct(&self, layout: ChannelLayout, sample: SampleType) -> Option> { + let int = |s: SampleType| matches!(s, SampleType::U8 | SampleType::U16); + if !int(self.sample) || !int(sample) { + return None; + } + let gray_to_rgb = self.layout.is_gray() && layout.is_rgb(); + if !gray_to_rgb && !layout.same_model(self.layout) { + return None; + } + let one: &[u8] = match sample { + SampleType::U8 => &[u8::MAX], + _ => &u16::MAX.to_ne_bytes(), + }; + let (fc, tc) = (self.layout.channels(), layout.channels()); + let (sb, db) = (self.sample.bytes(), sample.bytes()); + let (src_color, dst_color) = (self.layout.color_channels(), layout.color_channels()); + let n = self.data.len() / (fc * sb); + let mut out = Vec::with_capacity(n.checked_mul(tc)?.checked_mul(db)?); + macro_rules! run { + ($conv:expr) => {{ + let conv = $conv; + for px in self.data.chunks_exact(fc * sb) { + for j in 0..dst_color { + let c = if src_color == 1 { 0 } else { j }; + conv(&px[c * sb..(c + 1) * sb], &mut out); + } + if layout.has_alpha() { + if self.layout.has_alpha() { + conv(&px[src_color * sb..(src_color + 1) * sb], &mut out); + } else { + out.extend_from_slice(one); + } + } + } + }}; + } + match (self.sample, sample) { + (SampleType::U8, SampleType::U8) => run!(|s: &[u8], o: &mut Vec| o.push(s[0])), + (SampleType::U16, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(s)), + (SampleType::U8, SampleType::U16) => run!(|s: &[u8], o: &mut Vec| o.extend_from_slice(&(u16::from(s[0]) * 257).to_ne_bytes())), + _ => run!(|s: &[u8], o: &mut Vec| o.push(((u32::from(u16::from_ne_bytes([s[0], s[1]])) + 128) / 257) as u8)), + } + Some(out) + } + /// [`Image::convert`], borrowing `self` when it already has that layout and sample type /// (encoders use it so a full-size image isn't copied for nothing). pub fn converted(&self, layout: ChannelLayout, sample: SampleType) -> std::borrow::Cow<'_, Image> { @@ -661,6 +712,66 @@ mod tests { use super::*; + /// The generic f32 route `convert` used before the integer fast paths (the reference). + fn convert_via_f32(img: &Image, layout: ChannelLayout, sample: SampleType) -> Vec { + quantize(&convert_layout(&img.to_normalized(), img.layout, layout), sample) + } + + #[test] + fn int_fast_path_matches_f32_route_for_every_value() { + // Every U8 and every U16 value through every depth pair (alpha goes through the same per-sample conversion; covered by the layout test). + let all8: Vec = (0..=255).collect(); + let all16: Vec = (0..=65535).collect(); + for (src, to) in + [(SampleType::U8, SampleType::U8), (SampleType::U8, SampleType::U16), (SampleType::U16, SampleType::U8), (SampleType::U16, SampleType::U16)] + { + let img = if src == SampleType::U8 { + Image::from_u8(256, 1, ChannelLayout::Gray, all8.clone()).unwrap() + } else { + Image::from_u16(65536, 1, ChannelLayout::Gray, &all16).unwrap() + }; + for layout in [ChannelLayout::Gray, ChannelLayout::Rgb] { + assert_eq!(img.convert(layout, to).data(), &convert_via_f32(&img, layout, to)[..], "{src:?}->{to:?} {layout:?}"); + } + } + } + + #[test] + fn int_fast_path_matches_f32_route_for_every_layout() { + let mut seed = 0x9e3779b97f4a7c15u64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for from_s in [SampleType::U8, SampleType::U16] { + for from_l in ChannelLayout::ALL { + let n = 64 * from_l.channels(); + let mut vals = Vec::new(); + for i in 0..n { + // Edge values (0, 1, max-1, max, mid) mixed with random ones. + let r = next(); + let v = match i % 7 { + 0 => 0.0, + 1 => 1.0, + 2 => 1.0 / 255.0, + 3 => 254.0 / 255.0, + _ => (r % 65536) as f32 / 65535.0, + }; + vals.push(v); + } + let img = Image::from_normalized(64, 1, from_l, from_s, &vals).unwrap(); + for to_l in ChannelLayout::ALL { + for to_s in SampleType::ALL { + let got = img.convert(to_l, to_s); + assert_eq!(got.data(), &convert_via_f32(&img, to_l, to_s)[..], "{from_l:?}/{from_s:?} -> {to_l:?}/{to_s:?}"); + } + } + } + } + } + #[test] fn u8_u16_roundtrip_exact() { let v: Vec = (0..=255).collect(); From 3dd5fc1cdc631efcc213d849b86829602895b7ea Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 13:13:57 +0200 Subject: [PATCH 094/523] automation: say why a command is disabled in engine.commands (#3740) engine.commands, command.list and the MCP command_list tool now give each row a disabledReason (null when enabled, else the failed precondition) and needsDocument (true when the command is off because no document is open), so an agent can tell "open a document first" from "not runnable". enabled keeps its meaning. Both listings share one row builder in the engine. Fixes #3664 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/automation/src/headless.rs | 16 +--------------- crates/automation/src/server.rs | 4 +++- crates/automation/tests/mcp.rs | 6 ++++++ crates/engine/src/commands.rs | 26 ++++++++++++++------------ crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ docs/control-protocol.md | 6 +++--- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 028ca09f14..dc18afe2f2 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -317,21 +317,7 @@ impl Headless { } pub fn command_list(&self) -> Value { - Value::Array( - command_specs() - .iter() - .map(|c| { - json!({ - "id": c.id, - "label": c.label, - "menu": c.menu, - "shortcut": c.shortcut, - "params": c.params, - "enabled": self.session.is_enabled(c.id), - }) - }) - .collect(), - ) + Value::Array(command_specs().iter().map(|c| photocraft_engine::commands::list_entry(&self.session, c, self.session.disabled_reason(c.id))).collect()) } pub fn command_run(&mut self, id: &str, params: Value) -> Result { diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 909e221984..c2f18b8bd5 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -792,7 +792,9 @@ impl PhotocraftMcp { } } - #[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now.")] + #[tool(description = "List engine commands: id, label, menu path, shortcut, parameter doc, enabled now. \ + `disabledReason` says why a command is off (null when enabled); `needsDocument` is true when it is off because no \ + document is open: create or open one first (doc_new, doc_open).")] async fn command_list(&self, Parameters(p): Parameters) -> Result { let all = if let Some(r) = self.headless_op(|h| Ok(h.command_list())).await { r diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 3ec39ebcc0..af5459af1f 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -170,6 +170,12 @@ async fn command_list_filters() { let enabled = json_of(&call(&client, "command_list", json!({"enabled_only": true})).await); assert!(enabled.as_array().unwrap().iter().any(|c| c["id"] == "file.new")); assert!(!enabled.as_array().unwrap().iter().any(|c| c["id"] == "layer.new.layer"), "needs a document"); + // The full list says why a document command is off with no document open (#3664). + let layer = all.as_array().unwrap().iter().find(|c| c["id"] == "layer.new.layer").unwrap(); + assert_eq!((layer["enabled"].clone(), layer["needsDocument"].clone()), (json!(false), json!(true)), "{layer}"); + assert!(layer["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{layer}"); + let new = all.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap(); + assert_eq!((new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(false), Value::Null), "{new}"); client.cancel().await.unwrap(); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 3554e5690f..48caf85b28 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -136,6 +136,19 @@ pub fn command_specs() -> &'static [CommandSpec] { ®istry().specs } +/// One `command.list` / `engine.commands` row for `c`, given why it can't run now (`None` = it can). +/// `disabledReason` is that message; `needsDocument` is true when the reason is that no document is +/// open (#3664), so an agent can tell "open or create a document first" from a precondition that a +/// document alone won't meet (a selection, an empty clipboard). +pub fn list_entry(s: &Session, c: &CommandSpec, reason: Option) -> Value { + let needs_document = + s.active().is_none() && reason.as_deref().is_some_and(|r| ["no document", "no active document", "no open document"].iter().any(|p| r.starts_with(p))); + json!({ + "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, + "enabled": reason.is_none(), "disabledReason": reason, "needsDocument": needs_document, + }) +} + pub fn find(id: &str) -> Option<&'static CommandSpec> { let registry = registry(); registry.by_id.get(id).and_then(|&index| registry.specs.get(index)) @@ -1014,18 +1027,7 @@ fn build() -> Vec { shortcut: None, params: "{}", enabled: always, - run: |s, _| { - Ok(Value::Array( - command_specs() - .iter() - .map(|c| { - json!({ - "id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok(), - }) - }) - .collect(), - )) - }, + run: |s, _| Ok(Value::Array(command_specs().iter().map(|c| list_entry(s, c, (c.enabled)(s).err())).collect())), journal: false, }, CommandSpec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e4cbfe57a5..a35a19bb43 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -767,6 +767,33 @@ fn command_list_reports_enablement() { assert_eq!(find("layer.new.layer")["enabled"], false); } +/// With no document open, a document command says so instead of only `enabled: false` (#3664). +#[test] +fn command_list_tells_no_document_from_other_preconditions() { + let mut s = Session::new(); + let list = s.execute("command.list", json!({})).unwrap(); + let find = |list: &Value, id: &str| list.as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); + for id in ["paint.stroke", "layer.new.layer", "filter.blur.gaussianBlur"] { + let c = find(&list, id); + assert_eq!((c["enabled"].clone(), c["needsDocument"].clone()), (json!(false), json!(true)), "{id}: {c}"); + assert!(c["disabledReason"].as_str().is_some_and(|r| r.starts_with("no document")), "{id}: {c}"); + } + let new = find(&list, "file.new"); + assert_eq!((new["enabled"].clone(), new["needsDocument"].clone(), new["disabledReason"].clone()), (json!(true), json!(false), Value::Null)); + // A precondition that isn't the missing document keeps its own reason. + let paste = find(&list, "file.newFromClipboard"); + assert_eq!((paste["enabled"].clone(), paste["needsDocument"].clone()), (json!(false), json!(false))); + assert_eq!(paste["disabledReason"], "the clipboard is empty"); + // With a document, the same commands run; a missing selection is not a missing document. + let mut s = session_with_doc(); + let list = s.execute("command.list", json!({})).unwrap(); + assert_eq!(find(&list, "paint.stroke")["needsDocument"], false); + assert_eq!(find(&list, "paint.stroke")["disabledReason"], Value::Null); + let deselect = find(&list, "select.deselect"); + assert_eq!((deselect["enabled"].clone(), deselect["needsDocument"].clone()), (json!(false), json!(false))); + assert_eq!(deselect["disabledReason"], "no selection"); +} + #[test] fn translate_moves_pixels_and_respects_locks() { let mut s = session_with_doc(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0d974c095f..3f0be8ebe9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -90,7 +90,7 @@ Change the UI language without restarting with `prefs.set`: are documented in [UI localisation](localization.md). `prefs.get` and the existing preference store expose and persist the same setting; scripts keep using canonical command IDs. -`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled. Examples: +`engine.execute` runs any command by id. `engine.commands` (or the engine command `command.list`) lists them all, with labels, menu paths, shortcuts, a parameter description, and whether each is currently enabled. Each row carries `enabled` (bool), `disabledReason` (`null` when enabled, otherwise the precondition that failed, such as `"no document open"`, `"no selection"` or `"the clipboard is empty"`) and `needsDocument` (true when the command is off because no document is open, so the first step is to create or open one; a command may still have its own precondition after that, such as an active text layer. False when enabled or off for another reason). With no document open, nearly every document command reports `enabled: false, needsDocument: true`; plan around `needsDocument` rather than reading `enabled` alone (#3664). Examples: | Command | Params | |---|---| @@ -263,7 +263,7 @@ How each MCP tool maps onto control methods in bridge mode: |---|---| | `command_run {id, params, wait?}` | `engine.execute {command: id, params, wait}` | | `jobs_list` / `jobs_cancel {job?}` | `jobs.list` / `jobs.cancel {job?}` | -| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server) | +| `command_list {filter?, enabled_only?}` | `engine.commands` (filtered by the MCP server; rows keep `disabledReason` and `needsDocument`) | | `doc_new {…}` | `engine.execute {command: "file.new", params}` | | `doc_inspect` | `engine.execute {command: "document.inspect"}` | | `doc_open {path}` | `app.open {path}` | @@ -321,7 +321,7 @@ no MCP framing, no app start-up per command. Configure its file access with the |---|---| | `engine.execute` | `{command, params?, wait?}`: any engine command (`wait: false` starts a long one as a background job: `{job, pending}`) | | `jobs.list` / `jobs.cancel` | `{}` / `{job?}`: background jobs; cancel one or all. Every request (and MCP tool call) first applies the jobs that finished, so `doc.save`, `doc.inspect`, `doc.render` and `session.list` include a finished job's result without polling `jobs.list` first | -| `engine.commands` | `{filter?}`: registry with params docs and enablement | +| `engine.commands` | `{filter?}`: registry with params docs and enablement (`enabled`, `disabledReason`, `needsDocument`) | | `session.list` | open documents and the active index | | `doc.open` / `doc.new` | `{path}` / `file.new` params | | `doc.place` | `{path, scale? (percent), fit? (true), center? ([x, y]), index?}`: places a file as a Smart Object layer above the active one and returns `{layer, scale, bounds}`. `path` resolves beneath the read root, so this is the capability-scoped `file.placeEmbedded` — that command itself still refuses an ambient path | From 53d814f6437db8a26d5a469f0f8198ab8f5fb658 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 12 Oct 2026 12:32:23 +0100 Subject: [PATCH 095/523] feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once they are all hidden, shows them again: its Layer menu item then reads Show Layers. layer.hideLayers now does the same, so the key toggles: it shows them only when every targeted layer is hidden. The / key toggles Lock Transparent Pixels, as the Layers panel's first lock button does (layer.toggleLockTransparency). With several layers selected, any unlocked one means lock them all; the Background, which can't take this lock, is left out. Each press is one undoable step. Co-Authored-By: Claude Opus 5.5 * feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking its thumbnail does, beside Ctrl+2 for the composite (channel.target.layerMask; greyed when the layer has no mask). Its Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection, the Ctrl-click on the composite channel (select.loadLuminosity). Co-Authored-By: Claude Opus 5.5 * feat(engine): Ctrl+Alt+Backspace fills from history Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or the layer, from the History Brush's source state. edit.fillHistory is edit.fill with contents "history": the oldest state still held, unless "state" names another. It joins the other fill keys, so it honours the Channels panel target and appears with them in Keyboard Shortcuts. Co-Authored-By: Claude Opus 5.5 * feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer While a layer name is being edited, Tab commits it and starts renaming the layer below, and Shift+Tab the layer above, as in Photoshop; the Background, which isn't renamed in place, ends it. Alt-clicking a layer's thumbnail selects the layer and fits its content on screen (View › Fit Layer(s) on Screen). Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace through the shortcut dispatcher. docs/ui-parity.md lists the new keys. Co-Authored-By: Claude Opus 5.5 * fix(ui): an explicit channel target drives the Layers panel's mask target After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back to the composite, but the Layers panel kept the mask targeted (its own flag, which only ever followed the engine towards the mask), so painting still went to the mask. The same happened after clicking the mask thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets the panel's mask and vector-mask targets from the engine's target. Co-Authored-By: Claude Opus 5.5 * Use Document::is_background after #4036 removed extra_cmds::is_background Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/channel_cmds.rs | 17 +++ crates/engine/src/channel_cmds/tests.rs | 27 ++++ crates/engine/src/extra_cmds.rs | 90 ++++++++++++- crates/engine/src/fill_key_cmds.rs | 52 +++++++- crates/ui-egui/src/layer_row_ui.rs | 18 ++- .../ui-egui/src/layer_row_ui/rename_tests.rs | 28 +++- crates/ui-egui/src/layer_shortcut_tests.rs | 124 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 12 ++ crates/ui-egui/src/panels.rs | 15 +++ docs/ui-parity.md | 4 +- 10 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/layer_shortcut_tests.rs diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index a85ff75b78..39ce9c8250 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1804,6 +1804,16 @@ pub fn specs() -> Vec { journal: false, }, spec!("channel.target.composite", "Target Composite Channel", [], Some("Cmd+2"), "{}", has_doc, |s, _| target_slot(s, 2)), + // ⌘\ targets the active layer's mask, as clicking its thumbnail does. + spec!("channel.target.layerMask", "Target Layer Mask", [], Some("Cmd+\\"), "{}", has_layer_mask, |s, _| target_cmd( + s, + &serde_json::json!({"channel": "mask"}) + )), + // ⌘⌥2 loads the image's luminosity as a selection (⌘-click on the composite channel). + spec!("select.loadLuminosity", "Load Luminosity", [], Some("Cmd+Alt+2"), "{}", has_doc, |s, _| load_selection( + s, + &serde_json::json!({"channel": "composite"}) + )), spec!("channel.target.slot3", "Target Channel 3", [], Some("Cmd+3"), "{}", slot_ok::<3>, |s, _| target_slot(s, 3)), spec!("channel.target.slot4", "Target Channel 4", [], Some("Cmd+4"), "{}", slot_ok::<4>, |s, _| target_slot(s, 4)), spec!("channel.target.slot5", "Target Channel 5", [], Some("Cmd+5"), "{}", slot_ok::<5>, |s, _| target_slot(s, 5)), @@ -1814,6 +1824,13 @@ pub fn specs() -> Vec { ] } +/// Target Layer Mask: the active layer has a mask. +fn has_layer_mask(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + let layer = st.active_layer.and_then(|id| st.doc.layer(id)).ok_or("no active layer")?; + layer.mask.as_ref().map(|_| ()).ok_or_else(|| "the active layer has no layer mask".to_string()) +} + fn slot_ok(s: &Session) -> std::result::Result<(), String> { let d = s.active().ok_or("no document open")?; let colors = color_count(&d.doc); diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 1223c0589b..17727dc8ad 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -741,6 +741,33 @@ fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() { assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode"); } +#[test] +fn command_backslash_targets_the_layer_mask_and_command_2_the_composite() { + let mut s = session(); + s.execute("layer.new.layer", json!({})).unwrap(); + assert!(!s.is_enabled("channel.target.layerMask"), "no mask yet"); + assert!(s.execute("channel.target.layerMask", json!({})).is_err()); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.target.composite", json!({})).unwrap(); + assert_eq!(crate::commands::find("channel.target.layerMask").and_then(|c| c.shortcut), Some("Cmd+\\")); + s.execute("channel.target.layerMask", json!({})).unwrap(); + assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::LayerMask); + s.execute("channel.target.composite", json!({})).unwrap(); + assert_eq!(s.active().unwrap().channel_view.target, ChannelTarget::Composite); +} + +#[test] +fn command_alt_2_loads_the_images_luminosity() { + let mut s = session(); + rect(&mut s, 0, 0, 20, 20, "replace"); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + assert_eq!(crate::commands::find("select.loadLuminosity").and_then(|c| c.shortcut), Some("Cmd+Alt+2")); + s.execute("select.loadLuminosity", json!({})).unwrap(); + assert!(sel_at(&s, 5, 5) < 0.01, "black: unselected"); + assert!(sel_at(&s, 30, 5) > 0.99, "white: selected"); +} + #[test] fn thumbnail_selection_shape_without_pixel_cache_uses_its_transparency() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index cebb9ec890..251acfb3f4 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -566,6 +566,35 @@ fn set_visible(s: &mut Session, p: &Value, visible: bool) -> Result { Ok(Value::Null) } +/// Layer › Hide Layers (⌘,): hide the targeted layers, or show them when every one is already +/// hidden, as Photoshop's item does (it reads Show Layers then), so ⌘, toggles. +fn hide_or_show(s: &mut Session, p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let targets = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] }; + let all_hidden = targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| !l.visible)); + set_visible(s, p, all_hidden) +} + +/// `/`: Lock Transparent Pixels on or off for the targeted layers, as the Layers panel's first lock +/// button does. With several layers targeted, any unlocked one means lock them all. The +/// Background, which can't take this lock, is left out. +fn toggle_lock_transparency(s: &mut Session, p: &Value) -> Result { + let st = s.active().ok_or(EngineError::NoDocument)?; + let targets: Vec = if crate::layer_multi_cmds::multi(s, p) { crate::layer_multi_cmds::selected(s) } else { vec![layer_param(s, p)?] }; + let targets: Vec = targets.into_iter().filter(|id| st.doc.layer(*id).is_some_and(|l| !st.doc.is_background(l.id))).collect(); + if targets.is_empty() { + return Err(EngineError::Other("the Background layer can't lock transparent pixels".into())); + } + let lock = !targets.iter().all(|id| st.doc.layer(*id).is_some_and(|l| l.locks.transparency)); + s.edit(if lock { "Lock Transparent Pixels" } else { "Unlock Transparent Pixels" }, |doc, _| { + for id in &targets { + doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.locks.transparency = lock; + } + Ok(()) + })?; + Ok(json!({"locked": lock})) +} + /// Is the layer at `path` shown alone: it and its enclosing groups visible, every layer outside /// it hidden (the layers inside a group keep their own visibility)? fn shown_alone(doc: &Document, path: &[usize]) -> bool { @@ -916,8 +945,25 @@ pub fn specs() -> Vec { }, ungroup ), - spec!("layer.hideLayers", "Hide Layers", &["Layer"], Some("Cmd+,"), r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, false)), + spec!( + "layer.hideLayers", + "Hide Layers", + &["Layer"], + Some("Cmd+,"), + r##"{"layer":id?} (shows them instead when every targeted layer is already hidden, so ⌘, toggles)"##, + has_layer, + hide_or_show + ), spec!("layer.showLayers", "Show Layers", &[], None, r##"{"layer":id?}"##, has_layer, |s, p| set_visible(s, p, true)), + spec!( + "layer.toggleLockTransparency", + "Lock Transparent Pixels", + &[], + Some("/"), + r##"{"layer":id?} (toggles; several layers selected: locks them all unless every one is locked)"##, + has_layer, + toggle_lock_transparency + ), spec!( "layer.showOnly", "Show Only This Layer", @@ -1496,6 +1542,48 @@ mod tests { assert_eq!(doc(&s).layers[0].name, "Background"); } + #[test] + fn hide_layers_toggles_once_every_target_is_hidden() { + let mut s = session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + let b = active(&s).id; + let visible = |s: &Session| doc(s).layer(b).unwrap().visible; + // Select › All Layers leaves out the Background, as in Photoshop. + let all = |s: &Session| crate::layer_multi_cmds::selected(s).iter().map(|id| doc(s).layer(*id).unwrap().visible).collect::>(); + s.execute("layer.hideLayers", json!({})).unwrap(); + assert!(!visible(&s)); + s.execute("layer.hideLayers", json!({})).unwrap(); + assert!(visible(&s), "⌘, again shows it"); + // Several selected: any one still shown means hide them all; then they all come back. + s.execute("layer.hideLayers", json!({"layer": b.0})).unwrap(); + s.execute("select.allLayers", json!({})).unwrap(); + assert!(all(&s).len() > 1); + s.execute("layer.hideLayers", json!({})).unwrap(); + assert!(all(&s).iter().all(|v| !v)); + s.execute("layer.hideLayers", json!({})).unwrap(); + assert!(all(&s).iter().all(|v| *v)); + } + + #[test] + fn slash_toggles_lock_transparent_pixels_but_not_on_the_background() { + let mut s = session(8); + let bg = doc(&s).layers[0].id; + assert!(doc(&s).is_background(bg)); + s.execute("layer.new.layer", json!({})).unwrap(); + let top = active(&s).id; + let locked = |s: &Session, id: LayerId| doc(s).layer(id).unwrap().locks.transparency; + assert_eq!(s.execute("layer.toggleLockTransparency", json!({})).unwrap()["locked"], true); + assert!(locked(&s, top)); + s.execute("layer.toggleLockTransparency", json!({})).unwrap(); + assert!(!locked(&s, top)); + s.undo(); + assert!(locked(&s, top), "one undoable step each"); + let bg_locks = doc(&s).layer(bg).unwrap().locks; + assert!(s.execute("layer.toggleLockTransparency", json!({"layer": bg.0})).is_err()); + assert_eq!(doc(&s).layer(bg).unwrap().locks, bg_locks, "the Background is left alone"); + assert!(s.execute("layer.toggleLockTransparency", json!({"layer": 999_999})).is_err()); + } + #[test] fn layer_utilities() { let mut s = session(8); diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 6235b629bb..2ae2fe4dfa 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -1,5 +1,6 @@ //! Photoshop's fill keys (#249): ⌥⌫ fills the selection (or the whole layer) with the foreground -//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. Photoshop +//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. ⌘⌥⌫ fills +//! from history: the History Brush's source state (the oldest state still held). Photoshop //! has no menu item for them; here they are ordinary commands with default shortcuts, so they //! appear in Edit › Keyboard Shortcuts (Tools) and can be rebound, recorded and driven by agents. //! @@ -12,7 +13,7 @@ use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; /// The fill-key command ids (routed to the Channels panel target like `edit.fill`). -pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"]; +pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve", "edit.fillHistory"]; const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##; @@ -39,6 +40,22 @@ fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) (edit_fill.run)(s, &Value::Object(q)) } +/// ⌘⌥⌫: `edit.fill` from history, the History Brush's source unless `state` names another. +fn fill_history(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.fillHistory"; + let mut q: Map = match p { + Value::Object(m) => m.clone(), + Value::Null => Map::new(), + _ => return Err(EngineError::BadParams { cmd: CMD.into(), msg: "expected an object".into() }), + }; + for k in ["color", "contents", "pattern"] { + q.remove(k); + } + q.insert("contents".into(), json!("history")); + let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| EngineError::Other("Fill is unavailable".into()))?; + (edit_fill.run)(s, &Value::Object(q)) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -81,6 +98,16 @@ pub fn specs() -> Vec { run: |s, p| fill(s, p, "edit.fillBackgroundPreserve", true, true), journal: true, }, + CommandSpec { + id: "edit.fillHistory", + label: "Fill from History", + menu: &[], + shortcut: Some("Cmd+Alt+Backspace"), + params: r##"{"state":n? (default: the History Brush's source, the oldest state still held),"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?,"preserveTransparency":bool?}"##, + enabled, + run: fill_history, + journal: true, + }, ] } @@ -133,6 +160,27 @@ mod tests { } } + #[test] + fn fill_from_history_restores_the_history_brush_source_in_the_selection() { + let mut s = session(); + assert_eq!(crate::commands::find("edit.fillHistory").and_then(|c| c.shortcut), Some("Cmd+Alt+Backspace")); + s.execute("edit.fillForeground", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 6})).unwrap(); + s.execute("edit.fillHistory", json!({})).unwrap(); + // The oldest state (the new, transparent layer) comes back inside the selection only. + assert_eq!(px(&s, 1, 1)[3], 0.0); + assert_eq!(px(&s, 6, 1), [1.0, 0.0, 0.0, 1.0]); + // A named state, and refused ones change nothing. + s.execute("select.deselect", json!({})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + for bad in [json!({"state": 99}), json!({"state": "x"}), json!(7)] { + assert!(s.execute("edit.fillHistory", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.active().unwrap().history.past_len(), steps); + s.execute("edit.fillHistory", json!({"state": 1})).unwrap(); + assert_eq!(px(&s, 1, 1), [1.0, 0.0, 0.0, 1.0], "state 1 is after the foreground fill"); + } + #[test] fn background_fills_only_the_selection() { let mut s = session(); diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index d265690597..fb229fcbc8 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -295,8 +295,20 @@ pub fn end_rename(ctx: &egui::Context, commit: bool) -> Option<(String, Value)> if commit { r.commit_action() } else { None } } +/// Where Tab or ⇧Tab sends a rename: the layer it left and whether the next one is above. +fn next_rename_id() -> egui::Id { + egui::Id::new("layer-rename-next") +} + +/// The rename that Tab (`up` false) or ⇧Tab (`up` true) just left on `layer`, for the Layers panel +/// to continue on the layer below or above, as Photoshop does. Taken once. +pub fn take_next_rename(ctx: &egui::Context) -> Option<(u64, bool)> { + ctx.data_mut(|d| d.remove_temp::<(u64, bool)>(next_rename_id())) +} + /// Draw the rename field of `layer` in `rect` when it is the one being renamed, and handle its -/// keys and focus. Returns the rename command when it was committed this frame. +/// keys and focus. Returns the rename command when it was committed this frame. Tab and ⇧Tab +/// commit too and ask for the next rename ([`take_next_rename`]). pub fn rename_field(ui: &mut egui::Ui, layer: u64, rect: Rect) -> Option<(String, Value)> { let ctx = ui.ctx().clone(); let mut r = rename(&ctx).filter(|r| r.layer == layer)?; @@ -316,6 +328,10 @@ pub fn rename_field(ui: &mut egui::Ui, layer: u64, rect: Rect) -> Option<(String return None; } let (enter, tab, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Tab), i.key_pressed(egui::Key::Escape))); + if tab { + let up = ctx.input(|i| i.modifiers.shift); + ctx.data_mut(|d| d.insert_temp(next_rename_id(), (layer, up))); + } if esc { store(&ctx, None); None diff --git a/crates/ui-egui/src/layer_row_ui/rename_tests.rs b/crates/ui-egui/src/layer_row_ui/rename_tests.rs index a4e985a293..4a7ea135ff 100644 --- a/crates/ui-egui/src/layer_row_ui/rename_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/rename_tests.rs @@ -1,6 +1,7 @@ //! #314: renaming a layer in place works like Photoshop. A click anywhere else, Enter or Tab //! commits; Esc cancels; only one rename is open at a time, and starting another (double-click, -//! the context menu or Layer › Rename Layer) commits the first. +//! the context menu or Layer › Rename Layer) commits the first. Tab then renames the layer below, +//! ⇧Tab the layer above. use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::Harness; @@ -127,7 +128,8 @@ fn enter_and_tab_commit_and_escape_cancels() { start(&mut h, b); type_text(&mut h, "By Tab"); key(&mut h, Key::Tab); - assert_eq!((name(&h, b), renaming(&h.ctx)), ("By Tab".into(), None)); + assert_eq!((name(&h, b), renaming(&h.ctx)), ("By Tab".into(), Some(a)), "Tab commits and renames the layer below"); + key(&mut h, Key::Escape); start(&mut h, c); type_text(&mut h, "Never"); @@ -143,6 +145,28 @@ fn enter_and_tab_commit_and_escape_cancels() { assert_eq!(name(&h, c), "Gamma"); } +#[test] +fn shift_tab_renames_the_layer_above_and_tab_at_the_bottom_ends() { + let (mut h, [a, b, c]) = harness(1.0); + start(&mut h, b); + type_text(&mut h, "Middle"); + h.event(Event::ModifiersChanged(Modifiers::SHIFT)); + h.event(Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }); + h.event(Event::Key { key: Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::SHIFT }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!((name(&h, b), renaming(&h.ctx)), ("Middle".into(), Some(c)), "⇧Tab renames the layer above"); + // Typing replaces the next layer's name too, as the whole name starts selected. + type_text(&mut h, "Top"); + key(&mut h, Key::Enter); + assert_eq!((name(&h, c), renaming(&h.ctx)), ("Top".into(), None)); + // Tab from the lowest layer ends the rename: the Background below isn't renamed in place. + start(&mut h, a); + key(&mut h, Key::Tab); + assert_eq!(renaming(&h.ctx), None); +} + #[test] fn starting_another_rename_commits_the_first_and_only_one_is_open() { for ppp in [1.0, 2.0] { diff --git a/crates/ui-egui/src/layer_shortcut_tests.rs b/crates/ui-egui/src/layer_shortcut_tests.rs new file mode 100644 index 0000000000..a363c340bf --- /dev/null +++ b/crates/ui-egui/src/layer_shortcut_tests.rs @@ -0,0 +1,124 @@ +//! Photoshop's layer and selection keys reach their commands: ⌘, toggles visibility, ⌘\ targets +//! the mask, / toggles Lock Transparent Pixels, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app +} + +fn press(app: &mut PhotocraftApp, key: Key, modifiers: Modifiers) { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + ..Default::default() + }; + ctx.begin_pass(raw); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); +} + +fn active(app: &PhotocraftApp) -> &photocraft_doc::Layer { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap() +} + +#[test] +fn command_comma_toggles_the_layers_visibility() { + let mut app = app(); + press(&mut app, Key::Comma, Modifiers::COMMAND); + assert!(!active(&app).visible); + press(&mut app, Key::Comma, Modifiers::COMMAND); + assert!(active(&app).visible); +} + +#[test] +fn slash_toggles_lock_transparent_pixels() { + let mut app = app(); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(active(&app).locks.transparency); + press(&mut app, Key::Slash, Modifiers::NONE); + assert!(!active(&app).locks.transparency); +} + +#[test] +fn command_backslash_targets_the_layer_mask() { + let mut app = app(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.run("channel.target.composite", json!({})).unwrap(); + press(&mut app, Key::Backslash, Modifiers::COMMAND); + assert_eq!(app.session.active().unwrap().channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::LayerMask); + assert!(app.ui.mask_target && app.layer_mask_targeted(), "the Layers panel targets the mask too"); + // Ctrl+2 goes back to the layer's pixels, in the Layers panel and for the tools. + press(&mut app, Key::Num2, Modifiers::COMMAND); + assert_eq!(app.session.active().unwrap().channel_view.target, photocraft_engine::channel_cmds::ChannelTarget::Composite); + assert!(!app.ui.mask_target && !app.layer_mask_targeted()); + // The same after targeting the mask by its thumbnail (the panel's own flag). + app.ui.mask_target = true; + app.sync_mask_targets(); + press(&mut app, Key::Num2, Modifiers::COMMAND); + assert!(!app.ui.mask_target && !app.layer_mask_targeted()); +} + +#[test] +fn command_alt_2_loads_luminosity() { + let mut app = app(); + press(&mut app, Key::Num2, Modifiers::COMMAND | Modifiers::ALT); + assert!(app.session.active().unwrap().doc.selection.is_some(), "the white image's luminosity selects it all"); +} + +#[test] +fn command_alt_backspace_fills_from_history() { + // The Background exists in the oldest state, so its pixels come back from there. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + app.run("edit.fillForeground", json!({})).unwrap(); + assert_eq!(active(&app).surface().unwrap().pixel(3, 3)[1], 0.0, "red"); + press(&mut app, Key::Backspace, Modifiers::COMMAND | Modifiers::ALT); + assert_eq!(active(&app).surface().unwrap().pixel(3, 3)[1], 1.0, "white again"); +} + +/// ⌥-click a layer's thumbnail: select it and fit its content on screen. +#[test] +fn alt_click_on_a_layer_thumbnail_fits_the_layer() { + use egui::{Event, PointerButton, pos2, vec2}; + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 400, "height": 300})).unwrap(); + let small = s.execute("layer.new.layer", json!({"name": "Small"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 300, "y": 200, "width": 20, "height": 10})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + let ctx = h.ctx.clone(); + let (r, _) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &r); + h.run_steps(8); + let before = h.state().ui.views[0].clone(); + let row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == small).unwrap().row; + let at = pos2(row.left() + 46.0, row.center().y); + h.hover_at(at); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::ALT }); + h.run_steps(1); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.session.active().unwrap().active_layer, Some(photocraft_doc::LayerId(small))); + let v = &app.ui.views[0]; + assert_eq!(v.center, [310.0, 205.0], "centred on the layer's content"); + assert!(v.zoom > before.zoom * 4.0, "zoomed in on 20 × 10 px: {} -> {}", before.zoom, v.zoom); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1115b94962..7705e35645 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -890,6 +890,16 @@ impl PhotocraftApp { if gate.is_some() { self.session.authorize = None; } + // An explicit channel target (⌘2, ⌘\, ⌘3…) decides what the Layers panel targets too: + // ⌘2 after the mask was targeted goes back to the layer's pixels. + if result.is_ok() && id.starts_with("channel.target") { + if let Some(t) = self.session.active().map(|st| st.channel_view.target) { + use photocraft_engine::channel_cmds::ChannelTarget; + self.ui.mask_target = t == ChannelTarget::LayerMask; + self.ui.vector_mask_target = t == ChannelTarget::VectorMask; + } + self.sync_mask_targets(); + } result } @@ -2266,6 +2276,8 @@ mod pen_variants_tests; #[cfg(test)] mod color_replacement_tests; #[cfg(test)] +mod layer_shortcut_tests; +#[cfg(test)] mod pencil_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 086d1c0604..d908af7afa 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2254,6 +2254,16 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let below = Rect::from_min_max(pos2(last_rect.left(), last_rect.bottom()), pos2(last_rect.right(), ui.clip_rect().bottom())); layer_drop_zone(app, &ctx, ui, below, (last, last_rect), "below", &mut actions); } + // Tab / ⇧Tab in a rename: rename the layer below / above next, as in Photoshop. The + // Background isn't renamed in place (double-clicking it converts it), so it ends there. + if let Some((from, up)) = crate::layer_row_ui::take_next_rename(ui.ctx()) + && let Some(at) = drawn.iter().position(|(id, _)| id.0 == from) + && let Some(&(next, _)) = if up { at.checked_sub(1).and_then(|i| drawn.get(i)) } else { drawn.get(at + 1) } + && let Some(l) = doc.layer(next).filter(|l| !crate::doc_props_ui::is_background(&doc, l)) + && let Some(done) = crate::layer_row_ui::start_rename(ui.ctx(), next.0, &l.name) + { + actions.push(done); + } // ⌥-click the line between two layers: clip / release the upper one (#967). crate::clip_line_ui::show(ui, &doc, &mut actions); // A rename whose row is gone (deleted, filtered out, inside a closed group) ends, @@ -2754,6 +2764,11 @@ fn layer_row( } else if on_thumb || !row.primary { actions.push(("ui.maskTarget".into(), json!(false))); } + // ⌥-click the layer thumbnail: fit the layer on screen, as Photoshop does. + let m = ui.input(|i| i.modifiers); + if on_thumb && m.alt && !m.shift && !m.command { + actions.push(("view.fitLayersOnScreen".into(), Value::Null)); + } } // Double-click: the name renames in place (over the row's full height, not just the glyphs: // #651); the Background, which can't be renamed while it's locked, becomes a normal layer; an diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d168db4dff..3daee822c7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -183,6 +183,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | `.kys` import | done (`engine/src/kys.rs`) | | | Shortcut glyphs per platform | done (UI-217-19) | | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | +| Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | ## Panels and workspace @@ -215,3 +216,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Flick panning done: releasing a moving Hand drag (tool, Space or middle button) glides and decays, gated by Enable Flick Panning | | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | +| 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | From edc1b1e77412decd51a5d61f295fe51677187b14 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:47:33 -0400 Subject: [PATCH 096/523] View: remember Extras, Show and Snap To across restarts (#3762) (#3787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * View: remember Extras, Show and Snap To across restarts (#3762) View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To targets reset to their defaults on every launch. They are now saved in Preferences (`viewExtras`) as they change and restored at startup, as Photoshop keeps them between sessions. Only items that differ from their defaults are stored, so a default changed in a later release still reaches users who never touched it. A hand-edited file can't break them: unknown keys and non-boolean values keep their defaults. Co-Authored-By: Claude Opus 5.5 * View: rustfmt the view-extras restart test (#3762) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/prefs.rs | 4 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/prefs_ui.rs | 34 +++++++++++ crates/ui-egui/src/view_cmds.rs | 100 ++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 93a52d2d10..c99c3cbb71 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -994,6 +994,10 @@ pub struct Preferences { /// differ from their defaults, saved as the user changes them and restored at launch, as /// Photoshop remembers each tool's options between sessions. JSON owned by the shell. pub tool_options: Value, + /// View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and the View › Snap To + /// targets that differ from their defaults, saved as the user toggles them and restored at + /// launch, as Photoshop keeps them between sessions (#3762). JSON owned by the shell. + pub view_extras: Value, /// File › Scripts › Script Events Manager: event → script bindings. pub script_events: crate::automate_cmds::ScriptEvents, } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7705e35645..323d559a8e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1725,6 +1725,8 @@ impl eframe::App for PhotocraftApp { brush_picker::persist(self, &ctx); // Tool options are remembered between launches the same way (#2814). tool_memory::persist(self, &ctx); + // So are View › Extras, Show and Snap To (#3762). + view_cmds::persist(self, &ctx); self.automation_input = false; shape_stroke_ui::finish_frame(self, &ctx); native_menu::sync(self, &ctx); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 5b15e7bc9c..202944b58a 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -218,6 +218,7 @@ pub fn load(app: &mut PhotocraftApp) { crate::brush_picker::restore(app); sync_rulers(app); crate::tool_memory::restore(app); + crate::view_cmds::restore(app); app.sync_recent(); app.prefs_rt.saved_rev = app.session.prefs.rev(); app.prefs_rt.saved_value = Some(app.session.prefs_value()); @@ -2649,6 +2650,39 @@ mod tests { } } + #[test] + fn view_show_items_survive_a_restart() { + let (mut app, store) = app_with_store(); + let ctx = egui::Context::default(); + tick(&mut app, &ctx); + // Untouched: nothing is stored, so later default changes still reach the user. + crate::view_cmds::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert!(stored(&store).get("viewExtras").is_none_or(Value::is_null)); + // The menu commands, as a user toggles them (#3762: Pixel Grid off). + for id in ["view.show.pixelGrid", "view.show.grid", "view.snapTo.layers", "view.extras"] { + crate::menus::invoke(&mut app, &ctx, id, json!({})).unwrap(); + } + crate::view_cmds::persist(&mut app, &ctx); + tick(&mut app, &ctx); + assert_eq!(stored(&store)["viewExtras"], json!({"extras": false, "grid": true, "show": {"pixelGrid": false}, "snapTo": {"layers": false}})); + // A restart gets them back. + let saved = store.lock().unwrap().clone().unwrap(); + let (mut app2, _) = app_with_saved(Some(saved)); + tick(&mut app2, &ctx); + let v = &app2.ui.view; + assert!(!v.extras && !v.show.pixel_grid && !v.snap_to.layers && app2.ui.extras.grid); + assert!(v.show.selection_edges && v.snap_to.guides && app2.ui.extras.guides, "the rest keep their defaults"); + // A hand-edited file: unknown keys and non-booleans keep the defaults, good ones still load. + for bad in [json!("junk"), json!({"extras": 0, "show": {"pixelGrid": "no", "mesh": true, "bogus": true}, "snapTo": [1]})] { + let (mut app3, _) = app_with_saved(Some(json!({"viewExtras": bad}).to_string())); + tick(&mut app3, &ctx); + let v = &app3.ui.view; + assert!(v.extras && v.show.pixel_grid && v.snap_to.layers, "{bad}"); + assert_eq!(v.show.mesh, bad.is_object()); + } + } + #[test] fn show_tooltips_preferences_turn_tooltips_off() { let (mut app, _) = app_with_store(); diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index f0404633cf..cb75e28032 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -214,6 +214,106 @@ fn snap_slot<'a>(s: &'a mut SnapTo, key: &str) -> Option<&'a mut bool> { }) } +/// Every [`show_slot`] key. +const SHOW_KEYS: [&str; 13] = [ + "layerEdges", + "selectionEdges", + "targetPath", + "pixelGrid", + "smartGuides", + "brushPreview", + "slices", + "notes", + "mesh", + "editPins", + "count", + "canvasGuides", + "artboardGuides", +]; +/// Every [`snap_slot`] key. +const SNAP_KEYS: [&str; 5] = ["guides", "grid", "layers", "slices", "documentBounds"]; + +/// The flags of `now` that differ from `def`, by key (`None` when none do). +fn changed_flags(now: T, def: T, keys: &[&str], slot: impl for<'a> Fn(&'a mut T, &str) -> Option<&'a mut bool>) -> Option { + let (mut now, mut def) = (now, def); + let diff: Map = keys + .iter() + .filter_map(|k| { + let v = *slot(&mut now, k)?; + (Some(v) != slot(&mut def, k).copied()).then(|| ((*k).to_string(), Value::Bool(v))) + }) + .collect(); + (!diff.is_empty()).then_some(Value::Object(diff)) +} + +/// View › Extras, Show and Snap To as remembered across restarts (`Preferences::view_extras`): +/// only what differs from the defaults, so a default changed in a later release still reaches +/// users who never touched that item. `Null` when nothing differs. +fn remembered(app: &PhotocraftApp) -> Value { + let (v, e, def) = (&app.ui.view, &app.ui.extras, crate::state::Extras::default()); + let mut m = Map::new(); + if v.extras != ViewOptions::default().extras { + m.insert("extras".into(), Value::Bool(v.extras)); + } + // View › Show › Grid and Guides live in `UiState::extras` beside the rulers and snapping. + if e.grid != def.grid { + m.insert("grid".into(), Value::Bool(e.grid)); + } + if e.guides != def.guides { + m.insert("guides".into(), Value::Bool(e.guides)); + } + if let Some(show) = changed_flags(v.show, Show::default(), &SHOW_KEYS, show_slot) { + m.insert("show".into(), show); + } + if let Some(snap) = changed_flags(v.snap_to, SnapTo::default(), &SNAP_KEYS, snap_slot) { + m.insert("snapTo".into(), snap); + } + if m.is_empty() { Value::Null } else { Value::Object(m) } +} + +/// Remember View › Extras, Show and Snap To in the preferences once the user lets go of the +/// pointer (#3762). Cheap: a small JSON compare per frame. Run every frame, so items set by the +/// control channel or the Show Extras Options dialog are remembered too. +pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) { + if ctx.input(|i| i.pointer.any_down()) { + return; + } + let now = remembered(app); + if app.session.prefs().view_extras != now { + app.session.prefs.edit(|p| p.view_extras = now); + } +} + +/// Restore the remembered View › Extras, Show and Snap To items at launch. A hand-edited file +/// can't break them: an unknown key or a value that isn't a boolean keeps its default. +pub fn restore(app: &mut PhotocraftApp) { + let saved = app.session.prefs().view_extras.clone(); + if !saved.is_null() && !saved.is_object() { + log::warn!("Preferences: the remembered View › Show items are not an object; using the defaults"); + return; + } + let flag = |k: &str| saved.get(k).and_then(Value::as_bool); + if let Some(on) = flag("extras") { + app.ui.view.extras = on; + } + if let Some(on) = flag("grid") { + app.ui.extras.grid = on; + } + if let Some(on) = flag("guides") { + app.ui.extras.guides = on; + } + for (k, v) in saved.get("show").and_then(Value::as_object).into_iter().flatten() { + if let (Some(slot), Some(on)) = (show_slot(&mut app.ui.view.show, k), v.as_bool()) { + *slot = on; + } + } + for (k, v) in saved.get("snapTo").and_then(Value::as_object).into_iter().flatten() { + if let (Some(slot), Some(on)) = (snap_slot(&mut app.ui.view.snap_to, k), v.as_bool()) { + *slot = on; + } + } +} + /// Window › : (panel, dock tab) for panels that live as tabs of a dock card. fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> { let pro = app.ui.theme.is_pro(); From 224cebbd8ab104f1c94f7008a80ee14eddee0219 Mon Sep 17 00:00:00 2001 From: buivanvu94 <125793064+buivanvu94@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:48:03 +0700 Subject: [PATCH 097/523] fix(automation): warn when text falls back from a missing font (#3391) (#3630) * fix(automation): warn when text falls back from a missing font (#3391) * cargo fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 2 +- crates/automation/src/headless.rs | 7 ++- .../automation/tests/missing_font_warnings.rs | 57 +++++++++++++++++++ crates/engine/src/type_cmds.rs | 36 +++++++++++- crates/engine/src/type_extra_cmds.rs | 27 +++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 crates/automation/tests/missing_font_warnings.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 1743665d90..560eaf71fc 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -240,7 +240,7 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let o = files::open(Path::new(input)).map_err(|e| e.to_string())?; warn_all(err, &o.warnings); - warn_all(err, &missing_font_warnings(photocraft_engine::type_extra_cmds::missing_fonts(&o.document))); + warn_all(err, &photocraft_engine::type_extra_cmds::missing_font_warnings(&o.document)); let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?; warn_all(err, &ws); Ok(()) diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index dc18afe2f2..cd92f584a2 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -96,13 +96,15 @@ impl Headless { st.source_read_only = o.source_read_only; } let d = &self.session.documents()[index]; + let mut warnings = o.warnings; + warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&d.doc)); Ok(json!({ "index": index, "name": d.doc.name, "width": d.doc.size.width, "height": d.doc.size.height, "layers": d.doc.layer_count(), - "warnings": o.warnings, + "warnings": warnings, })) } @@ -240,7 +242,7 @@ impl Headless { } }; let target_text = target.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?; - let warnings = match &self.filesystem { + let mut warnings = match &self.filesystem { Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())), Filesystem::TrustedLocal => { let writer = self.writers.entry(doc.id.0).or_default(); @@ -252,6 +254,7 @@ impl Headless { warnings } }; + warnings.extend(photocraft_engine::type_extra_cmds::missing_font_warnings(&doc)); // A layered document save (PSD, PSB, OpenRaster or .pcraft) is a full write, not a flattened copy: // record the current revision as saved so the session stops reporting `dirty`, and make // the file the document's path. A flat export (PNG, JPEG, …) is a copy and leaves both diff --git a/crates/automation/tests/missing_font_warnings.rs b/crates/automation/tests/missing_font_warnings.rs new file mode 100644 index 0000000000..fe66e5c062 --- /dev/null +++ b/crates/automation/tests/missing_font_warnings.rs @@ -0,0 +1,57 @@ +//! #3391: `doc.save`, `doc.open`, and type commands emit warnings when text uses a missing font. + +use photocraft_automation::Headless; +use serde_json::json; + +fn tmp_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "pc-font-warn-{}-{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn missing_font_warnings_emitted_in_headless_and_type_commands() { + let dir = tmp_dir(); + let psd_path = dir.join("card.psd"); + let png_path = dir.join("card.png"); + + let mut h = Headless::trusted_local(); + h.handle("doc.new", json!({"width": 600, "height": 300, "background": "white"})).unwrap(); + + // 1. type.create with missing font + let created = h + .handle("engine.execute", json!({"command": "type.create", "params": {"x": 30, "y": 100, "text": "Headline", "font": "NoSuchFamily123", "size": 40}})) + .unwrap(); + assert_eq!(created["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"])); + + // 2. doc.save card.psd + let saved_psd = h.handle("doc.save", json!({"path": psd_path.to_str().unwrap()})).unwrap(); + let warnings = saved_psd["warnings"].as_array().expect("warnings array in doc.save"); + assert!( + warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"), + "doc.save PSD warning: {saved_psd}" + ); + + // 3. doc.save card.png + let saved_png = h.handle("doc.save", json!({"path": png_path.to_str().unwrap()})).unwrap(); + let warnings = saved_png["warnings"].as_array().expect("warnings array in doc.save"); + assert!( + warnings.iter().any(|w| w == "font 'NoSuchFamily123' is not installed; text may render using a fallback face"), + "doc.save PNG warning: {saved_png}" + ); + + // 4. doc.close then doc.open + h.handle("doc.close", json!({})).unwrap(); + let opened = h.handle("doc.open", json!({"path": psd_path.to_str().unwrap()})).unwrap(); + let warnings = opened["warnings"].as_array().expect("warnings array in doc.open"); + assert!( + warnings.iter().any(|w| w.as_str().unwrap_or("").contains("is not installed; text may render using a fallback face")), + "doc.open warning: {opened}" + ); + + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index e59ce9258e..4a4cb347bf 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -639,7 +639,8 @@ fn info(s: &Session, p: &Value) -> Result { .map(|ln| json!({ "start": char_at(text, ln.range.start), "end": char_at(text, ln.range.end), "baseline": ln.baseline, "x0": ln.x0, "x1": ln.x1, "ascent": ln.ascent, "descent": ln.descent })) .collect(); let bounds = t.cache.as_ref().map(|c| c.content_bounds()).map(|r| json!([r.x0, r.y0, r.x1, r.y1])); - Ok(json!({ + let warnings = crate::type_extra_cmds::missing_font_warnings_for_layer(t); + let mut out = json!({ "layer": id.0, "text": text, "shape": t.shape, @@ -652,7 +653,11 @@ fn info(s: &Session, p: &Value) -> Result { "lines": lines, "bounds": bounds, "pxPerPt": layout.px_per_pt, - })) + }); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + Ok(out) } /// A type layer (not yet rendered or added) from `type.create`'s params: text, point `x`/`y` or @@ -723,7 +728,14 @@ pub fn specs() -> Vec { Ok(id) })?; let bounds = s.active().and_then(|d| d.doc.layer(id)).and_then(|l| l.surface()).map(|c| c.content_bounds()); - Ok(json!({ "layer": id.0, "bounds": bounds.map(|r| [r.x0, r.y0, r.x1, r.y1]) })) + let mut out = json!({ "layer": id.0, "bounds": bounds.map(|r| [r.x0, r.y0, r.x1, r.y1]) }); + if let Some(LayerContent::Text(t)) = s.active().and_then(|d| d.doc.layer(id)).map(|l| &l.content) { + let warnings = crate::type_extra_cmds::missing_font_warnings_for_layer(t); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + } + Ok(out) }, }, CommandSpec { @@ -1582,4 +1594,22 @@ mod tests { assert!(s.active().unwrap().doc.layer(LayerId(id)).is_none()); } } + + #[test] + fn missing_font_emits_warning_in_create_set_style_and_edit() { + let mut s = session(); + let r = s.execute("type.create", json!({"x": 10, "y": 20, "text": "Hello", "font": "NoSuchFamily123"})).unwrap(); + assert_eq!(r["warnings"], json!(["font 'NoSuchFamily123' is not installed; text may render using a fallback face"])); + let id = r["layer"].as_u64().unwrap(); + let styled = s.execute("type.setStyle", json!({"layer": id, "font": "StillNoSuchFamily456"})).unwrap(); + assert_eq!(styled["warnings"], json!(["font 'StillNoSuchFamily456' is not installed; text may render using a fallback face"])); + let edited = s.execute("type.edit", json!({"layer": id, "runs": [{"start": 0, "end": 2, "font": "AnotherMissingFont789"}]})).unwrap(); + assert_eq!( + edited["warnings"], + json!([ + "font 'AnotherMissingFont789' is not installed; text may render using a fallback face", + "font 'StillNoSuchFamily456' is not installed; text may render using a fallback face" + ]) + ); + } } diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index 022146f113..e460a975b6 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -436,6 +436,33 @@ pub fn missing_fonts(doc: &Document) -> Vec { out } +/// Font families used by a type layer that the font database cannot supply. +pub fn missing_fonts_in_layer(t: &TextLayer) -> Vec { + let have = installed_families(); + let mut out: Vec = Vec::new(); + for r in t.char_runs() { + let f = r.style.font_family; + if !f.is_empty() && !have.contains(&f.to_lowercase()) && !out.contains(&f) { + out.push(f); + } + } + out +} + +/// Warnings for missing font families in `doc`. +pub fn missing_font_warnings(doc: &Document) -> Vec { + fonts_to_warnings(missing_fonts(doc)) +} + +/// Warnings for missing font families in `layer`. +pub fn missing_font_warnings_for_layer(t: &TextLayer) -> Vec { + fonts_to_warnings(missing_fonts_in_layer(t)) +} + +fn fonts_to_warnings(fonts: Vec) -> Vec { + fonts.into_iter().map(|font| format!("font '{font}' is not installed; text may render using a fallback face")).collect() +} + /// Replace missing families per `map` (missing → installed; unmapped ones → the default family). fn replace_fonts(s: &mut Session, map: &serde_json::Map, all: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; From 56c5fd5797338cb88a93b9fc5d5c14619af1f077 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:14 +0200 Subject: [PATCH 098/523] fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744) Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of 2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12 cases already matched within 1 level. Applied to the CPU and GPU paths and to the effect reach. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/effects.rs | 32 ++++++++++++++++++++++++++++++-- crates/gpu/src/fx.rs | 2 +- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 915eb3ddd3..c594e5bd87 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -43,7 +43,7 @@ pub fn margin(layer: &Layer) -> i32 { Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size, Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size, Effect::Stroke(s) => s.size, - Effect::Satin(s) => s.distance + s.size, + Effect::Satin(s) => satin_distance(s) + s.size, Effect::BevelEmboss(b) => b.size + b.soften, _ => 0.0, }; @@ -1689,8 +1689,14 @@ impl FxMaps { } } +/// The distance the two satin copies are offset by. Photoshop draws Distance 0 as 1 px (an +/// edge band of 2/size, measured in Photoshop 27.11), not as two copies cancelling out. +pub fn satin_distance(s: &photocraft_doc::effects::Satin) -> f32 { + s.distance.max(1.0) +} + fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map { - let (dx, dy) = offset(s.angle, s.distance); + let (dx, dy) = offset(s.angle, satin_distance(s)); let mut a = shape.shifted(dx, dy, 0.0); let mut b = shape.shifted(-dx, -dy, 0.0); // Two box passes of the satin size (fitted: 0.2 % mean error on psd-tools layer_effects). @@ -3008,6 +3014,28 @@ mod tests { m } + #[test] + fn satin_at_distance_zero_matches_photoshop() { + // Photoshop 27.11, Satin black 100 %, angle 90°, Linear contour, distance 0, size 5 over a + // #808080 square: the rows from the top edge read 82, 102, 123, 128. + let s = photocraft_doc::effects::Satin { + common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0), + color: photocraft_color::Color::BLACK, + angle: 90.0, + distance: 0.0, + size: 5.0, + contour: Contour::Linear, + anti_alias: false, + invert: false, + }; + let m = satin_map(&square_shape(60), &s); + for (row, gray) in [(5, 82.0), (7, 102.0), (9, 123.0), (11, 128.0), (30, 128.0)] { + let want = 1.0 - gray / 128.0; + let got = m.v[row * 60 + 30]; + assert!((got - want).abs() < 0.02, "row {row}: {got} vs {want}"); + } + } + fn shadow(noise: f32, origin: (i32, i32)) -> Map { let s = Shadow { common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0), diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 13cea90af8..10f9272620 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -353,7 +353,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, } Effect::Satin(s) => { // satin_map - let (dx, dy) = offset(s.angle, s.distance); + let (dx, dy) = offset(s.angle, photocraft_compose::effects::satin_distance(s)); let a = b.shift(In::Shape, dx, dy, 0.0, false); let a = b.conv(a, photocraft_compose::effects::tent_kernel(s.size)); let c = b.shift(In::Shape, -dx, -dy, 0.0, false); From b071929ecb9dd8c7fe474e1489dac3081205897d Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 13:48:24 +0200 Subject: [PATCH 099/523] Type: editing text keeps its ligatures and OpenType alternates (#3617) (#3747) Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's glyphs instead of falling back to the default ones. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 60 ++++++++++++++++++++++++++++++++++------ crates/text/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 2c0ca38af5..b1359f31bb 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -140,9 +140,11 @@ pub fn parse_txt2(data: &[u8]) -> Option { ed::parse(&wrapped).ok().filter(|v| matches!(v, E::Dict(_))) } -/// Applies the automatic kerning modes that Photoshop keeps only in `Txt2` (EngineData writes -/// Optical and "0" alike as `AutoKerning true`) to `layer`, read from `tysh`. Only when the -/// layer's `TextIndex` entry still holds the layer's text (an edit elsewhere leaves `Txt2` +/// Applies what Photoshop keeps only in `Txt2` to `layer`, read from `tysh`: the automatic +/// kerning modes (EngineData writes Optical and "0" alike as `AutoKerning true`) and the +/// OpenType settings besides (discretionary) ligatures: stylistic sets, contextual alternates, +/// swashes and the rest ([`TXT2_FEATURES`]), which Photoshop leaves out of EngineData. Only when +/// the layer's `TextIndex` entry still holds the layer's text (an edit elsewhere leaves `Txt2` /// stale). /// /// Observed layout: `Txt2 /1` is the list of text objects; an object's `/0` is its text model @@ -168,11 +170,10 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let Some(style_runs) = model.path(&["6", "0"]).and_then(E::as_array) else { return; }; - let modes: Vec<(f64, Option)> = - style_runs.iter().map(|r| (r.get("1").and_then(E::as_f64).unwrap_or(0.0), r.path(&["0", "0", "6", "11"]).and_then(E::as_i64))).collect(); - let lens = utf16_to_byte_lengths(text, &modes.iter().map(|m| m.0).collect::>()); - // Auto-kern mode per byte segment, then split the model runs at segment boundaries. - let mut segs: Vec<(usize, Option)> = lens.into_iter().zip(modes.iter().map(|m| m.1)).collect(); + 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 lens = utf16_to_byte_lengths(text, &styles.iter().map(|m| m.0).collect::>()); + // 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(); segs.retain(|s| s.0 > 0); let runs = layer.char_runs(); let mut out: Vec = Vec::new(); @@ -187,14 +188,18 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { } let piece_end = if at < seg_end { end.min(seg_end) } else { end }; let mut style = r.style.clone(); + 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 segs.get(si).and_then(|s| s.1).filter(|_| at < seg_end) { + match txt2_style.and_then(|st| st.get("11")).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 { + add_txt2_features(&mut style, st); + } let len = piece_end - at; match out.last_mut() { Some(last) if last.style == style => last.len += len, @@ -207,6 +212,43 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { layer.sync_summary(); } +/// OpenType settings of a `Txt2` style that are on/off flags, by style key, as Photoshop +/// writes them (each turned on alone in the Character panel's OpenType settings and read back): +/// `/20` Contextual Alternates, `/23` Fractions, `/24` Ordinals, `/25` Swash, `/26` Titling +/// Alternates, `/28` Stylistic Alternates, `/29` Ornaments. (`/18` and `/19` are the standard and +/// discretionary ligatures, which EngineData carries too; `/27` is Arabic connection forms.) +pub const TXT2_FEATURES: [(&str, &str); 7] = [("20", "calt"), ("23", "frac"), ("24", "ordn"), ("25", "swsh"), ("26", "titl"), ("28", "salt"), ("29", "ornm")]; + +/// The `Txt2` style key holding the Stylistic Sets as a bit mask: bit n is set n + 1 +/// (`ss01`..`ss20`). +pub const TXT2_STYLISTIC_SETS: &str = "85"; + +/// Sets in `style` the OpenType features a `Txt2` style dictionary turns on ([`TXT2_FEATURES`] +/// and the Stylistic Sets mask). A feature the style already sets keeps its value. A flag that is +/// off changes nothing, except Contextual Alternates: shapers apply `calt` by default, Photoshop +/// only when it is on (its default is off), so off is written as `calt` 0. +fn add_txt2_features(style: &mut CharStyle, txt2_style: &E) { + let mut set = |tag: String, value: u16| { + if !style.features.iter().any(|f| f.tag == tag) { + style.features.push(photocraft_doc::text::FontFeature { tag, value }); + } + }; + for (key, tag) in TXT2_FEATURES { + match txt2_style.get(key).and_then(E::as_bool) { + Some(true) => set(tag.to_string(), 1), + Some(false) if tag == "calt" => set(tag.to_string(), 0), + _ => {} + } + } + // A mask beyond the 20 sets is not Photoshop's: ignore it. + let sets = txt2_style.get(TXT2_STYLISTIC_SETS).and_then(E::as_i64).filter(|m| (0..1 << 20).contains(m)).unwrap_or(0); + for n in 0..20 { + if sets & (1 << n) != 0 { + set(format!("ss{:02}", n + 1), 1); + } + } +} + /// Builds a [`TextLayer`] (model, text and transform; no cache, no `psd_raw`) from `TySh` data. pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { let t = parse_tysh(data)?; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 75b775b783..b1a1949cac 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -682,6 +682,11 @@ fn warp_bends_rendered_text_and_outlines() { /// `Txt2` as Photoshop writes it (a bare `/key value` sequence; text objects at `/1 /1`, style /// runs at `/0 /6 /0`, auto-kern mode at `/11`), for the layer text `text`. fn txt2_with_modes(text: &str, modes: &[(usize, i64)]) -> Vec { + txt2_with_styles(text, &modes.iter().map(|(len, m)| (*len, format!("/11 {m}"))).collect::>()) +} + +/// [`txt2_with_modes`] with each style run's keys given as EngineData text (`"/11 1 /85 2"`). +fn txt2_with_styles(text: &str, styles: &[(usize, String)]) -> Vec { let mut v = b"\n\n/98 << /0 14 >> /0 << >> /1 << /1 [ << /0 << /0 (\xfe\xff".to_vec(); for u in format!("{text}\r").encode_utf16() { let b = u.to_be_bytes(); @@ -693,8 +698,8 @@ fn txt2_with_modes(text: &str, modes: &[(usize, i64)]) -> Vec { } } v.extend_from_slice(b") /6 << /0 [ "); - for (len, m) in modes { - v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /11 {m} >> >> >> /1 {len} >> ").as_bytes()); + for (len, keys) in styles { + v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 {keys} >> >> >> /1 {len} >> ").as_bytes()); } v.extend_from_slice(b"] >> >> >> ] >>"); v @@ -741,6 +746,55 @@ fn txt2_carries_optical_kerning() { assert!(l.char_runs().iter().all(|r| r.style.kerning == Kerning::Optical)); } +/// #3617: Photoshop keeps Stylistic Sets, Contextual Alternates, Swash and the other OpenType +/// settings besides (discretionary) ligatures only in `Txt2`. Import reads them, so the layout +/// our engine draws when the type is edited keeps Photoshop's glyphs instead of falling back to +/// the default ones. +#[test] +fn txt2_carries_opentype_features() { + let feats = |l: &TextLayer| l.char_runs().iter().map(|r| r.style.features.iter().map(|f| (f.tag.clone(), f.value)).collect::>()).collect::>(); + let f = |tag: &str, value: u16| (tag.to_string(), value); + // "Il->36" plus the engine's closing break: Stylistic Set 2 (mask 2), Contextual Alternates + // and Swash on "Il"; Contextual Alternates off (Photoshop's default), Stylistic Set 1 and + // Fractions on the rest. + let text = "Il->36"; + let plain = styled(text, CharStyle { font_family: "Inter".into(), ..Default::default() }); + let tysh = with_text_index(&crate::psd::build_tysh(&plain, 72.0, None), 0); + let styles = [(2, "/11 1 /18 true /19 false /20 true /25 true /85 2".to_string()), (5, "/11 1 /20 false /23 true /85 1".to_string())]; + let txt2 = crate::psd::parse_txt2(&txt2_with_styles(text, &styles)).unwrap(); + let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + assert_eq!(feats(&back), vec![Vec::<(String, u16)>::new()], "EngineData alone doesn't have them"); + crate::psd::apply_txt2(&mut back, &tysh, &txt2); + assert_eq!(feats(&back), vec![vec![f("calt", 1), f("swsh", 1), f("ss02", 1)], vec![f("calt", 0), f("frac", 1), f("ss01", 1)]]); + assert_eq!(back.char_runs().iter().map(|r| r.len).collect::>(), vec![2, 4]); + // What editing shows (our layout) has Photoshop's glyphs: Inter's `ss02` draws another I and + // l, `ss01` another 3 and 6, and `->` stays two glyphs without `calt` (with it, an arrow). + let mut e = TextEngine::new(); + let ids = |e: &mut TextEngine, t: &TextLayer| e.layout(t, 72.0).glyphs.iter().map(|g| g.id).collect::>(); + let with = |len: usize, tags: &[(&str, u16)]| TextRun { + len, + style: CharStyle { + features: tags.iter().map(|(tag, value)| FontFeature { tag: (*tag).into(), value: *value }).collect(), + ..back.char_runs()[0].style.clone() + }, + }; + let mut expected = back.clone(); + expected.runs = vec![with(2, &[("ss02", 1)]), with(4, &[("calt", 0), ("ss01", 1)])]; + let shown = ids(&mut e, &back); + assert_eq!(shown, ids(&mut e, &expected)); + let defaults = ids(&mut e, &crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap()); + assert_eq!((shown.len(), defaults.len()), (6, 5), "`->` is two glyphs, as in Photoshop, not an arrow"); + assert!(shown.first() != defaults.first() && shown.last() != defaults.last(), "{shown:?} vs {defaults:?}"); + // A feature the style already sets (from EngineData, read first) keeps its value; masks and + // flags of the wrong type, masks beyond the 20 sets and unknown keys change nothing. + let mut t = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + t.runs = vec![TextRun { len: text.len(), style: CharStyle { features: vec![FontFeature { tag: "ss01".into(), value: 0 }], ..Default::default() } }]; + let odd = + [(1, "/85 1".to_string()), (1, "/85 (x) /20 (x) /25 (true)".to_string()), (1, "/85 -1".to_string()), (4, "/85 99999999999 /999 true".to_string())]; + crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&txt2_with_styles(text, &odd)).unwrap()); + assert_eq!(feats(&t), vec![vec![f("ss01", 0)]]); +} + /// A `Txt2` written by [`crate::psd::build_txt2`] (what PSD export writes for the type layers) /// restores every auto-kern mode on import (#1348: a new Optical layer used to reopen as /// Metrics, because EngineData's `AutoKerning true` covers both). From 81bff2aaa4267a2da9a4b6c85cdea451339628da Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 13:48:35 +0200 Subject: [PATCH 100/523] Brushes: Photoshop's texture Height/Subtract modes, Brightness and Contrast (#3340) (#3749) Imported brushes whose Texture uses Height or Linear Height at a low depth (common in Photoshop brush packs) painted nothing: the modes were modelled backwards (paint only on the brightest texels, more of it with more depth). Measured against Photoshop with single dabs over a 0..255 ramp pattern: - Height: paint reaches 12 x depth x coverage into the pattern and fills what lies below it (dark valleys first; a brighter pattern takes paint away, which is why the reporter had to flip Brightness's sign). Linear Height grades the shallow end by the pattern instead. - Subtract takes depth x pattern away (white removes the most paint); Color Dodge/Burn scale the pattern toward their neutral with depth. These now live in texture_combine; the Dual Brush keeps mask_combine. - Brightness shifts the pattern by value/255 (not value/150); positive Contrast steepens by 1/(1-c/100) after the shift, negative flattens by 1+c/100 before it. Texture::level is shared by the renderer and the Brush Settings swatch. - ABR import mapped textureContrast with /50 for both signs; positive values are now /100, matching the model and the Brush Settings field. Built-in Subtract presets get Invert so they look as before. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/io/src/abr_map.rs | 4 +- crates/io/src/abr_map/tests.rs | 49 ++++++++++++- crates/paint/src/brush.rs | 17 ++++- crates/paint/src/brush_tests.rs | 104 ++++++++++++++++++++++++++- crates/paint/src/presets.rs | 6 +- crates/paint/src/render.rs | 43 +++++++++-- crates/ui-egui/src/brush_sections.rs | 7 +- 7 files changed, 212 insertions(+), 18 deletions(-) diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 225869ea79..6230e27649 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -389,7 +389,9 @@ impl Mapper<'_> { t.invert = boolean(d, "InvT").unwrap_or(false); t.scale = pct(d, "textureScale").unwrap_or(1.0).clamp(0.01, 10.0); t.brightness = (num(d, "textureBrightness").unwrap_or(0.0) / 150.0).clamp(-1.0, 1.0) as f32; - t.contrast = (num(d, "textureContrast").unwrap_or(0.0) / 50.0).clamp(-1.0, 2.0) as f32; + // Photoshop's -50..100 onto the model's -1..1 (negative and positive scale apart). + let c = num(d, "textureContrast").unwrap_or(0.0); + t.contrast = (if c < 0.0 { c / 50.0 } else { c / 100.0 }).clamp(-1.0, 1.0) as f32; t.mode = mask_mode(enum_value(d, "textureBlendMode")).unwrap_or(MaskMode::Multiply); t.depth = pct(d, "textureDepth").unwrap_or(1.0).clamp(0.0, 1.0); let mut dj = self.dynamic(obj(d, "textureDepthDynamics")); diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 9837f4ea4f..013f307f81 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -153,7 +153,7 @@ fn v6_settings_map_onto_brush_sections() { assert_eq!((sc.count, sc.scatter.jitter, sc.scatter.control, sc.scatter.fade_steps, sc.count_jitter.jitter), (3, 2.5, Control::Fade, 40, 0.5)); let tx = &b.texture; assert!(tx.enabled && tx.each_tip && tx.invert); - assert_eq!((tx.scale, tx.brightness, tx.contrast, tx.mode, tx.depth), (1.5, -0.5, 0.5, MaskMode::ColorBurn, 0.6)); + assert_eq!((tx.scale, tx.brightness, tx.contrast, tx.mode, tx.depth), (1.5, -0.5, 0.25, MaskMode::ColorBurn, 0.6)); assert_eq!((tx.depth_jitter.jitter, tx.depth_jitter.minimum), (0.3, 0.2)); let Pattern::Tile(pt) = &tx.pattern else { panic!("embedded pattern expected, got {:?}", tx.pattern) }; assert_eq!((pt.width, pt.height, pt.data[3]), (4, 2, 65535)); @@ -330,3 +330,50 @@ fn sampled_tip_paints_the_brush_colour_never_black() { assert!(painted > 0, "the imported tip painted nothing"); assert!(painted < 64 * 64, "the tip covered everything - coverage was treated as solid colour"); } + +/// #3340: Photoshop's texture Contrast runs -50..100; the model maps -50..0 and 0..100 onto +/// -1..0 and 0..1 (as Brush Settings shows it), so +24 must not read back as +48. +#[test] +fn texture_contrast_maps_both_halves() { + for (c, want) in [(24, 0.24), (100, 1.0), (-30, -0.6), (-50, -1.0), (0, 0.0)] { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("C")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 20.0)))) + .with("useTexture", Value::Boolean(true)) + .with("textureContrast", Value::Integer(c)); + let bytes = write_v6(2, &[], &[], &[preset], false).unwrap(); + let imp = read_abr(&bytes, "Test").unwrap(); + let got = imp.presets[0].brush.texture.contrast; + assert!((got - want).abs() < 1e-6, "contrast {c}: {got}"); + } +} + +/// #3340: a Height-mode brush at a low depth with a darkened pattern (how Photoshop's own +/// "Pen" presets in the reported file are set up) imported but painted nothing. +#[test] +fn low_depth_height_texture_brush_paints() { + use photocraft_color::PixelFormat; + use photocraft_paint::StrokePoint; + use photocraft_paint::render::render_stroke; + use photocraft_raster::Surface; + + for mode in ["Hght", "linearHeight"] { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Pen")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 20.0)).with("Hrdn", prc(100.0)).with("Spcn", prc(5.0)))) + .with("useTexture", Value::Boolean(true)) + .with("textureBlendMode", en("BlnM", mode)) + .with("textureDepth", prc(13.0)) + .with("textureBrightness", Value::Integer(-87)) + .with("textureContrast", Value::Integer(5)) + .with("Txtr", Value::Descriptor(Descriptor::new("Ptrn").with("Nm ", t("Grit")).with("Idnt", t("pat-uuid")))); + let bytes = write_v6(2, &[], &[grit_pattern()], &[preset], false).unwrap(); + let imp = read_abr(&bytes, "Test").unwrap(); + let b = imp.presets[0].brush.clone(); + let mut s = Surface::new(PixelFormat::RGBA32F); + render_stroke(&mut s, &b, &[StrokePoint::new(10.0, 20.0, 1.0), StrokePoint::new(50.0, 20.0, 1.0)], None, false, 1.0); + let covered = (12..48).filter(|&x| s.rgba(x, 20)[3] > 0.5).count(); + // 13 % depth reaches 1.55 pattern ranges: above the whole darkened Grit pattern. + assert_eq!(covered, 36, "{mode}: the stroke painted {covered} of 36 pixels"); + } +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 25ebea5ef2..48e7f48d31 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -270,7 +270,7 @@ pub struct Texture { pub scale: f32, /// -1..1 (Photoshop's -150..150 mapped to ±1). pub brightness: f32, - /// -1..1. + /// -1..1: Photoshop's -50..0 mapped to -1..0 and its 0..100 to 0..1. pub contrast: f32, /// Apply the texture to every dab (enables depth jitter) instead of once to the stroke. pub each_tip: bool, @@ -281,6 +281,21 @@ pub struct Texture { pub depth_jitter: Dynamic, } +impl Texture { + /// A pattern value (0..1) after Brightness, Contrast and Invert: the value the mode combines + /// with the brush. Measured against Photoshop (#3340): Brightness shifts the pattern by + /// `brightness / 255` (±150 moves it 150 levels of 255, not all the way to white or black); + /// positive Contrast steepens it around mid-grey by `1 / (1 − c/100)` after the shift (100 is + /// a threshold), negative Contrast flattens it by `1 + c/100` before the shift; Invert is last. + pub fn level(&self, v: f32) -> f32 { + let b = if self.brightness.is_finite() { self.brightness.clamp(-1.0, 1.0) * (150.0 / 255.0) } else { 0.0 }; + let c = if self.contrast.is_finite() { self.contrast.clamp(-1.0, 1.0) } else { 0.0 }; + let x = if c >= 0.0 { (v + b - 0.5) / (1.0 - c).max(1e-4) + 0.5 } else { (v - 0.5) * (1.0 + c / 2.0) + 0.5 + b }; + let x = if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.5 }; + if self.invert { 1.0 - x } else { x } + } +} + impl Default for Texture { fn default() -> Self { Self { diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index a68f84f1f7..664aca9c06 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -6,7 +6,7 @@ use photocraft_raster::Surface; use crate::brush::*; use crate::dynamics::{Smoother, smooth_points}; -use crate::render::{BrushContext, StrokeRenderer, mask_combine, render_stroke}; +use crate::render::{BrushContext, StrokeRenderer, mask_combine, render_stroke, texture_combine}; use crate::tile::GrayTile; use crate::{Dab, Stroke, StrokePoint, dabs}; @@ -525,7 +525,9 @@ fn texture_modulates_coverage() { b.texture.invert = false; b.texture.brightness = 1.0; let s = paint(&b, &line(20.0, 80.0, 30.0), 100, 60); - assert!(s.rgba(46, 30)[3] > 0.9); + // +150 lifts a black texel by 150 levels of 255, not to white (#3340). + let lifted = s.rgba(46, 30)[3]; + assert!((lifted - 150.0 / 255.0).abs() < 0.02, "{lifted}"); // All modes keep zero coverage at zero and stay in range. for m in [ MaskMode::Multiply, @@ -544,6 +546,12 @@ fn texture_modulates_coverage() { let v = mask_combine(m, 0.7, t, 0.8); assert!((0.0..=1.0).contains(&v), "{m:?}"); } + for t in [0.0, 0.3, 1.0] { + assert_eq!(texture_combine(m, 0.0, t, 1.0), 0.0, "{m:?}"); + let v = texture_combine(m, 0.7, t, 0.8); + assert!((0.0..=1.0).contains(&v), "{m:?}"); + } + assert!((0.0..=1.0).contains(&texture_combine(m, 0.7, f32::NAN, f32::INFINITY)), "{m:?}"); // Full-white texture never reduces multiply-like masks below the brush. assert!(mask_combine(m, 0.6, 1.0, 1.0) >= 0.6 - 1e-5 || matches!(m, MaskMode::Overlay), "{m:?}"); } @@ -555,6 +563,98 @@ fn texture_modulates_coverage() { assert!(d.iter().any(|x| x.depth < 0.6)); } +/// Texture Brightness and Contrast as Photoshop applies them, measured by stroking a 0..255 grey +/// ramp pattern in Multiply mode at 100 % depth (the stroke's opacity is then the pattern value): +/// (setting, ramp value, opacity read back), in levels of 255 (#3340). +#[test] +fn texture_brightness_and_contrast_match_photoshop() { + let level = |brightness: f32, contrast: f32, invert: bool, v: f32| { + // The model's ranges: brightness ±150 → ±1; contrast -50..0 → -1..0, 0..100 → 0..1. + let c = if contrast < 0.0 { contrast / 50.0 } else { contrast / 100.0 }; + let t = Texture { brightness: brightness / 150.0, contrast: c, invert, ..Default::default() }; + t.level(v / 255.0) * 255.0 + }; + for (b, c, inv, v, want) in [ + (87.0, 0.0, false, 8.0, 95.0), + (87.0, 0.0, false, 200.0, 255.0), + (-87.0, 0.0, false, 104.0, 17.0), + (150.0, 0.0, false, 8.0, 158.0), + (0.0, 50.0, false, 104.0, 81.0), + (0.0, 50.0, false, 136.0, 145.0), + (0.0, -50.0, false, 8.0, 67.0), + (0.0, -50.0, false, 248.0, 188.0), + (0.0, 100.0, false, 120.0, 0.0), + (0.0, 100.0, false, 136.0, 255.0), + // Positive contrast after the shift, negative contrast before it. + (40.0, 50.0, false, 104.0, 161.0), + (40.0, 50.0, false, 40.0, 33.0), + (-40.0, -30.0, false, 120.0, 82.0), + (-40.0, -30.0, false, 248.0, 172.0), + // Invert comes last. + (40.0, 0.0, true, 8.0, 207.0), + (0.0, 50.0, true, 72.0, 238.0), + ] { + let got = level(b, c, inv, v); + assert!((got - want).abs() <= 2.0, "brightness {b}, contrast {c}, invert {inv}, ramp {v}: {got} (Photoshop {want})"); + } +} + +/// The Texture modes that differ from the Dual Brush's, against single Photoshop dabs over a +/// 0..255 ramp: (mode, depth, brush coverage, ramp value, opacity read back), in levels of 255. +#[test] +fn texture_modes_match_photoshop() { + use MaskMode::*; + for (m, d, v, t, want) in [ + // Height: a low depth paints the dark valleys only, more depth fills the pattern. + (Height, 0.0, 255.0, 8.0, 0.0), + (Height, 0.01, 255.0, 8.0, 28.0), + (Height, 0.05, 255.0, 8.0, 148.0), + (Height, 0.05, 255.0, 136.0, 20.0), + (Height, 0.13, 255.0, 8.0, 255.0), + (Height, 0.13, 255.0, 152.0, 244.0), + (Height, 0.13, 255.0, 248.0, 148.0), + (Height, 0.13, 186.0, 88.0, 201.0), + (Height, 0.13, 142.0, 104.0, 116.0), + (Height, 0.25, 255.0, 248.0, 255.0), + // Linear Height: graded by the pattern while the reach stays below its top. + (LinearHeight, 0.01, 255.0, 152.0, 15.0), + (LinearHeight, 0.05, 255.0, 8.0, 151.0), + (LinearHeight, 0.05, 255.0, 152.0, 63.0), + (LinearHeight, 0.10, 255.0, 72.0, 240.0), + (LinearHeight, 0.13, 101.0, 120.0, 82.0), + (LinearHeight, 0.13, 142.0, 104.0, 130.0), + // Subtract: the light parts take paint away. + (Subtract, 1.0, 255.0, 8.0, 247.0), + (Subtract, 1.0, 255.0, 248.0, 7.0), + (Subtract, 0.5, 224.0, 72.0, 188.0), + // Color Burn / Color Dodge below full depth. + (ColorBurn, 0.5, 224.0, 72.0, 207.0), + (ColorDodge, 0.5, 99.0, 120.0, 130.0), + // Unchanged modes. + (Multiply, 0.13, 255.0, 8.0, 223.0), + (Darken, 0.5, 255.0, 8.0, 131.0), + ] { + let got = texture_combine(m, v / 255.0, t / 255.0, d) * 255.0; + assert!((got - want).abs() <= 3.0, "{m:?} depth {d}, coverage {v}, ramp {t}: {got} (Photoshop {want})"); + } +} + +/// #3340: an imported Height-mode brush at a low depth with a darkened pattern painted nothing. +#[test] +fn low_depth_height_texture_paints() { + let tile = GrayTile::from_fn(8, 8, |x, y| if (x + y) % 2 == 0 { 0.45 } else { 0.6 }); + for mode in [MaskMode::Height, MaskMode::LinearHeight] { + let b = BrushSettings { + size: 40.0, + texture: Texture { enabled: true, pattern: Pattern::Tile(tile.clone()), mode, depth: 0.13, brightness: -87.0 / 150.0, ..Default::default() }, + ..brush() + }; + let s = paint(&b, &line(20.0, 80.0, 30.0), 100, 60); + let a = s.rgba(50, 30)[3]; + assert!(a > 0.95, "{mode:?}: {a}"); + } +} + #[test] fn dual_brush_intersects() { // Dual tip: small dabs far apart → the stroke survives only where they land. diff --git a/crates/paint/src/presets.rs b/crates/paint/src/presets.rs index 621565188d..cbba537e1c 100644 --- a/crates/paint/src/presets.rs +++ b/crates/paint/src/presets.rs @@ -107,7 +107,9 @@ fn dry_media(base: &BrushSettings) -> Vec { spacing: 0.12, shape_dynamics: ShapeDynamics { enabled: true, size: pressure_min(0.5), angle: Dynamic::controlled(Control::Direction), ..Default::default() }, transfer: transfer(pressure_min(0.3), Dynamic::default()), - texture: Texture { mode: MaskMode::Subtract, ..paper(5, 0.7) }, + // Subtract takes paint away where the pattern is light (#3340); inverting keeps + // these presets as they were designed, with the dark parts taking it away. + texture: Texture { mode: MaskMode::Subtract, invert: true, ..paper(5, 0.7) }, ..base.clone() }, ), @@ -129,6 +131,7 @@ fn dry_media(base: &BrushSettings) -> Vec { pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 2 }, depth: 0.55, mode: MaskMode::Subtract, + invert: true, ..Default::default() }, ..base.clone() @@ -379,6 +382,7 @@ fn special_effects(base: &BrushSettings) -> Vec { enabled: true, pattern: Pattern::Procedural { style: PatternStyle::Canvas, size: 64, seed: 1 }, mode: MaskMode::Subtract, + invert: true, depth: 0.7, ..Default::default() }, diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index a92b75fbda..94ac63a793 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -108,6 +108,40 @@ pub fn mask_combine(mode: MaskMode, v: f32, t: f32, d: f32) -> f32 { r.clamp(0.0, 1.0) } +/// How far a Height-mode dab at full depth and coverage reaches into the pattern, in pattern +/// ranges (measured against Photoshop, #3340). +const HEIGHT_REACH: f32 = 12.0; + +/// Combine brush coverage `v` with a Texture pattern value `t` at `depth` (Brush Settings › +/// Texture). Where it differs from [`mask_combine`] (still used for the Dual Brush) it follows +/// Photoshop as measured with single dabs over a grey ramp (#3340): +/// - Subtract takes `depth × t` away, so a white pattern removes the most paint. +/// - Color Dodge and Color Burn move the pattern toward their neutral colour as depth falls. +/// - Height treats the pattern as a height field the brush presses into: the paint reaches +/// `12 × depth × v` and fills whatever lies below that, so a low depth paints only the dark +/// valleys, from about 17 % the whole pattern is under the paint, and a brighter pattern +/// takes paint away. +/// - Linear Height is Height, but where the reach stays below the top of the pattern the paint +/// fades with the pattern instead of being cut off. +pub fn texture_combine(mode: MaskMode, v: f32, t: f32, d: f32) -> f32 { + if v <= 0.0 { + return 0.0; + } + let r = match mode { + MaskMode::Subtract => v - d * t, + MaskMode::ColorDodge => v / (1.0 - d * t).max(1e-3), + MaskMode::ColorBurn => 1.0 - (1.0 - v) / (1.0 - d + d * t).max(1e-3), + // Photoshop steps the depth in 1/255ths here, which shows at the lowest depths. + MaskMode::Height => HEIGHT_REACH * (d * 255.0).round() / 255.0 * v - t, + MaskMode::LinearHeight => { + let reach = HEIGHT_REACH * (d * 255.0).round() / 255.0 * v; + if reach < 1.0 { reach * (1.0 - t) } else { reach - t } + } + _ => mask_combine(mode, v, t, d), + }; + if r.is_finite() { r.clamp(0.0, 1.0) } else { 0.0 } +} + /// A stroke-ready brush: the settings plus prepared tip mips and texture. #[derive(Clone, Debug)] pub struct BrushContext { @@ -129,11 +163,8 @@ fn prepare_pattern(b: &BrushSettings) -> Option> { _ => return None, }, }; - let (br, ct) = (t.brightness.clamp(-1.0, 1.0), t.contrast.clamp(-1.0, 1.0)); for v in &mut img.data { - let mut x = (*v - 0.5) * (1.0 + ct) + 0.5 + br; - x = x.clamp(0.0, 1.0); - *v = if t.invert { 1.0 - x } else { x }; + *v = t.level(*v); } Some(Arc::new(img)) } @@ -326,7 +357,7 @@ impl BrushContext { val += (hard - val) * 0.7; } if tex_tip { - val = mask_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); + val = texture_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); } if wet { val *= wet_factor(rn); @@ -366,7 +397,7 @@ impl BrushContext { m = mask_combine(b.dual_brush.mode, m, dv, 1.0); } if self.texture.is_some() && !b.texture.each_tip { - m = mask_combine(b.texture.mode, m, self.texture_at(x, y), b.texture.depth.clamp(0.0, 1.0)); + m = texture_combine(b.texture.mode, m, self.texture_at(x, y), b.texture.depth.clamp(0.0, 1.0)); } m } diff --git a/crates/ui-egui/src/brush_sections.rs b/crates/ui-egui/src/brush_sections.rs index 7fae63ffd0..16332e4d08 100644 --- a/crates/ui-egui/src/brush_sections.rs +++ b/crates/ui-egui/src/brush_sections.rs @@ -395,14 +395,9 @@ fn pattern_swatch(ui: &mut egui::Ui, tx: &paint::Texture) { _ => paint::tile::PatternImage { width: 1, height: 1, data: vec![0.5] }, }, }; - let (br, ct) = (tx.brightness.clamp(-1.0, 1.0), tx.contrast.clamp(-1.0, 1.0)); let px: Vec = (0..n * n) .flat_map(|i| { - let v = img.sample_wrap((i % n) as f32, (i / n) as f32); - let mut x = ((v - 0.5) * (1.0 + ct) + 0.5 + br).clamp(0.0, 1.0); - if tx.invert { - x = 1.0 - x; - } + let x = tx.level(img.sample_wrap((i % n) as f32, (i / n) as f32)); let g = (x * 255.0).round() as u8; [g, g, g, 255] }) From e4e3f00f484a0aa345e551f59bf268e1983f1087 Mon Sep 17 00:00:00 2001 From: Dennis <34663561+Madcat71@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:45 +0200 Subject: [PATCH 101/523] Camera raw colour fit: read a Sony Adobe RGB preview as Adobe RGB, not sRGB (#3760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated, hue-shifted reference, and the developed raw came out that way. `photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same reason (storytold/lightcraft#778). Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4, their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2. Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values, a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new function. Co-authored-by: Claude Opus 5.5 --- crates/io/src/raw.rs | 56 ++++++++++++++++++++++++------ crates/raw/src/lib.rs | 2 +- crates/raw/src/preview.rs | 69 +++++++++++++++++++++++++++++++++++++ crates/raw/src/tiff.rs | 4 +++ crates/raw/tests/hostile.rs | 1 + 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 6ff2f59042..7e1f916481 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; -use photocraft_raw::{CameraLook, DevelopOptions, Limits, RawError, Reference, Sensor, WhiteBalance}; +use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; @@ -133,10 +133,23 @@ pub fn import_raw(name: &str, bytes: &[u8]) -> Result { /// the fit grid is at most `PROXY_EDGE` cells and wants about two pixels per cell). const FIT_PREVIEW_EDGE: u32 = 2 * photocraft_raw::PROXY_EDGE as u32; +/// The colour space of a raw's decoded preview when it is not sRGB: its own ICC profile, else the +/// one the raw's EXIF states (a Sony body set to Adobe RGB writes no profile into the JPEG). +/// `None` means sRGB. +fn preview_profile(raw: &[u8], icc: Option<&[u8]>) -> Result>, String> { + let src = match icc { + Some(icc) => Cow::Owned(Profile::parse(icc).map_err(|e| format!("the preview's ICC profile does not parse: {e}"))?), + None if photocraft_raw::embedded_preview_color_space(raw) == Some(PreviewColorSpace::AdobeRgb) => Cow::Borrowed(Builtin::AdobeRgbCompat.profile()), + None => return Ok(None), + }; + Ok((!src.same_colors(Builtin::Srgb.profile())).then_some(src)) +} + /// Fits the colour of a decoded raw to the camera's embedded JPEG (see /// [`photocraft_raw::fit_look`]): picks the smallest preview at least /// `FIT_PREVIEW_EDGE` pixels long (else the largest), decodes it, turns it upright and -/// converts it to sRGB when it carries another ICC profile. `Err` says why there is no fit. +/// converts it to sRGB when it is in another colour space ([`preview_profile`]). `Err` says +/// why there is no fit. pub fn fit_preview_look(raw: &[u8], sensor: &Sensor, opts: &DevelopOptions) -> Result { let previews = photocraft_raw::embedded_previews(raw); let long = |p: &photocraft_raw::Preview| p.width.max(p.height); @@ -152,15 +165,11 @@ pub fn fit_preview_look(raw: &[u8], sensor: &Sensor, opts: &DevelopOptions) -> R } let (w, h) = img.dimensions(); let mut rgb: Cow<[u8]> = Cow::Borrowed(img.data()); - if let Some(icc) = img.icc.as_deref() { - let srgb = Builtin::Srgb.profile(); - let src = Profile::parse(icc).map_err(|e| format!("the preview's ICC profile does not parse: {e}"))?; - if !src.same_colors(srgb) { - let t = Transform::new(&src, srgb, Intent::RelativeColorimetric, false).map_err(|e| e.to_string())?; - let mut out = vec![0u8; rgb.len()]; - t.convert_u8(&rgb, 3, &mut out, 3, false); - rgb = Cow::Owned(out); - } + if let Some(src) = preview_profile(raw, img.icc.as_deref())? { + let t = Transform::new(&src, Builtin::Srgb.profile(), Intent::RelativeColorimetric, false).map_err(|e| e.to_string())?; + let mut out = vec![0u8; rgb.len()]; + t.convert_u8(&rgb, 3, &mut out, 3, false); + rgb = Cow::Owned(out); } photocraft_raw::fit_look(sensor, opts, &Reference { width: w, height: h, rgb: &rgb }).map_err(|e| e.to_string()) } @@ -223,4 +232,29 @@ mod tests { assert_eq!(camera_name(None, Some("X")), "X"); assert_eq!(camera_name(Some("Panasonic"), Some("DC-G9")), "Panasonic DC-G9"); } + + /// A Sony body set to Adobe RGB says so only in the raw's EXIF (ColorSpace Uncalibrated, InteropIndex "R03"): + /// its preview, which has no ICC profile, is read as Adobe RGB for the colour fit, not as sRGB. + #[test] + fn a_preview_without_a_profile_takes_the_raws_exif_colour_space() { + use photocraft_raw::testgen::{TiffBuilder, Val}; + let raw = |color_space: u16, index: &str| { + let mut t = TiffBuilder::default(); + let interop = t.ifd(vec![(0x0001, Val::Ascii(index.into()))]); + let exif = t.ifd(vec![(0xA001, Val::Short(vec![color_space])), (0xA005, Val::Ifds(vec![interop]))]); + t.chain = vec![t.ifd(vec![(271, Val::Ascii("SONY".into())), (34665, Val::Ifds(vec![exif]))])]; + t.build() + }; + let adobe = raw(0xFFFF, "R03"); + let srgb = raw(1, "R98"); + assert!(preview_profile(&adobe, None).unwrap().unwrap().same_colors(Builtin::AdobeRgbCompat.profile())); + assert!(preview_profile(&srgb, None).unwrap().is_none()); + assert!(preview_profile(b"not a raw", None).unwrap().is_none(), "unstated: sRGB"); + // the preview's own profile wins over the raw's EXIF + let srgb_icc = Builtin::Srgb.profile().to_bytes().to_vec(); + assert!(preview_profile(&adobe, Some(srgb_icc.as_slice())).unwrap().is_none()); + let p3_icc = Builtin::DisplayP3.profile().to_bytes().to_vec(); + assert!(preview_profile(&srgb, Some(p3_icc.as_slice())).unwrap().unwrap().same_colors(Builtin::DisplayP3.profile())); + assert!(preview_profile(&adobe, Some(b"not an ICC profile")).is_err()); + } } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index b9f4f1c6ec..9a702f2ddb 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -74,7 +74,7 @@ pub use crate::develop::{DevelopOptions, Developed, RawInfo, WhiteBalance, devel pub use crate::error::RawError; pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve, fit_look}; pub use crate::opcodes::GainMap; -pub use crate::preview::{Preview, embedded_preview, embedded_previews}; +pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews}; pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; use crate::tiff::{Tiff, tag}; diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 4007706035..ec0c041fb5 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -97,3 +97,72 @@ pub fn embedded_previews(bytes: &[u8]) -> Vec> { }); found } + +/// The colour space a raw file states for its embedded previews, which carry no ICC profile of their own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreviewColorSpace { + Srgb, + AdobeRgb, +} + +/// The colour space the raw's EXIF gives for the camera's JPEG rendering, for a preview that declares none itself: +/// ColorSpace (0xA001) 1 = sRGB, or Uncalibrated (0xFFFF) with the Interoperability IFD's InteropIndex "R03", the +/// DCF option file for Adobe RGB (as a Sony body set to Adobe RGB writes; Exif and DCF specifications). `None` when +/// the file doesn't say, and for a DNG, whose EXIF describes the original capture while its previews are the +/// converter's. A preview's own ICC profile takes precedence over this. +pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { + let t = Tiff::new(bytes)?; + let ifd0 = t.ifd_at(t.first_ifd, 0)?; + if ifd0.has(tag::DNG_VERSION) { + return None; + } + let exif = t.ifd_at(t.tag_uint(&ifd0, tag::EXIF_IFD)? as usize, 0)?; + match t.tag_uint(&exif, tag::COLOR_SPACE)? { + 1 => Some(PreviewColorSpace::Srgb), + 0xFFFF => { + let interop = t.ifd_at(t.tag_uint(&exif, tag::INTEROP_IFD)? as usize, 0)?; + t.raw(interop.get(tag::INTEROP_INDEX)?)?.starts_with(b"R03").then_some(PreviewColorSpace::AdobeRgb) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testgen::{TiffBuilder, Val}; + + /// A raw-like TIFF whose EXIF IFD has `color_space` and, when given, an Interoperability IFD with `index`. + fn raw_with(color_space: Option, index: Option<&str>, dng: bool) -> Vec { + let mut t = TiffBuilder::default(); + let mut exif = Vec::new(); + if let Some(cs) = color_space { + exif.push((tag::COLOR_SPACE, Val::Short(vec![cs]))); + } + if let Some(index) = index { + let interop = t.ifd(vec![(tag::INTEROP_INDEX, Val::Ascii(index.into()))]); + exif.push((tag::INTEROP_IFD, Val::Ifds(vec![interop]))); + } + let exif = t.ifd(exif); + let mut ifd0 = vec![(tag::MAKE, Val::Ascii("SONY".into())), (tag::EXIF_IFD, Val::Ifds(vec![exif]))]; + if dng { + ifd0.push((tag::DNG_VERSION, Val::Byte(vec![1, 6, 0, 0]))); + } + t.chain = vec![t.ifd(ifd0)]; + t.build() + } + + #[test] + fn exif_colour_space_of_the_previews() { + assert_eq!(embedded_preview_color_space(&raw_with(Some(1), Some("R98"), false)), Some(PreviewColorSpace::Srgb)); + assert_eq!(embedded_preview_color_space(&raw_with(Some(0xFFFF), Some("R03"), false)), Some(PreviewColorSpace::AdobeRgb)); + for (cs, index) in [(Some(0xFFFF), Some("R98")), (Some(0xFFFF), None), (Some(2), Some("R03")), (None, Some("R03"))] { + assert_eq!(embedded_preview_color_space(&raw_with(cs, index, false)), None, "{cs:?} {index:?}"); + } + // a DNG's previews are the converter's, whatever the capture was + assert_eq!(embedded_preview_color_space(&raw_with(Some(0xFFFF), Some("R03"), true)), None); + for junk in [&b""[..], b"II*\0", b"II*\0\x08\0\0\0\xff\xff", b"not a tiff at all"] { + assert_eq!(embedded_preview_color_space(junk), None); + } + } +} diff --git a/crates/raw/src/tiff.rs b/crates/raw/src/tiff.rs index 17b9b8af76..671ab11fbe 100644 --- a/crates/raw/src/tiff.rs +++ b/crates/raw/src/tiff.rs @@ -36,6 +36,10 @@ pub(crate) mod tag { pub const EXIF_IFD: u16 = 34665; pub const EXIF_CFA_PATTERN: u16 = 41730; pub const MAKER_NOTE: u16 = 37500; + pub const COLOR_SPACE: u16 = 40961; + pub const INTEROP_IFD: u16 = 40965; + /// In the Interoperability IFD. + pub const INTEROP_INDEX: u16 = 1; pub const DNG_VERSION: u16 = 50706; pub const UNIQUE_CAMERA_MODEL: u16 = 50708; pub const CFA_PLANE_COLOR: u16 = 50710; diff --git a/crates/raw/tests/hostile.rs b/crates/raw/tests/hostile.rs index dabe9ffe9e..4b91f2f103 100644 --- a/crates/raw/tests/hostile.rs +++ b/crates/raw/tests/hostile.rs @@ -88,6 +88,7 @@ fn exercise(b: &[u8]) { let _ = identify(b); let _ = embedded_preview(b); let _ = embedded_previews(b); + let _ = embedded_preview_color_space(b); let opts = DevelopOptions { limits: Limits { max_alloc: 64 << 20, ..Limits::default() }, ..Default::default() }; if let Ok(s) = decode(b, &opts.limits) { for m in [Demosaic::Bilinear, Demosaic::Ahd] { From e35671ffa9d99b107aa471f75c875619c6e54fba Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:48:55 +0200 Subject: [PATCH 102/523] Shadows/Highlights: Photoshop's, measured (#3765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4, 8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the result with the colour differences scaled by Color, and the Midtone S-curve. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/algo/src/tone.rs | 131 +------ crates/algo/src/tone/shadows_highlights.rs | 423 +++++++++++++++++++++ 2 files changed, 427 insertions(+), 127 deletions(-) create mode 100644 crates/algo/src/tone/shadows_highlights.rs diff --git a/crates/algo/src/tone.rs b/crates/algo/src/tone.rs index 2050d8138d..8592b3dda2 100644 --- a/crates/algo/src/tone.rs +++ b/crates/algo/src/tone.rs @@ -4,7 +4,8 @@ //! All work on straight (non-premultiplied) RGBA buffers in 0..=1, row-major `w × h`, so callers //! convert any colour model / bit depth to RGBA first (as `Image › Adjustments` does for the //! per-pixel adjustments). Formulas are documented approximations of Photoshop's behaviour, -//! derived from its manual and observation, not from its implementation. +//! derived from its manual and observation, not from its implementation; Shadows/Highlights is +//! measured on Photoshop (see [`shadows_highlights`]). use photocraft_color::convert::{lab_to_srgb, srgb_to_lab}; use serde::{Deserialize, Serialize}; @@ -17,18 +18,6 @@ fn luma(c: [f32; 3]) -> f32 { 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2] } -fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { - let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -/// Blurred luminance (Gaussian, `radius` px ≈ 2σ like Photoshop's radius fields). -fn blurred_luma(px: &[[f32; 4]], w: usize, h: usize, radius: f32) -> Vec { - let mut l: Vec = px.iter().map(|p| luma([p[0], p[1], p[2]])).collect(); - gauss_blur_n(&mut l, w, h, 1, radius.max(0.0) / 2.0); - l -} - /// Image › Adjustments › Shadows/Highlights parameters (Photoshop's dialog, percentages as 0..=100). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ShadowsHighlights { @@ -64,95 +53,8 @@ impl Default for ShadowsHighlights { } } -/// Local tone mapping: a blurred-luminance mask selects shadows (dark neighbourhoods, up to the -/// tonal width) and highlights; shadows are lifted and highlights pulled down by a gain that -/// preserves hue, then saturation is corrected where the gain moved tones, midtone contrast is -/// applied, and the result is stretched by the clip percentages. -pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) { - if px.is_empty() { - return; - } - let ls = blurred_luma(px, w, h, p.shadow_radius); - let lh = if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 { ls.clone() } else { blurred_luma(px, w, h, p.highlight_radius) }; - let (sa, ha) = (p.shadow_amount / 100.0, p.highlight_amount / 100.0); - let (st, ht) = ((p.shadow_tone / 100.0).max(0.01), (p.highlight_tone / 100.0).max(0.01)); - let color = p.color / 100.0; - let mid = p.midtone / 100.0; - par_rows(px, w, 1, |y, pxrow| { - for (x, q) in pxrow.iter_mut().enumerate() { - let i = y * w + x; - if q[3] <= 0.0 { - continue; - } - let c = [q[0], q[1], q[2]]; - // Mask weights: 1 in deep shadows (resp. bright highlights), fading out at the tonal width. - let ms = 1.0 - smoothstep(0.0, st, ls[i]); - let mh = smoothstep(1.0 - ht, 1.0, lh[i]); - let mut out = c; - if sa > 0.0 && ms > 0.0 { - // Lift: a gamma-like curve on the pixel, stronger for darker neighbourhoods. - let g = 1.0 / (1.0 + 2.0 * sa * ms); - out = out.map(|v| v.max(0.0).powf(g)); - } - if ha > 0.0 && mh > 0.0 { - let g = 1.0 / (1.0 + 2.0 * ha * mh); - out = out.map(|v| 1.0 - (1.0 - v.min(1.0)).powf(g)); - } - if mid != 0.0 { - let k = 1.0 + mid * (1.0 - ms - mh).clamp(0.0, 1.0); - out = out.map(|v| (v - 0.5) * k + 0.5); - } - let changed = (ms * sa + mh * ha).min(1.0); - if color != 0.0 && changed > 0.0 { - let l1 = luma(out); - let s = 1.0 + color * changed; - out = out.map(|v| l1 + (v - l1) * s); - } - q[0] = out[0].clamp(0.0, 1.0); - q[1] = out[1].clamp(0.0, 1.0); - q[2] = out[2].clamp(0.0, 1.0); - } - }); - clip_stretch(px, p.black_clip, p.white_clip); -} - -/// Stretches levels so `black`% of pixels clip to 0 and `white`% to 1 (luminance histogram). -fn clip_stretch(px: &mut [[f32; 4]], black: f32, white: f32) { - if black <= 0.0 && white <= 0.0 { - return; - } - let mut hist = [0u32; 1024]; - let mut n = 0u32; - for q in px.iter().filter(|q| q[3] > 0.0) { - hist[((luma([q[0], q[1], q[2]]).clamp(0.0, 1.0)) * 1023.0).round() as usize] += 1; - n += 1; - } - if n == 0 { - return; - } - let find = |pct: f32, rev: bool| -> f32 { - let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32; - let mut acc = 0; - for k in 0..1024 { - let i = if rev { 1023 - k } else { k }; - acc += hist[i]; - if acc > target { - return i as f32 / 1023.0; - } - } - if rev { 0.0 } else { 1.0 } - }; - let lo = find(black, false); - let hi = find(white, true); - if hi - lo < 1e-3 { - return; - } - for q in px.iter_mut().filter(|q| q[3] > 0.0) { - for v in &mut q[..3] { - *v = ((*v - lo) / (hi - lo)).clamp(0.0, 1.0); - } - } -} +mod shadows_highlights; +pub use shadows_highlights::shadows_highlights; /// Image › Adjustments › Replace Color: Color Range-style selection (fuzziness in 0..=200 levels) /// around `color`, then a hue (±180°), saturation and lightness (±100) shift weighted by it. @@ -378,31 +280,6 @@ mod tests { .collect() } - #[test] - fn shadows_lift_dark_pixels_more_than_bright() { - let mut px = ramp(32, 8); - let orig = px.clone(); - shadows_highlights(&mut px, 32, 8, &ShadowsHighlights { black_clip: 0.0, white_clip: 0.0, ..Default::default() }); - let gain = |i: usize| luma([px[i][0], px[i][1], px[i][2]]) - luma([orig[i][0], orig[i][1], orig[i][2]]); - assert!(gain(3) > 0.02, "{}", gain(3)); - assert!(gain(3) > gain(30), "{} {}", gain(3), gain(30)); - // Zero amounts and no clipping: identity. - let mut px2 = orig.clone(); - let id = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 0.0, color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; - shadows_highlights(&mut px2, 32, 8, &id); - assert_eq!(px2, orig); - } - - #[test] - fn highlights_darken_bright_pixels() { - let mut px = ramp(32, 8); - let orig = px.clone(); - let p = ShadowsHighlights { shadow_amount: 0.0, highlight_amount: 80.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; - shadows_highlights(&mut px, 32, 8, &p); - assert!(px[30][0] < orig[30][0]); - assert!((px[1][0] - orig[1][0]).abs() < 1e-6); - } - #[test] fn replace_color_only_touches_matching_pixels() { let mut px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]]; diff --git a/crates/algo/src/tone/shadows_highlights.rs b/crates/algo/src/tone/shadows_highlights.rs new file mode 100644 index 0000000000..0b5feaee2d --- /dev/null +++ b/crates/algo/src/tone/shadows_highlights.rs @@ -0,0 +1,423 @@ +//! Image › Adjustments › Shadows/Highlights, as Photoshop 25.4 does it. +//! +//! Measured on Photoshop as a black box (synthetic 16-bit images through the adjustment, results +//! read back exactly); no Photoshop code was read. The pieces: +//! +//! - **Neighbourhood brightness.** Per pixel, the mean of its brightest channel and its Rec. 601 +//! luma. Its neighbourhood value is an edge-aware average: a Gaussian of σ = 1.14 · Radius whose +//! weights fall linearly with the difference from the pixel's own (lightly blurred) brightness, +//! so pixels across a strong edge count less and halos stay small. It is quantised to 8 bits. +//! - **Tonal mask.** Tone sets which neighbourhoods are treated: shadows take the complement of a +//! Beta distribution function of the neighbourhood brightness, from Beta(1.95, 23) at Tone 0 to +//! 1 − L at Tone 95 and over; highlights take the same of 1 − L. +//! - **Curve.** Amount sets a curve that screens the image with itself: 1 − (1 − v)^q, blended +//! between q = 1, 2, 4 and 8 by a strength s = 3 at 100, 2 at 50 and 1 near 15. Shadows lift each +//! channel by mask × (curve − v); highlights darken with the mirrored curve. +//! - **Colour.** Only the luma of that result is kept: the pixel's own colour differences are +//! added back, scaled by the Color slider (by exp(h · ΔY / max Y) for positive values, by +//! 1 + h · ΔY / max Y for negative ones) and shortened where they would leave the gamut. +//! - **Midtone** is a global S-curve on the luma, k · (Y − ½) · (4Y(1 − Y))^p. + +use crate::fxutil::gauss_blur_n; +use crate::photo_util::par_rows; + +use super::ShadowsHighlights; + +const W: [f32; 3] = [0.299, 0.587, 0.114]; + +fn luma(c: [f32; 3]) -> f32 { + W[0] * c[0] + W[1] * c[1] + W[2] * c[2] +} + +/// Neighbourhood σ per pixel of Radius. +const SPATIAL: f32 = 1.1424; +/// σ of the blur of the pixel's own brightness the edge weights compare with. +const GUIDE: f32 = 1.3559; +/// Brightness difference at which a neighbour's weight reaches zero. +const RANGE: f32 = 1.0152; +/// Brightness levels of the piecewise-linear edge-aware average. +const LEVELS: usize = 17; + +/// Edge-aware neighbourhood brightness of `lum` (`w × h`) for Photoshop's Radius. +fn neighbourhood(lum: &[f32], w: usize, h: usize, radius: f32) -> Vec { + let mut guide = lum.to_vec(); + gauss_blur_n(&mut guide, w, h, 1, GUIDE); + let sigma = SPATIAL * radius; + if sigma < 0.3 { + return guide; + } + // The average at each of a few brightness levels, interpolated at the pixel's own level + // (Durand and Dorsey, "Fast bilateral filtering", 2002). The averages are smooth, so they are + // taken on a grid `d` times coarser, with σ ≥ 4 cells there. + let d = ((sigma / 4.0).floor() as usize).max(1); + let (rw, rh) = (w.div_ceil(d), h.div_ceil(d)); + let cells = rw * rh; + let step = 1.0 / (LEVELS - 1) as f32; + let mut avg = vec![0.0f32; LEVELS * cells]; + let mut buf = vec![0.0f32; 2 * cells]; + for (k, out) in avg.chunks_exact_mut(cells).enumerate() { + let g = k as f32 * step; + buf.fill(0.0); + for (i, &l) in lum.iter().enumerate() { + let wgt = (1.0 - (l - g).abs() / RANGE).max(0.0); + let c = 2 * ((i / w) / d * rw + (i % w) / d); + buf[c] += wgt * l; + buf[c + 1] += wgt; + } + gauss_blur_n(&mut buf, rw, rh, 2, sigma / d as f32); + for (o, b) in out.iter_mut().zip(buf.as_chunks::<2>().0) { + *o = if b[1] > 1e-6 { b[0] / b[1] } else { g }; + } + } + let mut out = vec![0.0f32; lum.len()]; + par_rows(&mut out, w, 1, |y, row| { + let fy = ((y as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rh - 1) as f32); + let (y0, ty) = (fy.floor() as usize, fy.fract()); + let y1 = (y0 + 1).min(rh - 1); + for (x, o) in row.iter_mut().enumerate() { + let fx = ((x as f32 + 0.5) / d as f32 - 0.5).clamp(0.0, (rw - 1) as f32); + let (x0, tx) = (fx.floor() as usize, fx.fract()); + let x1 = (x0 + 1).min(rw - 1); + let at = |k: usize| { + let a = &avg[k * cells..(k + 1) * cells]; + let top = a[y0 * rw + x0] + (a[y0 * rw + x1] - a[y0 * rw + x0]) * tx; + let bot = a[y1 * rw + x0] + (a[y1 * rw + x1] - a[y1 * rw + x0]) * tx; + top + (bot - top) * ty + }; + let t = (guide[y * w + x].clamp(0.0, 1.0) / step).min((LEVELS - 1) as f32 - 1e-4); + let k = t.floor() as usize; + let f = t - k as f32; + *o = at(k) * (1.0 - f) + at(k + 1) * f; + } + }); + out +} + +/// Beta(α, β) parameters of the tonal mask for Tone 0, 5, …, 95 (fitted to Photoshop's masks +/// within 2/255). +const MASK_BETA: [(f32, f32); 20] = [ + (1.9468, 22.9366), + (1.7770, 18.3471), + (1.6403, 14.7559), + (1.5270, 11.9346), + (1.4269, 9.6467), + (1.3470, 7.8430), + (1.2807, 6.4038), + (1.2240, 5.2384), + (1.1772, 4.2967), + (1.1373, 3.5350), + (1.1021, 2.9109), + (1.0755, 2.4186), + (1.0508, 2.0189), + (1.0307, 1.7034), + (1.0140, 1.4583), + (1.0015, 1.2758), + (1.0, 1.1403), + (1.0, 1.0510), + (1.0, 1.0002), + (1.0, 1.0), +]; + +/// The tonal mask for `tone` (0..=100) at the 256 neighbourhood levels: 1 − I_L(α, β). +fn tonal_mask(tone: f32) -> [f32; 256] { + let t = (tone.clamp(0.0, 95.0)) / 5.0; + let i = (t.floor() as usize).min(MASK_BETA.len() - 2); + let f = t - i as f32; + let (a0, b0) = MASK_BETA[i]; + let (a1, b1) = MASK_BETA[i + 1]; + let (a, b) = ((a0 + (a1 - a0) * f) as f64, (b0 + (b1 - b0) * f) as f64); + // Midpoint sums of the Beta density, 16 per level. + const SUB: usize = 16; + let n = 255 * SUB; + let dens = |x: f64| ((a - 1.0) * x.ln() + (b - 1.0) * (1.0 - x).ln()).exp(); + let mut cum = vec![0.0f64; 256]; + let mut acc = 0.0; + for j in 0..n { + acc += dens((j as f64 + 0.5) / n as f64); + if (j + 1) % SUB == 0 { + cum[(j + 1) / SUB] = acc; + } + } + let total = acc.max(f64::MIN_POSITIVE); + std::array::from_fn(|k| (1.0 - cum[k] / total) as f32) +} + +/// Curve strength for Amount `a` (0..=100): 0 at 0, 1 near 15, 2 at 50, 3 at 100. +fn strength(a: f32) -> f32 { + let u = (a.clamp(0.0, 100.0) / 100.0).powf(0.6216); + if u <= 0.3 { u / 0.3 } else { (1.0 + 20.0 * u) / 7.0 } +} + +/// The full-strength curve of strength `s`: 1 − (1 − v)^q between q = 2^⌊s⌋ and 2^(⌊s⌋+1). +fn lift(s: f32, v: f32) -> f32 { + let n = (s.floor() as i32).clamp(0, 2); + let t = (s - n as f32).clamp(0.0, 1.0); + let c = (1.0 - v).clamp(0.0, 1.0); + let lo = c.powi(1 << n); + let hi = lo * lo; + 1.0 - ((1.0 - t) * lo + t * hi) +} + +/// Linear interpolation in a table of `(x, y)` sorted by `x`. +fn interp(table: &[(f32, f32)], x: f32) -> f32 { + let Some(&(x0, y0)) = table.first() else { return 0.0 }; + if x <= x0 { + return y0; + } + for p in table.windows(2) { + let ((xa, ya), (xb, yb)) = (p[0], p[1]); + if x <= xb { + return ya + (yb - ya) * (x - xa) / (xb - xa); + } + } + table.last().map_or(0.0, |p| p.1) +} + +/// Color: the h of the colour-difference gain for positive values (exp(h · F))… +const COLOR_UP: [(f32, f32); 12] = [ + (0.0, 0.0), + (1.0, 0.1566), + (2.0, 0.2271), + (5.0, 0.3776), + (10.0, 0.5653), + (20.0, 0.8669), + (30.0, 1.1299), + (40.0, 1.3748), + (50.0, 1.6093), + (60.0, 1.8375), + (75.0, 2.1721), + (100.0, 2.7193), +]; +/// … and for negative values (1 + h · F), by −Color. +const COLOR_DOWN: [(f32, f32); 9] = + [(0.0, 0.0), (1.0, -0.0145), (5.0, -0.0709), (10.0, -0.1373), (20.0, -0.2570), (30.0, -0.3612), (50.0, -0.5312), (75.0, -0.6866), (100.0, -0.7968)]; + +/// Midtone: (value, k, p) of k · (Y − ½) · (4Y(1 − Y))^p. +const MIDTONE: [(f32, f32, f32); 11] = [ + (-100.0, -0.6955, 2.267), + (-75.0, -0.5873, 2.362), + (-50.0, -0.4429, 2.492), + (-25.0, -0.2514, 2.667), + (-10.0, -0.1088, 2.803), + (0.0, 0.0, 2.9), + (10.0, 0.1206, 3.016), + (25.0, 0.3262, 3.216), + (50.0, 0.7423, 3.625), + (75.0, 1.2635, 4.160), + (100.0, 1.9032, 4.850), +]; + +fn midtone(m: f32, y: f32) -> f32 { + if m == 0.0 { + return y; + } + let ks: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.1)).collect(); + let ps: Vec<(f32, f32)> = MIDTONE.iter().map(|r| (r.0, r.2)).collect(); + let (k, p) = (interp(&ks, m), interp(&ps, m)); + (y + k * (y - 0.5) * (4.0 * y * (1.0 - y)).max(0.0).powf(p)).clamp(0.0, 1.0) +} + +fn level(v: f32) -> usize { + (v.clamp(0.0, 1.0) * 255.0).round() as usize +} + +/// Image › Adjustments › Shadows/Highlights on straight RGBA `w × h`. +pub fn shadows_highlights(px: &mut [[f32; 4]], w: usize, h: usize, p: &ShadowsHighlights) { + if px.is_empty() || px.len() != w * h { + return; + } + let (sa, ha) = (p.shadow_amount, p.highlight_amount); + let lum: Vec = px.iter().map(|q| (q[0].max(q[1]).max(q[2]) + luma([q[0], q[1], q[2]])) / 2.0).collect(); + let ls = (sa > 0.0).then(|| neighbourhood(&lum, w, h, p.shadow_radius)); + let lh = (ha > 0.0).then(|| match &ls { + Some(l) if (p.highlight_radius - p.shadow_radius).abs() < 1e-3 => l.clone(), + _ => neighbourhood(&lum, w, h, p.highlight_radius), + }); + let (ms, mh) = (tonal_mask(p.shadow_tone), tonal_mask(p.highlight_tone)); + let (ss, hs) = (strength(sa), strength(ha)); + // The new luma of every pixel. + let mut y2 = vec![0.0f32; px.len()]; + par_rows(&mut y2, w, 1, |y, row| { + for (x, out) in row.iter_mut().enumerate() { + let i = y * w + x; + let q = px[i]; + let mut adj = [q[0], q[1], q[2]]; + if let Some(l) = &ls { + let m = ms[level(l[i])]; + for v in &mut adj { + *v += m * (lift(ss, *v) - *v); + } + } + if let Some(l) = &lh { + let m = mh[255 - level(l[i])]; + for (v, &o) in adj.iter_mut().zip(&q[..3]) { + *v -= m * (lift(hs, 1.0 - o) - (1.0 - o)); + } + } + *out = midtone(p.midtone, luma(adj).clamp(0.0, 1.0)); + } + }); + clip_levels(&mut y2, px, p.black_clip, p.white_clip); + let gain = |f: f32| { + if p.color >= 0.0 { (interp(&COLOR_UP, p.color) * f).exp() } else { 1.0 + interp(&COLOR_DOWN, -p.color) * f } + }; + par_rows(px, w, 1, |y, row| { + for (x, q) in row.iter_mut().enumerate() { + if q[3] <= 0.0 { + continue; + } + let y1 = luma([q[0], q[1], q[2]]); + let yn = y2[y * w + x]; + let f = (yn - y1).abs() / yn.max(y1).max(1e-6); + let g = gain(f); + let d = [(q[0] - y1) * g, (q[1] - y1) * g, (q[2] - y1) * g]; + // Shorten the colour difference where it would leave 0..=1. + let mut k = 1.0f32; + for v in d { + if v > 1e-9 { + k = k.min((1.0 - yn) / v); + } else if v < -1e-9 { + k = k.min(yn / -v); + } + } + let k = k.clamp(0.0, 1.0); + for c in 0..3 { + q[c] = (yn + k * d[c]).clamp(0.0, 1.0); + } + } + }); +} + +/// Black and White Clip: stretches the new luma so `black`% of pixels reach 0 and `white`% reach 1. +fn clip_levels(y2: &mut [f32], px: &[[f32; 4]], black: f32, white: f32) { + if black <= 0.0 && white <= 0.0 { + return; + } + let mut hist = vec![0u32; 4096]; + let mut n = 0u32; + for (&y, q) in y2.iter().zip(px) { + if q[3] > 0.0 { + hist[(y.clamp(0.0, 1.0) * 4095.0).round() as usize] += 1; + n += 1; + } + } + if n == 0 { + return; + } + let find = |pct: f32, rev: bool| -> f32 { + let target = (pct.clamp(0.0, 50.0) / 100.0 * n as f32) as u32; + let mut acc = 0; + for k in 0..4096 { + let i = if rev { 4095 - k } else { k }; + acc += hist[i]; + if acc > target { + return i as f32 / 4095.0; + } + } + if rev { 0.0 } else { 1.0 } + }; + let (lo, hi) = (if black > 0.0 { find(black, false) } else { 0.0 }, if white > 0.0 { find(white, true) } else { 1.0 }); + if hi - lo < 1e-3 { + return; + } + for y in y2.iter_mut() { + *y = ((*y - lo) / (hi - lo)).clamp(0.0, 1.0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A flat `w × h` grey or colour image with one different pixel in the middle; returns that + /// pixel after Shadows/Highlights. + fn probe(bg: [f32; 3], px: [f32; 3], p: &ShadowsHighlights) -> [f32; 3] { + let (w, h) = (121, 121); + let mut img = vec![[bg[0], bg[1], bg[2], 1.0]; w * h]; + img[60 * w + 60] = [px[0], px[1], px[2], 1.0]; + shadows_highlights(&mut img, w, h, p); + let q = img[60 * w + 60]; + [q[0], q[1], q[2]] + } + + fn levels(v: [i32; 3]) -> [f32; 3] { + v.map(|c| c as f32 / 32768.0) + } + + fn off(a: [f32; 3], b: [f32; 3]) -> f32 { + a.iter().zip(b).map(|(x, y)| (x - y).abs() * 255.0).fold(0.0, f32::max) + } + + /// Photoshop 25.4's results (16-bit levels of 32768) for grey pixels lifted by the default + /// Shadows (35 %, Tone 50 %, Radius 30 px; Color 0), in black surroundings and in grey 0.5. + #[test] + fn grey_matches_photoshop() { + let p = ShadowsHighlights { color: 0.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; + for (bg, want) in [(0.0, [11389, 19422, 28451, 31931]), (0.5, [5211, 9909, 18229, 25700])] { + for (v, w) in [0.125f32, 0.25, 0.5, 0.749969].into_iter().zip(want) { + let got = probe([bg; 3], [v; 3], &p); + assert!(off(got, levels([w; 3])) < 2.0, "bg {bg} v {v}: {} vs {}", got[0] * 32768.0, w); + } + } + } + + /// Colour pixels keep their colour differences, scaled by Color (+20 here; Amount 35 %, + /// Radius 10 px), in black, 0.15 and 0.3 grey surroundings: Photoshop's results. + #[test] + fn colour_matches_photoshop() { + let p = ShadowsHighlights { shadow_radius: 10.0, black_clip: 0.0, white_clip: 0.0, ..Default::default() }; + for (bg, px, want) in [ + (0.0, [3277, 1638, 0], [8235, 5202, 2170]), + (0.0, [8192, 6554, 4915], [19350, 16514, 13676]), + (4915.0 / 32768.0, [4915, 3277, 1638], [10124, 7424, 4723]), + (9830.0 / 32768.0, [11469, 9830, 8192], [16540, 14376, 12214]), + ] { + let got = probe([bg; 3], levels(px), &p); + assert!(off(got, levels(want)) < 2.0, "{px:?} on {bg}: {:?} vs {want:?}", got.map(|c| (c * 32768.0).round())); + } + } + + /// Across a strong edge the neighbourhood barely leaks (Amount 100 %, Tone 100 %, Radius + /// 10 px; 0.1 | 0.9): Photoshop's results 8, 2 and 1 pixels either side. + #[test] + fn edge_matches_photoshop() { + let (w, h) = (200, 24); + let (a, b) = (3277.0 / 32768.0, 29491.0 / 32768.0); + let mut img: Vec<[f32; 4]> = (0..w * h).map(|i| if i % w < 100 { [a, a, a, 1.0] } else { [b, b, b, 1.0] }).collect(); + let p = ShadowsHighlights { + shadow_amount: 100.0, + shadow_tone: 100.0, + shadow_radius: 10.0, + color: 0.0, + black_clip: 0.0, + white_clip: 0.0, + ..Default::default() + }; + shadows_highlights(&mut img, w, h, &p); + let row = &img[12 * w..13 * w]; + for (x, want) in [(92, 16370), (98, 14439), (99, 12146), (100, 30891), (101, 30403), (102, 30146), (108, 29979)] { + let got = row[x][0] * 32768.0; + assert!((got - want as f32).abs() / 32768.0 * 255.0 < 6.0, "x {x}: {got} vs {want}"); + } + } + + #[test] + fn strength_and_curve_follow_photoshop() { + assert!((strength(50.0) - 2.0).abs() < 2e-3 && (strength(100.0) - 3.0).abs() < 1e-4); + assert!((strength(15.0) - 1.02).abs() < 0.01 && (strength(1.0) - 0.19).abs() < 0.01); + // Amount 50 is the image screened with itself twice: 1 − (1 − v)^4. + for v in [0.1f32, 0.3, 0.6] { + assert!((lift(2.0, v) - (1.0 - (1.0 - v).powi(4))).abs() < 1e-6); + } + } + + #[test] + fn tonal_mask_spans_photoshop_tones() { + let m95 = tonal_mask(95.0); + assert!((m95[128] - (1.0 - 128.0 / 255.0)).abs() < 1e-3, "{}", m95[128]); + assert_eq!(tonal_mask(100.0), m95); + // Tone 0 still reaches about 7 % into the levels (Photoshop: half at level 18). + let m0 = tonal_mask(0.0); + assert!(m0[0] > 0.999 && (m0[18] - 0.5).abs() < 0.03 && m0[60] < 0.02, "{} {}", m0[18], m0[60]); + } +} From 836fd52089f5de988accba94e929f288a7313438 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:49:05 +0200 Subject: [PATCH 103/523] fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780) Photoshop's Black & White tint keeps the grey's luminosity and takes the tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the same as the Color blend mode. PhotoCraft mixed half the grey with twice the grey times the tint, up to 141 levels off. Measured in Photoshop 27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16 black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %. CPU and GPU paths. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 29 ++++++++++++++++++++++++++++- crates/gpu/src/compose.wgsl | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index 357d8ac0d8..f4e6bede26 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -188,7 +188,9 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| { let g = black_white_gray(c, weights); match tint { - Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5), + // Photoshop tints like the Color blend mode: the tint's hue and saturation at the + // grey's luminosity (SetLum + ClipColor), measured in Photoshop 27.11. + Some(t) => photocraft_color::blend::set_lum(*t, g).map(|v| v.clamp(0.0, 1.0)), None => [g; 3], } }), @@ -1294,6 +1296,31 @@ mod tone_tests { assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d)); } + /// Photoshop 27.11, measured: Black & White (default weights) with Tint over greys. The tint + /// is blended like the Color blend mode: its hue and saturation at the grey's luminosity. + #[test] + fn black_white_tint_matches_photoshop() { + let weights = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; + let measured: [([u8; 3], u8, [u8; 3]); 6] = [ + ([255, 0, 0], 24, [81, 1, 1]), + ([255, 0, 0], 120, [255, 64, 64]), + ([255, 0, 0], 240, [255, 235, 235]), + ([0xe1, 0xd3, 0xb3], 120, [133, 119, 87]), + ([0xe1, 0xd3, 0xb3], 24, [33, 24, 1]), + ([0x40, 0x20, 0x10], 120, [144, 112, 96]), + ]; + for (tint, grey, want) in measured { + let tint = Some(tint.map(|v| v as f32 / 255.0)); + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[grey as f32 / 255.0; 4]] }; + b.px[0][3] = 1.0; + apply(&Adjustment::BlackWhite { weights, tint }, &mut b); + for (i, w) in want.iter().enumerate() { + let got = b.px[0][i] * 255.0; + assert!((got - *w as f32).abs() <= 2.5, "tint {tint:?} grey {grey} channel {i}: {got} vs {w}"); + } + } + } + #[test] fn gradient_map_dither_stays_close() { let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 5a3a6fe58e..790b217552 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -686,7 +686,7 @@ fn adjust(c: vec3) -> vec3 { if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; } let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0); if (p1.z > 0.5) { - return clamp(g * p2.rgb * 2.0, vec3(0.0), vec3(1.0)) * 0.5 + g * 0.5; + return clamp(set_lum(p2.rgb, g, 1.0), vec3(0.0), vec3(1.0)); // Color blend of the tint } return vec3(g); } From c5da1a0d5f2cc215d986d1b014bb250c96dacfd5 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:49:16 +0200 Subject: [PATCH 104/523] Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781) * fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop Measured in Photoshop 27.11: - Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft multiplied by (1 + s), up to 26 levels off. - Master Lightness applies first; hue and saturation then work on the result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %. - Colorize builds the colour at the lightened lightness, so black with Lightness +30 is a 30 % lightness colour, not grey (was 78 off). - A colour range's positive Lightness lightens towards the pixel's brightest channel, not towards white (Blues +50 was 71 off). All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb 120 -> 3). CPU and GPU paths. Co-Authored-By: Claude Opus 5.5 * fix(adjust): a range's negative Lightness darkens towards the darkest channel Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204) at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel, not towards black. Mirrors the positive rule (towards the brightest). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 85 ++++++++++++++++++++++++++++++------ crates/gpu/src/compose.wgsl | 34 ++++++++++----- 2 files changed, 94 insertions(+), 25 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index f4e6bede26..a39f3307f9 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -163,7 +163,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth } None => (0.0, 0.0, 0.0), }; - hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0 + dl).clamp(-1.0, 1.0), *colorize) + let out = hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0).clamp(-1.0, 1.0), *colorize); + range_lightness(out, dl) }) } Adjustment::Vibrance { vibrance, saturation } => { @@ -428,21 +429,32 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec; 3] { /// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the /// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%. pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] { - let (mut hh, mut ss, ll) = rgb_to_hsl(c); + let lighten = |v: f32| if l > 0.0 { v + (1.0 - v) * l } else { v * (1.0 + l) }; if colorize { - hh = hue.rem_euclid(360.0) / 360.0; - ss = s.abs().min(1.0); - } else { - hh = (hh + hue / 360.0).rem_euclid(1.0); - ss = (ss * (1.0 + s)).clamp(0.0, 1.0); + // Colorize builds the colour at the lightened (or darkened) lightness, so black with + // Lightness +30 becomes a 30 % lightness colour, not grey (measured in Photoshop 27.11). + let (_, _, ll) = rgb_to_hsl(c); + return hsl_to_rgb(hue.rem_euclid(360.0) / 360.0, s.abs().min(1.0), lighten(ll)); } - let mut rgb = hsl_to_rgb(hh, ss, ll); - if l > 0.0 { - rgb = rgb.map(|v| v + (1.0 - v) * l); - } else if l < 0.0 { - rgb = rgb.map(|v| v * (1.0 + l)); - } - rgb + // Photoshop applies Lightness first, then rotates the hue and scales the saturation of the + // result: Hue +40, Saturation +25, Lightness -10 turns (240, 160, 30) into (173, 243, 0), not + // (168, 230, 14) (Photoshop 27.11 corpus file adjustments/rgb8/hue-saturation). + let (hh, ss, ll) = rgb_to_hsl(c.map(lighten)); + let hh = (hh + hue / 360.0).rem_euclid(1.0); + // Raising saturation divides by 1 - s, lowering it multiplies by 1 + s: +50 % doubles it + // (measured in Photoshop 27.11); +100 % saturates any colour that has some. + let ss = if s > 0.0 { ss / (1.0 - s).max(1.0 / 512.0) } else { ss * (1.0 + s) }.clamp(0.0, 1.0); + hsl_to_rgb(hh, ss, ll) +} + +/// A colour range's Lightness, applied after the master edits. It moves each channel towards the +/// pixel's brightest channel when raised and towards its darkest when lowered, not towards white +/// or black: Blues +50 turns (0, 29, 115) into (57, 71, 115), Blues -50 turns (102, 128, 204) into +/// (102, 115, 153) (measured in Photoshop 27.11). +pub fn range_lightness(c: [f32; 3], l: f32) -> [f32; 3] { + let l = l.clamp(-1.0, 1.0); + let target = if l > 0.0 { c[0].max(c[1]).max(c[2]) } else { c[0].min(c[1]).min(c[2]) }; + c.map(|v| v + (target - v) * l.abs()) } /// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a @@ -1285,6 +1297,51 @@ mod tone_tests { } } + /// Photoshop 27.11, measured: Hue/Saturation raising saturation, and Colorize with Lightness. + #[test] + fn hue_saturation_matches_photoshop() { + // (input, hue, saturation, lightness, colorize, Photoshop's output) + type Case = ([u8; 3], f32, f32, f32, bool, [u8; 3]); + let measured: [Case; 8] = [ + ([204, 102, 102], 0.0, 50.0, 0.0, false, [254, 51, 51]), + ([204, 159, 102], 0.0, 50.0, 0.0, false, [254, 164, 51]), + ([204, 102, 102], 0.0, -50.0, 0.0, false, [178, 127, 127]), + ([0, 0, 0], 120.0, 100.0, 30.0, true, [0, 152, 0]), + ([28, 28, 28], 120.0, 100.0, 30.0, true, [0, 192, 0]), + ([160, 160, 160], 30.0, 25.0, -40.0, true, [120, 96, 73]), + ([252, 252, 252], 30.0, 25.0, -40.0, true, [177, 151, 126]), + ([255, 143, 0], 30.0, 25.0, -40.0, true, [95, 76, 58]), + ]; + for (input, hue, saturation, lightness, colorize, want) in measured { + let a = Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges: HueRange::defaults() }; + let c = input.map(|v| v as f32 / 255.0); + let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[c[0], c[1], c[2], 1.0]] }; + apply(&a, &mut b); + for (i, w) in want.iter().enumerate() { + let got = b.px[0][i] * 255.0; + assert!((got - *w as f32).abs() <= 2.0, "{input:?} h{hue} s{saturation} l{lightness} colorize {colorize}, channel {i}: {got} vs {w}"); + } + } + } + + /// Photoshop 27.11, measured: a range's Lightness (Blues ±50) moves towards the brightest or darkest channel. + #[test] + fn range_lightness_matches_photoshop() { + let measured = [ + ([0u8, 29, 115], 0.5, [57u8, 71, 115]), + ([0, 64, 255], 0.5, [125, 157, 255]), + ([102, 128, 204], 0.5, [152, 165, 204]), + ([102, 128, 204], -0.5, [102, 115, 154]), + ([0, 18, 115], -0.5, [0, 9, 58]), + ]; + for (input, l, want) in measured { + let got = range_lightness(input.map(|v| v as f32 / 255.0), l); + for i in 0..3 { + assert!((got[i] * 255.0 - want[i] as f32).abs() <= 2.5, "{input:?} channel {i}: {} vs {}", got[i] * 255.0, want[i]); + } + } + } + #[test] fn black_white_weights() { let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index 790b217552..bf535b08f0 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -633,19 +633,31 @@ fn adjust(c: vec3) -> vec3 { dl = lut(2, hsl.x) * chroma; } let sat = clamp(p0.y + ds, -1.0, 1.0); - let light = clamp(p0.z + dl, -1.0, 1.0); + let light = clamp(p0.z, -1.0, 1.0); if (p0.w > 0.5) { - hh = rem_euclid(p0.x, 360.0) / 360.0; - ss = min(abs(sat), 1.0); - } else { - hh = rem_euclid(hsl.x + (p0.x + dh) / 360.0, 1.0); - ss = clamp(hsl.y * (1.0 + sat), 0.0, 1.0); + // compose::adjust::hue_saturation: Colorize at the lightened / darkened lightness. + var ll = hsl.z * (1.0 + light); + if (light > 0.0) { ll = hsl.z + (1.0 - hsl.z) * light; } + return hsl_to_rgb(rem_euclid(p0.x, 360.0) / 360.0, min(abs(sat), 1.0), ll); } - var rgb = hsl_to_rgb(hh, ss, hsl.z); - if (light > 0.0) { - rgb = rgb + (1.0 - rgb) * light; - } else if (light < 0.0) { - rgb = rgb * (1.0 + light); + // Lightness first, then hue and saturation of the result. + var lc = c * (1.0 + light); + if (light > 0.0) { lc = c + (1.0 - c) * light; } + let lh = rgb_to_hsl(lc); + hh = rem_euclid(lh.x + (p0.x + dh) / 360.0, 1.0); + // Raising saturation divides by 1 - s; lowering it multiplies by 1 + s. + if (sat > 0.0) { + ss = clamp(lh.y / max(1.0 - sat, 1.0 / 512.0), 0.0, 1.0); + } else { + ss = clamp(lh.y * (1.0 + sat), 0.0, 1.0); + } + var rgb = hsl_to_rgb(hh, ss, lh.z); + // compose::adjust::range_lightness: towards the brightest channel when raised, the darkest when lowered. + let rl = clamp(dl, -1.0, 1.0); + if (rl > 0.0) { + rgb = rgb + (vec3(max(rgb.r, max(rgb.g, rgb.b))) - rgb) * rl; + } else if (rl < 0.0) { + rgb = rgb + (vec3(min(rgb.r, min(rgb.g, rgb.b))) - rgb) * -rl; } return rgb; } From 2aa1f8d2c6a5cdf421b07409be1ac7518de34d2d Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 06:49:26 -0500 Subject: [PATCH 105/523] feat(engine): add history.undo and history.redo aliases (#3675) (#3786) A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z. Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity. --- crates/engine/src/history_cmds.rs | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/engine/src/history_cmds.rs b/crates/engine/src/history_cmds.rs index bb9cb34391..cc18df581e 100644 --- a/crates/engine/src/history_cmds.rs +++ b/crates/engine/src/history_cmds.rs @@ -199,8 +199,39 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } +/// `history.undo` / `history.redo` enable predicates, matching `edit.undo` / `edit.redo`. +fn can_undo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.history.can_undo()).map(|_| ()).ok_or_else(|| "nothing to undo".into()) +} + +fn can_redo(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.history.can_redo()).map(|_| ()).ok_or_else(|| "nothing to redo".into()) +} + pub fn specs() -> Vec { vec![ + // `history.*` aliases of Edit › Undo / Redo, so a client reaching for the History namespace + // finds them (`edit.undo` / `edit.redo` remain the menu items and own the shortcuts, #3675). + CommandSpec { + id: "history.undo", + label: "Undo", + menu: &[], + shortcut: None, + params: "{} (the same action as `edit.undo`)", + enabled: can_undo, + run: |s, _| Ok(json!(s.undo())), + journal: false, + }, + CommandSpec { + id: "history.redo", + label: "Redo", + menu: &[], + shortcut: None, + params: "{} (the same action as `edit.redo`)", + enabled: can_redo, + run: |s, _| Ok(json!(s.redo())), + journal: false, + }, CommandSpec { id: "history.deleteState", label: "Delete State", @@ -282,6 +313,16 @@ mod tests { s.active().unwrap().history.entries() } + #[test] + fn history_undo_and_redo_alias_edit_undo_and_redo() { + let mut s = session(); + let n = s.active().unwrap().history.past_len(); + s.execute("history.undo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), n - 1, "history.undo steps back like edit.undo"); + s.execute("history.redo", json!({})).unwrap(); + assert_eq!(s.active().unwrap().history.past_len(), n, "history.redo steps forward like edit.redo"); + } + #[test] fn delete_current_state_steps_back_and_drops_it() { let mut s = session(); From 6037ade5ed3e65b582179a1fe56eb165fd5ca56d Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 20:32:15 +0800 Subject: [PATCH 106/523] Fix straight-path anchor insertion at the hit position (#3856) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- contributors/people.toml | 4 ++++ crates/vector/src/edit.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 37220e0e80..53bd154ace 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -17,6 +17,10 @@ # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. +[people.jonathanwxh-cell] +display_name = "jonathnwxh" +sync_github_name = false + [people.Skyfuller1303] display_name = "Skyfuller" sync_github_name = true diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index b0846382e5..ea99cff25c 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -184,7 +184,9 @@ pub fn add_anchor(path: &mut Path, knot: [usize; 2], t: f64) -> Result<[usize; 2 let a = *knot_mut(path, knot)?; let b = *knot_mut(path, end)?; let new = if retracted(&a, Handle::Out) && retracted(&b, Handle::In) { - let p = lerp(a.anchor, b.anchor, t); + // Hit testing and hover feedback use the cubic parameter, which is not linear even + // when both handles are retracted. Keep the inserted corner at that same point. + let p = eval(&[a.anchor, a.out_ctrl, b.in_ctrl, b.anchor], t); Knot::corner(p.x, p.y) } else { let (p01, p12, p23) = (lerp(a.anchor, a.out_ctrl, t), lerp(a.out_ctrl, b.in_ctrl, t), lerp(b.in_ctrl, b.anchor, t)); @@ -453,6 +455,28 @@ mod tests { } } + #[test] + fn adding_to_a_straight_segment_uses_the_hit_position() { + for (points, closed) in [ + (vec![(0.0, 0.0), (100.0, 0.0)], false), + (vec![(0.0, 0.0), (0.0, 100.0)], false), + (vec![(0.0, 0.0), (100.0, 100.0)], false), + (vec![(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], true), + ] { + for t in [0.25, 0.75] { + let sp = if closed { Subpath::polygon(&points) } else { Subpath::polyline(&points) }; + let mut p = Path::new(vec![sp]); + let segment = if closed { 2 } else { 0 }; + let clicked = eval(&p.subpaths[0].segments()[segment], t); + let Some(Hit::Segment(knot, hit_t)) = hit(&p, clicked, 1.0, &[]) else { panic!("expected a segment hit at {clicked:?}") }; + let added = add_anchor(&mut p, knot, hit_t).unwrap(); + let inserted = at(&p, added[0], added[1]); + assert!(dist(inserted.anchor, clicked) < 1e-9, "t={t}: inserted {:?}, clicked {clicked:?}", inserted.anchor); + assert_eq!(inserted, Knot::corner(clicked.x, clicked.y), "straight segments retain retracted handles"); + } + } + } + #[test] fn adding_to_a_straight_segment_makes_a_corner_on_the_line() { let mut p = tri(); @@ -460,7 +484,7 @@ mod tests { assert_eq!(add_anchor(&mut p, [0, 2], 0.5).unwrap(), [0, 3]); assert_eq!(at(&p, 0, 3), Knot::corner(25.0, 40.0)); assert_eq!(add_anchor(&mut p, [0, 0], 0.25).unwrap(), [0, 1]); - assert_eq!(at(&p, 0, 1), Knot::corner(25.0, 0.0)); + assert_eq!(at(&p, 0, 1), Knot::corner(15.625, 0.0)); assert_eq!(p.subpaths[0].knots.len(), 5); assert!(p.subpaths[0].closed); } From bacc886a6d41b14ee68f83de9f4a855b56bb8820 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 20:32:45 +0800 Subject: [PATCH 107/523] Preserve signed HDR colors during surface transforms (#3857) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/algo/src/transform.rs | 41 +++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index e684f87b96..1480ec2b3e 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -368,7 +368,8 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt any = true; let o = ((y - t.y0) as usize * w + (x - t.x0) as usize) * n; for c in 0..a { - outp[o + c] = (acc[c] / al).clamp(0.0, 1.0) as f32; + // Integer storage clips on write; float colours retain their signed/HDR range. + outp[o + c] = (acc[c] / al) as f32; } outp[o + a] = al as f32; } @@ -482,6 +483,44 @@ mod tests { } } + #[test] + fn translation_preserves_colour_range_at_every_depth() { + use photocraft_color::{ColorMode, SampleType}; + + let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]); + for sample in SampleType::ALL { + for mode in [ColorMode::Rgb, ColorMode::Grayscale] { + for alpha in [false, true] { + let mut s = Surface::new(PixelFormat::new(mode, sample, alpha)); + let mut pixel = if mode == ColorMode::Rgb { vec![2.0, -0.25, 0.5] } else { vec![2.0] }; + if alpha { + pixel.push(0.5); + } + s.fill_rect(Rect::new(4, 4, 8, 8), &pixel); + let mut expected = s.pixel(5, 5); + if !alpha { + expected.push(1.0); + } + for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] { + let out = warp_surface(&s, s.content_bounds(), &h, interp).unwrap(); + assert_eq!(out.pixel(12, 2), expected, "{sample:?}/{mode:?}/alpha={alpha}/{interp:?}"); + assert_eq!(out.pixel(0, 0).last(), Some(&0.0)); + } + } + } + } + } + + #[test] + fn bilinear_translation_interpolates_signed_hdr_colours() { + let mut s = Surface::new(PixelFormat::RGBA32F); + s.write_pixel(0, 0, &[2.0, -0.5, 0.25, 0.5]); + s.write_pixel(1, 0, &[4.0, -0.25, 0.75, 0.5]); + let h = Homography([1.0, 0.0, 0.5, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + let out = warp_surface(&s, s.content_bounds(), &h, Interp::Bilinear).unwrap(); + assert_eq!(out.pixel(1, 0), vec![3.0, -0.375, 0.5, 0.5]); + } + #[test] fn scale_doubles_size_and_rotation_keeps_area() { let mut s = rgba(); From 6231325aeadb430868f73020c5699cc176e0cfc0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 12 Oct 2026 14:33:28 +0200 Subject: [PATCH 108/523] Brushes: drag a brush folder into another folder (#2014) (#3851) Photoshop's Brushes panel nests folders by dragging one onto another; ours could only reorder a folder among its siblings. - brush.presets.moveGroup takes `into: {group?, folder?}`: the group or nested folder (with everything in it, presets in order) moves inside that folder of that group (`folder` [] = directly in the group), or with no `group` out to the top level as a group of its own. `before` names a sibling folder (or top-level group) there to land before. Moving a folder into itself or one of its own folders, a name clash at the destination, a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index` with `into`, and bad shapes are errors that leave the library unchanged. The preset store follows the move (a group moved into another one loses its group file; a folder moved out gets one). - Brushes panel and Brush Preset picker: a group or folder header dropped on another header goes inside it; on the header's upper third it lands before that folder, reordering siblings or moving a nested folder next to it (out to the top level before a group). Every drop is one moveGroup command (`folder_drop` maps the drop). Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/brush_preset_cmds.rs | 193 +++++++++++++++++++++++- crates/engine/src/preset_store/tests.rs | 34 +++++ crates/ui-egui/src/brush_panel_tests.rs | 84 +++++++++++ crates/ui-egui/src/brushes_tab.rs | 94 +++++++++--- 4 files changed, 382 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/brush_preset_cmds.rs b/crates/engine/src/brush_preset_cmds.rs index 614127f297..03b2814336 100644 --- a/crates/engine/src/brush_preset_cmds.rs +++ b/crates/engine/src/brush_preset_cmds.rs @@ -4,7 +4,9 @@ //! Groups are the panel's top-level folders ([`BrushPreset::group`]); a group can hold nested //! folders ([`BrushPreset::folder`], a path inside the group, e.g. the folders of an imported //! `.abr` file). The group commands take an optional `folder` path to act on a nested folder -//! instead of the whole group. A folder exists while a preset is in it (or below it). +//! instead of the whole group, and `moveGroup` can move a group or folder `into` another folder +//! or group, or out to the top level (dragging a folder onto a folder in Photoshop's panel). A +//! folder exists while a preset is in it (or below it). //! //! The library order is the panel order: groups appear in order of their first preset, and a //! group's folders and presets in order of their first preset too. Every @@ -247,9 +249,116 @@ fn move_folder(s: &mut Session, p: &Value, g: String, f: Vec, cmd: &str) Ok(json!({ "group": g, "folder": f, "index": at })) } +/// Where `into` puts a folder: `Some(None)` = the top level (it becomes a group), `Some(Some((group, +/// folder)))` = inside that folder of that group (`[]`: directly in the group). `None` without `into`. +type Dest = Option<(String, Vec)>; + +fn into_param(s: &Session, p: &Value, cmd: &str) -> Result> { + let into = match p.get("into") { + None | Some(Value::Null) => return Ok(None), + Some(v @ Value::Object(_)) => v, + Some(_) => return Err(bad(cmd, "`into` must be an object {group?, folder?}")), + }; + let Some(g) = group_param(into, "group", cmd)? else { + if folder_param(into, cmd)?.is_some_and(|f| !f.is_empty()) { + return Err(bad(cmd, "`into.folder` needs `into.group`")); + } + return Ok(Some(None)); + }; + group_block(s, &g, cmd)?; + let f = folder_param(into, cmd)?.unwrap_or_default(); + if !s.tools.presets.iter().any(|x| in_folder(x, &g, &f)) { + return Err(bad(cmd, format!("no folder `{}` in brush preset group `{g}`", f.join("/")))); + } + Ok(Some(Some((g, f)))) +} + +/// Move folder `f` of group `g` (the whole group when `f` is empty) into `dest`, keeping its +/// content and the order of its presets: Photoshop's drag of a folder onto another folder. It +/// lands at the end of the destination, or before the sibling folder (group at the top level) +/// named by `before`. +fn move_into(s: &mut Session, p: &Value, g: String, f: Vec, dest: Dest, cmd: &str) -> Result { + if p.get("index").is_some_and(|v| !v.is_null()) { + return Err(bad(cmd, "`index` can't be combined with `into` (use `before`)")); + } + let leaf = match f.last() { + Some(l) => l.clone(), + None if g.is_empty() => return Err(bad(cmd, "the ungrouped presets are not a folder that can be moved")), + None => g.clone(), + }; + let moving = |x: &BrushPreset| in_folder(x, &g, &f); + let rest_of = |x: &BrushPreset| x.folder.get(f.len()..).unwrap_or_default().to_vec(); + let before = match p.get("before") { + None | Some(Value::Null) => None, + Some(Value::String(b)) => Some(b.trim().to_string()), + Some(_) => return Err(bad(cmd, "`before` must be a folder name")), + }; + // The destination's own folders and groups, without the one being moved. + let others: Vec = s.tools.presets.iter().filter(|x| !moving(x)).cloned().collect(); + let (new_group, new_prefix, siblings) = match &dest { + None => { + if f.is_empty() { + return Err(bad(cmd, "`into`: the group is already at the top level")); + } + (leaf.clone(), Vec::new(), group_order(&others)) + } + Some((dg, df)) => { + if *dg == g && df.starts_with(&f) { + return Err(bad(cmd, "`into` names the folder being moved or one of its own folders")); + } + let mut prefix = df.clone(); + prefix.push(leaf.clone()); + (dg.clone(), prefix, subfolder_order(&others, dg, df)) + } + }; + if siblings.contains(&leaf) { + return Err(bad(cmd, format!("a folder named `{leaf}` already exists there"))); + } + if let Some(b) = &before + && !siblings.contains(b) + { + return Err(bad(cmd, format!("`before`: no folder named `{b}` there"))); + } + let deepest = s.tools.presets.iter().filter(|x| moving(x)).map(|x| new_prefix.len() + rest_of(x).len()).max().unwrap_or(0); + if deepest > MAX_FOLDER_DEPTH { + return Err(bad(cmd, format!("folders can be nested at most {MAX_FOLDER_DEPTH} deep"))); + } + let lib = std::mem::take(&mut s.tools.presets); + let (mut block, mut rest): (Vec, Vec) = lib.into_iter().partition(|x| in_folder(x, &g, &f)); + for x in &mut block { + let tail = rest_of(x); + x.group.clone_from(&new_group); + x.folder = new_prefix.iter().cloned().chain(tail).collect(); + x.builtin = false; + } + let pos = match (&dest, &before) { + (None, Some(b)) => rest.iter().position(|x| x.group == *b), + (None, None) => None, + (Some((dg, df)), Some(b)) => { + let sib: Vec = df.iter().cloned().chain(std::iter::once(b.clone())).collect(); + rest.iter().position(|x| in_folder(x, dg, &sib)) + } + (Some((dg, df)), None) => rest.iter().rposition(|x| in_folder(x, dg, df)).map(|k| k + 1), + }; + let tail = rest.split_off(pos.unwrap_or(rest.len()).min(rest.len())); + rest.extend(block); + rest.extend(tail); + s.tools.presets = rest; + s.brush_presets_changed(); + let folder = new_prefix.get(..new_prefix.len().saturating_sub(1)).unwrap_or_default(); + let at = match &dest { + None => group_order(&s.tools.presets).iter().position(|x| *x == new_group), + Some(_) => subfolder_order(&s.tools.presets, &new_group, folder).iter().position(|x| *x == leaf), + }; + Ok(json!({ "group": new_group, "folder": new_prefix, "index": at.unwrap_or(0) })) +} + fn move_group(s: &mut Session, p: &Value) -> Result { let cmd = "brush.presets.moveGroup"; let (g, f) = target_folder(s, p, cmd)?; + if let Some(dest) = into_param(s, p, cmd)? { + return move_into(s, p, g, f, dest, cmd); + } if !f.is_empty() { return move_folder(s, p, g, f, cmd); } @@ -348,7 +457,7 @@ pub fn specs() -> Vec { spec!( "brush.presets.moveGroup", "Move Brush Group", - r##"{"group":string,"folder":[name]? (move this nested folder among its sibling folders instead),"before":group? (or sibling folder name) | "index":n? (group or sibling folder position)=end} → {group, folder?, index}"##, + r##"{"group":string,"folder":[name]? (move this nested folder instead),"into":{"group":string?,"folder":[name]?}? (move it inside that folder of that group, [] = directly in the group; no group = out to the top level as a group of its own; the folder keeps its content),"before":group? (or sibling folder name, in its new place with `into`) | "index":n? (group or sibling folder position, without `into`)=end} → {group, folder?, index}"##, move_group ), spec!( @@ -572,4 +681,84 @@ mod tests { assert_eq!(s.tools.presets, before); s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "index": u64::MAX})).unwrap(); } + + fn paths(s: &Session) -> Vec { + s.tools.presets.iter().filter(|x| !x.builtin).map(|x| format!("{}:{}:{}", x.group, x.folder.join("/"), x.name)).collect() + } + + /// `with_folders` plus group "Other" holding O1, and folder Box (B1) in it. + fn with_two_groups() -> Session { + let mut s = with_folders(); + for (n, f) in [("O1", &[][..]), ("B1", &["Box"][..])] { + let folder = f.iter().map(|x| x.to_string()).collect(); + s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: "Other".into(), folder }); + } + s + } + + #[test] + fn folders_move_into_folders_and_groups() { + let mut s = with_two_groups(); + // A nested folder into a sibling folder: its subtree follows, order kept. + let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}})).unwrap(); + assert_eq!(r, json!({"group": "Set", "folder": ["Ink", "Fine", "Dry"], "index": 0})); + assert_eq!(folder_of(&s, "D1"), ["Ink", "Fine", "Dry"]); + // Into another group, before one of its folders. + let r = s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Other"}, "before": "Box"})).unwrap(); + assert_eq!(r["folder"], json!(["Ink"])); + assert_eq!(paths(&s), ["Set::S0", "Other::O1", "Other:Ink:I1", "Other:Ink/Fine:F1", "Other:Ink/Fine/Dry:D1", "Other:Ink:I2", "Other:Box:B1"]); + // Out to the top level: the folder becomes a group of its own. + let r = s.execute("brush.presets.moveGroup", json!({"group": "Other", "folder": ["Ink", "Fine"], "into": {}})).unwrap(); + assert_eq!(r["group"], "Fine"); + assert_eq!(folder_of(&s, "D1"), ["Dry"]); + assert_eq!(photocraft_paint::presets::find(&s.tools.presets, "F1").unwrap().group, "Fine"); + // A whole group into another group's folder. + s.execute("brush.presets.moveGroup", json!({"group": "Fine", "into": {"group": "Set"}})).unwrap(); + assert!(names(&s, "Fine").is_empty()); + assert_eq!(folder_of(&s, "D1"), ["Fine", "Dry"]); + assert_eq!(subfolder_order(&s.tools.presets, "Set", &[]), ["Fine"]); + } + + #[test] + fn bad_moves_into_folders_fail_without_changes() { + let mut s = with_two_groups(); + // A chain 31 deep under Set/Deep. + let deep: Vec = std::iter::once("Deep".to_string()).chain((0..30).map(|i| format!("d{i}"))).collect(); + s.tools.presets.push(BrushPreset { name: "Z".into(), brush: Default::default(), builtin: false, group: "Set".into(), folder: deep.clone() }); + s.tools.presets.push(BrushPreset { name: "Y".into(), brush: Default::default(), builtin: false, group: "Other".into(), folder: vec!["Dry".into()] }); + let before = s.tools.presets.clone(); + let deep_json = json!(deep); + for p in [ + // Into itself or its own descendant. + json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink"]}}), + json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}), + json!({"group": "Set", "into": {"group": "Set", "folder": ["Dry"]}}), + json!({"group": "Set", "into": {"group": "Set"}}), + // A group already at the top level; the ungrouped presets. + json!({"group": "Set", "into": {}}), + // A name clash at the destination. + json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}}), + json!({"group": "Other", "folder": ["Box"], "into": {"group": "Set", "folder": ["Ink"]}, "before": "Nope"}), + json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Set", "folder": ["Ink"]}, "index": 0}), + // Missing destinations and bad shapes. + json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Nope"}}), + json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other", "folder": ["Nope"]}}), + json!({"group": "Set", "folder": ["Dry"], "into": {"folder": ["Box"]}}), + json!({"group": "Set", "folder": ["Dry"], "into": "Other"}), + json!({"group": "Set", "folder": ["Dry"], "into": {"group": 5}}), + json!({"group": "Set", "folder": ["Dry"], "into": {"group": "Other"}, "before": 3}), + // Deeper than MAX_FOLDER_DEPTH once moved (Ink/Fine is 2 deep: 2 + 31 > 32). + json!({"group": "Set", "folder": ["Deep"], "into": {"group": "Set", "folder": ["Ink", "Fine"]}}), + json!({"group": "Set", "folder": ["Ink"], "into": {"group": "Set", "folder": deep_json}}), + ] { + assert!(s.execute("brush.presets.moveGroup", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.tools.presets, before); + // Ink/Fine into Set itself: a clash with nothing, so it moves up a level. + s.execute("brush.presets.moveGroup", json!({"group": "Set", "folder": ["Ink", "Fine"], "into": {"group": "Set"}})).unwrap(); + assert_eq!(folder_of(&s, "F1"), ["Fine"]); + // An ungrouped preset's group can't move. + s.tools.presets.push(BrushPreset { name: "U".into(), brush: Default::default(), builtin: false, group: String::new(), folder: Vec::new() }); + assert!(s.execute("brush.presets.moveGroup", json!({"group": "", "into": {"group": "Set"}})).is_err()); + } } diff --git a/crates/engine/src/preset_store/tests.rs b/crates/engine/src/preset_store/tests.rs index b7f175867c..513bc56c15 100644 --- a/crates/engine/src/preset_store/tests.rs +++ b/crates/engine/src/preset_store/tests.rs @@ -624,6 +624,40 @@ fn imported_abr_folders_persist_and_old_stores_load_flat() { assert!(photocraft_paint::presets::find(&d.tools.presets, "Blank").unwrap().folder.is_empty()); } +/// Moving a group into another group's folder rewrites the target group's file and removes the +/// moved group's; moving a folder out to the top level makes a group file for it (#2014). +#[test] +fn folders_moved_into_other_folders_persist() { + let dir = TempDir::new("folder-into"); + let (mut s, _) = session(&dir); + let set = abr_with_folders(&[("Kit Loose", &[]), ("Kit Pen", &["Inks"]), ("Kit Nib", &["Inks", "Fine"])]); + let other = abr_with_folders(&[("Pack Chalk", &["Dry"])]); + s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&set), "group": "Kit"})).unwrap(); + s.execute("brush.presets.importAbr", json!({"data": photocraft_paint::tile::b64_encode(&other), "group": "Pack"})).unwrap(); + let group_files = |d: &TempDir| -> Vec { d.files().into_iter().filter(|f| f.ends_with(".pcbrushes")).collect() }; + let mut both = vec![group_file_name("Kit"), group_file_name("Pack")]; + both.sort(); + assert_eq!(group_files(&dir), both); + s.execute("brush.presets.moveGroup", json!({"group": "Pack", "into": {"group": "Kit", "folder": ["Inks"]}})).unwrap(); + assert_eq!(group_files(&dir), [group_file_name("Kit")]); + let (t, w) = session(&dir); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(user(&t), user(&s)); + let folder = |s: &Session, n: &str| { + let p = photocraft_paint::presets::find(&s.tools.presets, n).unwrap(); + format!("{}:{}", p.group, p.folder.join("/")) + }; + assert_eq!(folder(&t, "Pack Chalk"), "Kit:Inks/Pack/Dry"); + // Out to the top level: a group (and file) of its own. + s.execute("brush.presets.moveGroup", json!({"group": "Kit", "folder": ["Inks", "Pack"], "into": {}})).unwrap(); + let mut both = vec![group_file_name("Kit"), group_file_name("Pack")]; + both.sort(); + assert_eq!(group_files(&dir), both); + let (t, _) = session(&dir); + assert_eq!(folder(&t, "Pack Chalk"), "Pack:Dry"); + assert_eq!(folder(&t, "Kit Nib"), "Kit:Inks/Fine"); +} + #[test] fn a_group_file_that_failed_to_load_is_not_overwritten() { // e.g. written by a newer PhotoCraft: skipped on load, so a save must not replace it. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index c61b48c1be..7d5a51e4fb 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -635,6 +635,90 @@ fn folder_actions_become_folder_commands() { assert_eq!(last_journal(&app).unwrap(), ("brush.presets.moveGroup".to_string(), json!({ "group": "Kit", "folder": ["Dry"], "before": "Inks" }))); } +/// Dragging a folder or group header onto another header (#2014): the upper edge places it +/// before, the rest puts it inside; the action runs `brush.presets.moveGroup` with `into`. +#[test] +fn folder_drops_map_to_move_commands() { + use crate::brushes_tab::{Action, apply, folder_drop}; + let v = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::>(); + // Into: a folder onto another folder, a group onto a group. + assert_eq!( + folder_drop("Kit", &v(&["Dry"]), "Kit", &v(&["Inks"]), false), + Some(Action::MoveInto { group: "Kit".into(), folder: v(&["Dry"]), into: Some(("Kit".into(), v(&["Inks"]))), before: None }) + ); + assert_eq!( + folder_drop("Pack", &[], "Kit", &v(&["Inks"]), false), + Some(Action::MoveInto { group: "Pack".into(), folder: vec![], into: Some(("Kit".into(), v(&["Inks"]))), before: None }) + ); + // Onto itself, into its own folder, or into the folder it is already in: nothing. + assert_eq!(folder_drop("Kit", &v(&["Inks"]), "Kit", &v(&["Inks"]), false), None); + assert_eq!(folder_drop("Kit", &v(&["Inks"]), "Kit", &v(&["Inks", "Fine"]), true), None); + assert_eq!(folder_drop("Kit", &[], "Kit", &v(&["Inks"]), false), None); + assert_eq!(folder_drop("Kit", &v(&["Inks", "Fine"]), "Kit", &v(&["Inks"]), false), None); + // Upper edge: siblings reorder; a nested folder before a group goes to the top level; a + // folder before a folder elsewhere moves next to it. + assert_eq!( + folder_drop("Kit", &v(&["Dry"]), "Kit", &v(&["Inks"]), true), + Some(Action::MoveGroup { group: "Kit".into(), folder: v(&["Dry"]), before: Some("Inks".into()) }) + ); + assert_eq!(folder_drop("Pack", &[], "Kit", &[], true), Some(Action::MoveGroup { group: "Pack".into(), folder: vec![], before: Some("Kit".into()) })); + assert_eq!( + folder_drop("Kit", &v(&["Inks", "Fine"]), "Pack", &[], true), + Some(Action::MoveInto { group: "Kit".into(), folder: v(&["Inks", "Fine"]), into: None, before: Some("Pack".into()) }) + ); + assert_eq!( + folder_drop("Kit", &v(&["Inks", "Fine"]), "Pack", &v(&["Box"]), true), + Some(Action::MoveInto { group: "Kit".into(), folder: v(&["Inks", "Fine"]), into: Some(("Pack".into(), vec![])), before: Some("Box".into()) }) + ); + + let mut app = app(); + for p in [kit("Kit Pen", &["Inks"]), kit("Kit Nib", &["Inks", "Fine"]), kit("Kit Chalk", &["Dry"])] { + app.session.tools.presets.push(p); + } + let into = folder_drop("Kit", &v(&["Dry"]), "Kit", &v(&["Inks", "Fine"]), false).unwrap(); + apply(&mut app, vec![into]); + assert_eq!(paint::presets::find(&app.session.tools.presets, "Kit Chalk").unwrap().folder, ["Inks", "Fine", "Dry"]); + assert_eq!( + last_journal(&app).unwrap(), + ("brush.presets.moveGroup".to_string(), json!({ "group": "Kit", "folder": ["Dry"], "into": {"group": "Kit", "folder": ["Inks", "Fine"]} })) + ); + // Out to the top level, before a group. + let out = folder_drop("Kit", &v(&["Inks", "Fine"]), "Kit", &[], true).unwrap(); + apply(&mut app, vec![out]); + let chalk = paint::presets::find(&app.session.tools.presets, "Kit Chalk").unwrap(); + assert_eq!((chalk.group.as_str(), chalk.folder.clone()), ("Fine", v(&["Dry"]))); + let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets); + assert!(order.iter().position(|g| g == "Fine") < order.iter().position(|g| g == "Kit")); +} + +/// A folder header dragged onto another folder's header in the Brushes panel goes inside it. +#[test] +fn dragging_a_folder_header_onto_another_moves_it_inside() { + use egui_kittest::kittest::Queryable; + let mut h = harness(1, 0); + let builtin_groups: Vec = grouped_presets(&h.state().session.tools.presets).into_iter().map(|(g, _)| g).collect(); + h.state_mut().ui.brushes_panel.collapsed = builtin_groups; + for p in [kit("Kit Pen", &["Test Inks"]), kit("Kit Chalk", &["Test Dry"])] { + h.state_mut().session.tools.presets.push(p); + } + h.run_steps(3); + let from = h.get_by_label("Test Dry").rect().center(); + let to = h.get_by_label("Test Inks").rect(); + let to = egui::pos2(to.center().x, to.top() + to.height() * 0.7); + h.hover_at(from); + h.run(); + h.drag_at(from); + h.run(); + for k in 1..=6 { + h.hover_at(from + (to - from) * (k as f32 / 6.0)); + h.run(); + } + h.drop_at(to); + h.run_steps(3); + assert_eq!(paint::presets::find(&h.state().session.tools.presets, "Kit Chalk").unwrap().folder, ["Test Inks", "Test Dry"]); + assert_eq!(last_journal(h.state()).unwrap().0, "brush.presets.moveGroup"); +} + /// A fresh session with the Brush tool: shortcuts, the options bar and the Brush Settings window, /// in the default theme (#258). fn app_harness() -> Harness<'static, PhotocraftApp> { diff --git a/crates/ui-egui/src/brushes_tab.rs b/crates/ui-egui/src/brushes_tab.rs index b6d96f6bd2..31c1161b94 100644 --- a/crates/ui-egui/src/brushes_tab.rs +++ b/crates/ui-egui/src/brushes_tab.rs @@ -2,9 +2,10 @@ //! preview, name) or a grid of tips, with a size slider and a search field. //! //! Presets and groups are organised like Photoshop's panel: drag a preset within its group or -//! into another group or folder (onto a preset, or onto a header to append), drag a group's -//! header to reorder groups (a nested folder's header to reorder it among its sibling folders), -//! and right-click for Rename and Delete. Groups hold nested folders ([`BrushPreset::folder`], +//! into another group or folder (onto a preset, or onto a header to append), drag a group's or +//! folder's header onto another header to put it inside that folder (or onto the header's upper +//! edge to place it before, which reorders siblings and moves a folder out to the top level), and +//! right-click for Rename and Delete. Groups hold nested folders ([`BrushPreset::folder`], //! e.g. an imported `.abr` file's own folders), drawn indented under their group and opened or //! closed like groups. Every change is a `brush.presets.*` command, so it is journaled, drivable, //! and persisted by the preset store. @@ -59,6 +60,15 @@ pub enum Action { folder: Vec, before: Option, }, + /// Move a group (`folder` empty) or nested folder into another folder: `into` is (group, + /// folder path, `[]` = directly in the group), or `None` for the top level (it becomes a + /// group). `before` is a folder (or top-level group) there to land before. + MoveInto { + group: String, + folder: Vec, + into: Option<(String, Vec)>, + before: Option, + }, /// View only: show the rename bar for a preset or group ([`preset_list`] applies it). BeginRename(Renaming), /// Commit a rename typed in the rename bar. @@ -238,6 +248,20 @@ pub fn apply(app: &mut PhotocraftApp, acts: Vec) { } run_or_status(app, "brush.presets.moveGroup", p); } + Action::MoveInto { group, folder, into, before } => { + let into = match into { + Some((g, f)) => json!({ "group": g, "folder": f }), + None => json!({}), + }; + let mut p = json!({ "group": group, "into": into }); + if !folder.is_empty() { + p["folder"] = json!(folder); + } + if let Some(b) = before { + p["before"] = json!(b); + } + run_or_status(app, "brush.presets.moveGroup", p); + } Action::Rename(r) => { let text = r.text.trim().to_string(); let old = r.folder.last().unwrap_or(&r.name); @@ -582,9 +606,41 @@ fn preset_card( /// Indent of one nested folder level. const FOLDER_INDENT: f32 = 16.0; -/// A group's or nested folder's header: chevron, folder, name, count. Click toggles; drag -/// reorders groups (a folder among its sibling folders); a preset dropped here moves to the end of -/// the group or folder. `key` is the group key, `folder` the nested folder's path (empty for the +/// What dropping the group or folder (`src_group`, `src_folder`; a group when `src_folder` is +/// empty) on the header of (`group`, `folder`) does, as in Photoshop's Brushes panel: on the +/// header's upper edge (`edge`) it lands before that folder, next to it (reordering when they are +/// siblings); anywhere else it goes into that folder. `None` when the drop does nothing: onto +/// itself, into one of its own folders, or into the folder it is already in. +pub fn folder_drop(src_group: &str, src_folder: &[String], group: &str, folder: &[String], edge: bool) -> Option { + if src_group == group && folder.starts_with(src_folder) { + return None; + } + let src = (src_group.to_string(), src_folder.to_vec()); + if !edge { + let parent = src_folder.split_last().map(|(_, p)| p); + if src_group == group && parent == Some(folder) { + return None; + } + return Some(Action::MoveInto { group: src.0, folder: src.1, into: Some((group.to_string(), folder.to_vec())), before: None }); + } + match folder.split_last() { + // Before a top-level group: groups reorder; a nested folder moves out to the top level. + None if src_folder.is_empty() => Some(Action::MoveGroup { group: src.0, folder: src.1, before: Some(group.to_string()) }), + None => Some(Action::MoveInto { group: src.0, folder: src.1, into: None, before: Some(group.to_string()) }), + Some((leaf, parent)) => { + let sibling = src_group == group && src_folder.split_last().map(|(_, p)| p) == Some(parent); + if sibling { + Some(Action::MoveGroup { group: src.0, folder: src.1, before: Some(leaf.clone()) }) + } else { + Some(Action::MoveInto { group: src.0, folder: src.1, into: Some((group.to_string(), parent.to_vec())), before: Some(leaf.clone()) }) + } + } + } +} + +/// A group's or nested folder's header: chevron, folder, name, count. Click toggles; a group or +/// folder dragged here lands before it (its upper edge) or goes into it ([`folder_drop`]); a +/// preset dropped here moves to the end of the group or folder. `key` is the group key, `folder` the nested folder's path (empty for the /// group), `label` the group's panel label. fn group_header(ui: &mut egui::Ui, label: &str, key: &str, folder: &[String], open: bool, count: usize, acts: &mut Vec) { let t = Tokens::get(ui.ctx()); @@ -616,31 +672,27 @@ fn group_header(ui: &mut egui::Ui, label: &str, key: &str, folder: &[String], op [] => BrushDrag::Group(key.to_string()), f => BrushDrag::Folder { group: key.to_string(), folder: f.to_vec() }, }; - // A folder moves among its siblings: same group, same parent, not itself. - let sibling = |g: &str, f: &[String]| { - g == key && !folder.is_empty() && f.len() == folder.len() && f != folder && f.split_last().map(|x| x.1) == folder.split_last().map(|x| x.1) - }; resp.dnd_set_drag_payload(me); + // The upper third of the header places a dragged folder before this one; the rest drops it in. + let edge = ui.ctx().pointer_interact_pos().is_some_and(|p| p.y < r.top() + r.height() / 3.0); + let folder_action = |d: &BrushDrag| match d { + BrushDrag::Group(g) => folder_drop(g, &[], key, folder, edge), + BrushDrag::Folder { group, folder: f } => folder_drop(group, f, key, folder, edge), + BrushDrag::Preset(_) => None, + }; if let Some(d) = resp.dnd_hover_payload::() { - match &*d { - BrushDrag::Group(g) if folder.is_empty() && g != key => widgets::drop_line(ui, r, false, false, &t), - BrushDrag::Folder { group, folder: f } if sibling(group, f) => widgets::drop_line(ui, r, false, false, &t), - BrushDrag::Preset(_) => { + match (&*d, folder_action(&d)) { + (BrushDrag::Preset(_), _) | (_, Some(Action::MoveInto { before: None, .. })) => { ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } + (_, Some(_)) => widgets::drop_line(ui, r, false, false, &t), _ => {} } } if let Some(d) = resp.dnd_release_payload::() { match &*d { - BrushDrag::Group(g) if folder.is_empty() && g != key => { - acts.push(Action::MoveGroup { group: g.clone(), folder: Vec::new(), before: Some(key.to_string()) }) - } - BrushDrag::Folder { group, folder: f } if sibling(group, f) => { - acts.push(Action::MoveGroup { group: group.clone(), folder: f.clone(), before: Some(shown.to_string()) }) - } BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), folder: Some(folder.to_vec()), index: None }), - _ => {} + other => acts.extend(folder_action(other)), } } if resp.clicked() { From 25c0ec78d5748d3f22de2628f9f5e0d63bc43587 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 14:33:50 +0200 Subject: [PATCH 109/523] Keep CMYK ink values when flattening and merging (#3855) Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of plain Normal raster layers in its ink values, so a lone Background flattens bit-exact and 16-bit depth is kept. Stacks that still need the RGB compositor separate back through the document's own CMYK profile instead of the built-in one, and Merge Down's scratch document keeps the profile. Fixes #3778 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/compose/src/lib.rs | 27 +++- crates/compose/src/native.rs | 101 ++++++++++++++ crates/engine/src/commands.rs | 2 +- crates/engine/src/flatten_cmyk_tests.rs | 169 ++++++++++++++++++++++++ crates/engine/src/pixels.rs | 37 ++---- 5 files changed, 305 insertions(+), 31 deletions(-) create mode 100644 crates/compose/src/native.rs create mode 100644 crates/engine/src/flatten_cmyk_tests.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 01087f4937..c059d9c8b2 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod fill_layout; pub mod gradient_fill; pub mod masks; pub mod multichannel; +pub mod native; pub mod pattern; pub mod proxy; pub mod psblend; @@ -232,20 +233,38 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 { /// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model /// and depth), optionally flattened over an opaque `background`; rendered and written in bands, /// so no full-size float composite is held. Tiles equal to the default pixel are pruned. +/// A CMYK document of plain Normal layers is composited in its ink values instead +/// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778). pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { + composite_to_surface(doc, doc.bounds(), fmt, background) +} + +/// [`flatten_to_surface`] over `rect` only (the rest of the surface stays empty). +pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { + let paper = background.is_some_and(|bg| bg == [1.0; 3]); + if (background.is_none() || paper) + && let Some(s) = native::flatten_cmyk(doc, rect, fmt, paper) + { + return s; + } let mut s = Surface::new(fmt); let n = fmt.channels(); let mut data = Vec::new(); - let _ = render_bands(doc, doc.bounds(), 0, |band| -> Result<(), ()> { + // The composite separates back to CMYK through the document's own profile, the one its + // pixels were read through. + let cmyk = cmyk_space(doc); + let _ = render_bands(doc, rect, 0, |band| -> Result<(), ()> { let band = match background { Some(bg) => band.over_background(bg), None => band, }; data.clear(); data.resize(band.px.len() * n, 0.0); - for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { - photocraft_raster::from_rgba_into(&fmt, *p, out); - } + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { + photocraft_raster::from_rgba_into(&fmt, *p, out); + } + }); s.write_region(band.rect, &data); Ok(()) }); diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs new file mode 100644 index 0000000000..9eafd18cc6 --- /dev/null +++ b/crates/compose/src/native.rs @@ -0,0 +1,101 @@ +//! Mode-native flattening of plain CMYK layer stacks (#3778). +//! +//! The compositor works in display RGB, so flattening a CMYK document through it reads every ink +//! value through the profile and separates it again: even a lone Background comes back with +//! different ink. When every visible layer is a plain raster layer in Normal mode (no masks, +//! effects, clipping or advanced blending), the composite needs no colour conversion at all: +//! Normal mixes ink values linearly, as Photoshop does in CMYK, so it is worked out on the stored +//! values and an opaque layer keeps them exactly. + +use photocraft_color::blend::BlendMode; +use photocraft_color::{ColorMode, PixelFormat}; +use photocraft_doc::{Document, Layer, LayerContent}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +/// Process channels of a CMYK pixel. +const INKS: usize = 4; + +/// A layer the native path can composite: a visible raster layer the compositor would only blend +/// Normally at its opacity. +fn plain(layer: &Layer) -> Option<&Surface> { + let LayerContent::Raster(s) = &layer.content else { return None }; + let simple = layer.blend == BlendMode::Normal + && !layer.clipped + && layer.mask.as_ref().is_none_or(|m| !m.enabled) + && layer.vector_mask.as_ref().is_none_or(|v| !v.enabled) + && !crate::effects::has_effects(layer) + && layer.excluded_channels == 0 + && layer.blend_if.is_default() + && layer.advanced.is_default() + && layer.video.is_none() + && s.format().mode == ColorMode::Cmyk; + simple.then_some(s) +} + +/// The composite of `doc` over `rect` in `fmt`, worked out in ink values, or `None` when the +/// document isn't CMYK or a visible layer needs the full compositor (see the module docs). +/// `paper` flattens onto white paper (no ink) and makes the result opaque. +pub fn flatten_cmyk(doc: &Document, rect: Rect, fmt: PixelFormat, paper: bool) -> Option { + if doc.mode != ColorMode::Cmyk || fmt.mode != ColorMode::Cmyk || doc.layers.iter().any(|l| l.clipped) { + return None; + } + let mut stack = Vec::new(); + for layer in doc.layers.iter().filter(|l| l.visible) { + let alpha = (layer.opacity * layer.fill_opacity).clamp(0.0, 1.0); + stack.push((plain(layer)?, alpha)); + } + let mut out = Surface::new(fmt); + if rect.is_empty() { + return Some(out); + } + let n = fmt.channels(); + let w = rect.width() as usize; + let rows = crate::band_rows_for(rect.width(), 0); + let (mut acc, mut src, mut data) = (Vec::new(), Vec::new(), Vec::new()); + let mut y = rect.y0; + while y < rect.y1 { + let band = Rect::new(rect.x0, y, rect.x1, y.saturating_add(rows).min(rect.y1)); + // Straight C, M, Y, K, alpha per pixel, starting transparent. + acc.clear(); + acc.resize(w * band.height() as usize, [0.0f32; INKS + 1]); + for &(surf, opacity) in &stack { + if opacity <= 0.0 || (surf.content_bounds().intersect(&band).is_empty() && surf.default_pixel().iter().all(|v| *v == 0.0)) { + continue; + } + let sf = surf.format(); + let sn = sf.channels(); + surf.read_region_into(band, &mut src); + for (d, s) in acc.iter_mut().zip(src.chunks_exact(sn)) { + let sa = if sf.alpha { s[INKS] } else { 1.0 } * opacity; + if sa <= 0.0 { + continue; + } + let oa = sa + d[INKS] * (1.0 - sa); + let keep = d[INKS] * (1.0 - sa); + for c in 0..INKS { + // An opaque pixel replaces what is below exactly (no rounding through the mix). + d[c] = if sa >= 1.0 { s[c] } else { (s[c] * sa + d[c] * keep) / oa }; + } + d[INKS] = oa; + } + } + data.clear(); + data.resize(acc.len() * n, 0.0); + for (p, o) in acc.iter().zip(data.chunks_exact_mut(n)) { + // White paper carries no ink, so flattening onto it scales the ink by coverage. + let a = p[INKS]; + let k = if paper && a < 1.0 { a } else { 1.0 }; + for c in 0..INKS { + o[c] = p[c] * k; + } + if fmt.alpha { + o[INKS] = if paper { 1.0 } else { a }; + } + } + out.write_region(band, &data); + y = band.y1; + } + out.prune(); + Some(out) +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 48caf85b28..a47bde9fff 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -774,7 +774,7 @@ fn build() -> Vec { below.push(idx - 1); let lower = doc.layer_at(&below).ok_or(EngineError::NoLayer(id))?.clone(); let upper = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone(); - let merged = pixels::merge_down(doc.bounds(), &lower, &upper, doc.pixel_format()); + let merged = pixels::merge_down(doc, &lower, &upper); doc.remove(id); *doc.layer_at_mut(&below).ok_or(EngineError::NoLayer(lower.id))? = merged; *active = Some(lower.id); diff --git a/crates/engine/src/flatten_cmyk_tests.rs b/crates/engine/src/flatten_cmyk_tests.rs new file mode 100644 index 0000000000..4fd187ceed --- /dev/null +++ b/crates/engine/src/flatten_cmyk_tests.rs @@ -0,0 +1,169 @@ +//! #3778: Flatten Image, Merge Visible and Merge Down keep a CMYK document's ink values instead +//! of round-tripping them through RGB. + +use photocraft_cms::synth::{CmykParams, cmyk_profile}; +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::Session; + +const W: i32 = 16; +const H: i32 = 8; + +fn canvas() -> Rect { + Rect::new(0, 0, W, H) +} + +/// Ink values that vary per pixel and per channel, away from the 8-bit grid. +fn ink(x: i32, y: i32) -> [f32; 4] { + let f = |k: i32| ((x * 7 + y * 13 + k * 29) % 97) as f32 / 96.0 * 0.9 + 0.0371; + [f(0), f(1), f(2), f(3)] +} + +/// A 16-bit CMYK document tagged with a synthetic profile (not the built-in coated CMYK), holding +/// just an opaque Background of varied ink. +fn cmyk_doc() -> Document { + let mut d = Document::new("cmyk", Size::new(W as u32, H as u32), ColorMode::Cmyk, SampleType::U16); + let profile = + cmyk_profile(&CmykParams { description: "Flatten Test CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }); + d.icc_profile = Some(profile.to_bytes()); + let mut bg = Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, false)); + for y in 0..H { + for x in 0..W { + bg.write_pixel(x, y, &ink(x, y)); + } + } + let mut layer = Layer::new("Background", LayerContent::Raster(bg)); + layer.locks.transparency = true; + layer.locks.position = true; + d.layers.push(layer); + d +} + +/// A layer above the Background: solid ink on the left half, half-transparent ink on a strip, +/// nothing on the rest. +fn upper_layer() -> Layer { + let mut s = Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true)); + s.fill_rect(Rect::new(0, 0, W / 2, H), &[0.8123, 0.1234, 0.4567, 0.0789, 1.0]); + s.fill_rect(Rect::new(W / 2, 0, W / 2 + 2, H), &[0.25, 0.5, 0.75, 0.1, 0.5]); + Layer::new("Layer 1", LayerContent::Raster(s)) +} + +fn session(doc: Document) -> Session { + let mut s = Session::new(); + s.add_document(doc, None); + s +} + +/// The bottom layer's ink values (C, M, Y, K per pixel, without any alpha channel). +fn pixels(s: &Session) -> Vec { + let surf = s.active().unwrap().doc.layers[0].surface().unwrap(); + let n = surf.channels(); + surf.read_region(canvas()).chunks_exact(n).flat_map(|p| p[..4].to_vec()).collect() +} + +fn bottom_format(s: &Session) -> PixelFormat { + s.active().unwrap().doc.layers[0].surface().unwrap().format() +} + +#[test] +fn flattening_a_lone_cmyk_background_keeps_its_pixels() { + let doc = cmyk_doc(); + let before = doc.layers[0].surface().unwrap().read_region(canvas()); + let mut s = session(doc); + s.execute("layer.flattenImage", json!({})).unwrap(); + let fmt = bottom_format(&s); + assert_eq!((fmt.mode, fmt.sample), (ColorMode::Cmyk, SampleType::U16), "16-bit CMYK stays 16-bit CMYK"); + let moved = pixels(&s).iter().zip(&before).filter(|(a, b)| a != b).count(); + assert_eq!(moved, 0, "a single Background must flatten to the same ink values"); +} + +/// What a Normal 100 % layer over the Background gives, worked out in ink values: the solid part +/// replaces the ink, the half-transparent strip mixes half and half, the rest shows the Background. +fn expected_two_layers() -> Vec { + let mut out = Vec::new(); + for y in 0..H { + for x in 0..W { + let below = ink(x, y); + let px = if x < W / 2 { + [0.8123, 0.1234, 0.4567, 0.0789] + } else if x < W / 2 + 2 { + let top = [0.25, 0.5, 0.75, 0.1]; + [0, 1, 2, 3].map(|c| top[c] * 0.5 + below[c] * 0.5) + } else { + below + }; + out.extend_from_slice(&px); + } + } + out +} + +fn assert_close(got: &[f32], want: &[f32], what: &str) { + assert_eq!(got.len(), want.len(), "{what}: pixel count"); + // One 16-bit code (the stored value is quantised to 1/65535). + let worst = got.iter().zip(want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(worst <= 1.0 / 65535.0 + 1e-6, "{what}: ink values moved by up to {worst}"); +} + +#[test] +fn flattening_normal_cmyk_layers_composites_in_ink_values() { + let mut doc = cmyk_doc(); + doc.layers.push(upper_layer()); + let mut s = session(doc); + s.execute("layer.flattenImage", json!({})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1); + assert_eq!(bottom_format(&s).sample, SampleType::U16); + assert_close(&pixels(&s), &expected_two_layers(), "Flatten Image"); +} + +#[test] +fn merge_visible_and_merge_down_keep_cmyk_ink_values() { + for cmd in ["layer.mergeVisible", "layer.mergeDown"] { + let mut doc = cmyk_doc(); + let upper = upper_layer(); + let id = upper.id; + doc.layers.push(upper); + let mut s = session(doc); + s.execute(cmd, json!({"layer": id.0})).unwrap(); + assert_eq!(s.active().unwrap().doc.layers.len(), 1, "{cmd}"); + assert_eq!(bottom_format(&s).sample, SampleType::U16, "{cmd}"); + assert_close(&pixels(&s), &expected_two_layers(), cmd); + } +} + +/// A stack the ink-value path can't take (a Multiply layer) still goes through the compositor, +/// but separates back through the document's profile, the one it read the ink through, so the +/// round trip lands near the original ink instead of on the built-in profile's separation. +#[test] +fn compositor_flatten_separates_through_the_documents_profile() { + let mut doc = cmyk_doc(); + // Light inks, inside the printable gamut, so a separation can give them back. + let bg = doc.layers[0].surface_mut().unwrap(); + for y in 0..H { + for x in 0..W { + bg.write_pixel(x, y, &ink(x, y).map(|v| v * 0.4)); + } + } + let before = doc.layers[0].surface().unwrap().read_region(canvas()); + let mut empty = Layer::new("Layer 1", LayerContent::Raster(Surface::new(PixelFormat::new(ColorMode::Cmyk, SampleType::U16, true)))); + empty.blend = photocraft_color::blend::BlendMode::Multiply; + doc.layers.push(empty); + let mut s = session(doc); + s.execute("layer.flattenImage", json!({})).unwrap(); + // Compare what the inks print as (a CMYK colour has many separations): RGB through the profile. + let d = &s.active().unwrap().doc; + let space = photocraft_compose::cmyk_space(d); + let shown = |v: &[f32]| -> Vec<[f32; 3]> { + photocraft_color::convert::with_cmyk_space(space.as_ref(), || v.as_chunks::<4>().0.iter().map(|p| photocraft_color::convert::cmyk_to_rgb(*p)).collect()) + }; + let (a, b) = (shown(&pixels(&s)), shown(&before)); + let diffs: Vec = a.iter().zip(&b).flat_map(|(p, q)| (0..3).map(move |c| (p[c] - q[c]).abs())).collect(); + let mean = diffs.iter().sum::() / diffs.len() as f32; + // The synthetic profile's small tables don't round-trip exactly; the built-in profile's + // separation lands about 0.08 off on average. + assert!(mean < 0.03, "flattening shifted the printed colour by {mean} on average"); +} diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 2ba5b5dcfd..b19a1b270f 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -21,32 +21,11 @@ pub fn composite_layers(solo: &Document, format: photocraft_color::PixelFormat, .reduce(|a, b| a.union(&b)) .unwrap_or(Rect::EMPTY) .intersect(&canvas); - let mut surface = Surface::new(format); if area.is_empty() { - return surface; + return Surface::new(format); } - - let channels = format.channels(); - let mut data = Vec::new(); - let _ = photocraft_compose::render_bands(solo, area, 0, |mut band| -> Result<(), ()> { - if let Some(color) = background { - for pixel in &mut band.px { - let alpha = pixel[3]; - for channel in 0..3 { - pixel[channel] = pixel[channel] * alpha + color[channel] * (1.0 - alpha); - } - pixel[3] = 1.0; - } - } - data.resize(band.px.len() * channels, 0.0); - for (pixel, encoded) in band.px.iter().zip(data.chunks_exact_mut(channels)) { - photocraft_raster::from_rgba_into(&format, *pixel, encoded); - } - surface.write_region(band.rect, &data); - Ok(()) - }); - surface.prune(); - surface + // Also keeps a plain CMYK stack in its ink values (#3778). + photocraft_compose::composite_to_surface(solo, area, format, background) } /// Apply an adjustment destructively to a surface, weighted by an optional selection. @@ -77,6 +56,9 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf s.put_tiles(done); } +#[cfg(test)] +#[path = "flatten_cmyk_tests.rs"] +mod flatten_cmyk_tests; #[cfg(test)] #[path = "pixels_tests.rs"] mod tests; @@ -296,9 +278,12 @@ pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surfa } /// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down). -pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer { +pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer { + let format = doc.pixel_format(); let stack = vec![lower.clone(), upper.clone()]; - let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample); + let mut tmp = Document::new("merge", doc.size, format.mode, format.sample); + // The pair is read (and a CMYK one separated again) through the document's own profile. + tmp.icc_profile = doc.icc_profile.clone(); tmp.layers = stack; let s = composite_layers(&tmp, format, None); let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s)); From b0554b8178dfbe8b1c7121bb89d8c0b2873bc97a Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 20:34:22 +0800 Subject: [PATCH 110/523] Type: wire missingGlyphProtection (script fallback toggle) (#3863) * text: missing glyph protection can turn the script fallback stack off (#1909) * engine: apply type.missingGlyphProtection to the live text engine * docs: scorecard, settings that do nothing 30 -> 29 of 154 --- crates/engine/src/prefs.rs | 17 ++++++++++++++--- crates/text/src/fonts.rs | 18 +++++++++++++++++- crates/text/src/tests.rs | 19 +++++++++++++++++++ docs/scorecard.md | 6 +++--- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index c99c3cbb71..208cee016f 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -869,6 +869,15 @@ impl Default for TypePrefs { } } +impl TypePrefs { + /// Preferences ▸ Type ▸ Missing Glyph Protection: with it on (the default, as in Photoshop), + /// glyphs the chosen face lacks fall back through the coverage fonts (#1909). The text engine + /// applies this in `Session::apply_prefs`. + pub fn missing_glyph_protection(&self) -> bool { + self.missing_glyph_protection + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct EnhancedControls { @@ -1049,7 +1058,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "plugIns.showExtensionPanels", "plugIns.allowScriptsToConnect", "plugIns.generatorEnabled", - "type.missingGlyphProtection", "type.textEngine", "type.recentFonts", "enhancedControls.scrubbySliderAcceleration", @@ -1484,8 +1492,8 @@ impl Session { r } - /// Push engine-relevant preferences into live state: every document's history limit and - /// the layer-effect cache budget. + /// Push engine-relevant preferences into live state: every document's history limit, the + /// layer-effect cache budget and the text engine's missing-glyph protection. pub fn apply_prefs(&mut self) { let n = self.prefs().performance.history_states.max(1) as usize; let bytes = self.prefs().performance.history_budget_bytes(); @@ -1496,6 +1504,9 @@ impl Session { st.history.trim(&st.doc); } photocraft_compose::set_effect_cache_budget(budget << 20); + if let Ok(mut engine) = photocraft_text::shared().lock() { + engine.fonts.set_missing_glyph_protection(self.prefs().type_.missing_glyph_protection()); + } crate::plugin_cmds::sync_prefs(self); } diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 1d728f14d4..21fc1a92a4 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -140,6 +140,9 @@ pub struct FontDb { /// PostScript name → (family, weight, italic), filled lazily. ps_cache: HashMap>, fallbacks: Vec, + /// Preferences ▸ Type ▸ Missing Glyph Protection: route glyphs the chosen face lacks through + /// [`Self::fallback_stack`] (#1909). Off, only the default family shapes the text. + missing_glyph_protection: bool, face_cache: HashMap>, face_aliases: HashMap<(String, usize), String>, } @@ -159,6 +162,7 @@ impl FontDb { system_loaded: false, ps_cache: HashMap::new(), fallbacks: Vec::new(), + missing_glyph_protection: true, face_cache: HashMap::new(), face_aliases: HashMap::new(), }; @@ -272,8 +276,20 @@ impl FontDb { } /// Families available after the requested one (bundled default + installed coverage fonts). + /// With missing glyph protection off (Preferences ▸ Type), only the default family shapes. pub(crate) fn fallback_stack(&self) -> impl Iterator { - std::iter::once(DEFAULT_FAMILY).chain(self.fallbacks.iter().map(String::as_str)) + let fallbacks = self.missing_glyph_protection.then_some(self.fallbacks.iter().map(String::as_str)); + std::iter::once(DEFAULT_FAMILY).chain(fallbacks.into_iter().flatten()) + } + + /// Preferences ▸ Type ▸ Missing Glyph Protection, applied by the engine's `apply_prefs`. + pub fn set_missing_glyph_protection(&mut self, on: bool) { + self.missing_glyph_protection = on; + } + + /// Is missing glyph protection on? + pub fn missing_glyph_protection(&self) -> bool { + self.missing_glyph_protection } /// All family names, sorted. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index b1a1949cac..dc8df9e5e3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1580,6 +1580,25 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::>()); } +/// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the +/// fallback stack, so a face without the glyph shows the missing-glyph box instead of a +/// fallback's glyph (#1909). +#[test] +fn missing_glyph_protection_can_turn_the_fallback_stack_off() { + let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { + eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); + return; + }; + let mut db = fonts::FontDb::new(); + db.register_font_data(cairo.bytes.to_vec()); + assert!(db.missing_glyph_protection(), "on by default, as in Photoshop"); + assert!(db.fallback_stack().any(|f| f == "Cairo")); + db.set_missing_glyph_protection(false); + assert_eq!(db.fallback_stack().collect::>(), [fonts::DEFAULT_FAMILY], "only the default family remains"); + db.set_missing_glyph_protection(true); + assert!(db.fallback_stack().any(|f| f == "Cairo"), "turning it back on restores the coverage fonts"); +} + #[test] fn translate_tysh_moves_only_the_translation() { let mut t = point("Hello!", 26.0); diff --git a/docs/scorecard.md b/docs/scorecard.md index 80cb52faae..01296e3739 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 30 of 155 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 29 of 155 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 30 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 29 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.missing_glyph_protection`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From fc02c91ffbab6639187d78a6cf107e98907fff4f Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Mon, 12 Oct 2026 14:43:41 +0200 Subject: [PATCH 111/523] io: read version 3 Photo Filter colour as XYZ, not Lab (#3790) Photoshop CS2 writes `phfl` version 3 with "4 bytes each for XYZ color". The parser read those values as Lab x100, so real files (L = 241.91) fell back to Unsupported. Read them as D50 XYZ with 1.0 = 32768 (the ICC 16-bit PCSXYZ encoding; the scale is inferred from a real file, the spec does not give it). Writing stays version 2 RGB. Fixes #3777 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 85 +++++++++++++++++++++++++------------ crates/io/src/psd_export.rs | 30 +++++++++++++ 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 2489b669c5..d7ebad09f6 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -506,8 +506,16 @@ fn parse_black_white(d: &[u8]) -> Option { } /// `phfl` (Adobe spec, "Photo Filter"): version (2 or 3); version 2 has a colour structure -/// (colour space + four u16), version 3 three i32 holding the Lab colour ×100 (as documented by -/// ag-psd, MIT); then density (u32 percent) and preserve luminosity (1 byte). +/// (colour space + four u16), version 3 "4 bytes each for XYZ color"; then density (u32 percent) +/// and preserve luminosity (1 byte). +/// +/// The spec gives no scale for the XYZ values. They are read as D50 XYZ (the ICC PCS) with +/// 1.0 = 32768, the ICC 16-bit PCSXYZ encoding. That is inferred, not documented: the +/// Photoshop CS2 file in #3777 holds (24191, 25002, 3827), a bright warm yellow at this scale, +/// and rendering it against Photoshop's export fit best between 30000 and 32768 (1.0 = 65536, +/// a dull olive, was clearly worse). +const PHFL_XYZ_ONE: f32 = 32768.0; + fn parse_photo_filter(d: &[u8]) -> Option { let (color, at) = match be16(d, 0)? { 2 => { @@ -519,11 +527,13 @@ fn parse_photo_filter(d: &[u8]) -> Option { (crate::channel_map::decode_color(space, c).to_rgb(), 12) } 3 => { - let lab = [be32(d, 2)? as i32, be32(d, 6)? as i32, be32(d, 10)? as i32].map(|v| v as f32 / 100.0); - if !(0.0..=100.0).contains(&lab[0]) || lab[1].abs() > 128.0 || lab[2].abs() > 128.0 { + let xyz = [be32(d, 2)?, be32(d, 6)?, be32(d, 10)?].map(|v| v as f32 / PHFL_XYZ_ONE); + // Real colours stay below about 1.0; anything past 2.0 is not an XYZ colour. + if xyz.iter().any(|v| *v > 2.0) { return None; } - (photocraft_color::convert::lab_to_srgb(lab), 14) + let lin = photocraft_color::convert::mat3_mul(&photocraft_color::convert::XYZ_D50_TO_SRGB, xyz); + (lin.map(|v| photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0))), 14) } _ => return None, }; @@ -1075,15 +1085,17 @@ mod tests { assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Cmyk), Adjustment::ChannelMixer { .. })); assert!(matches!(parse(b"mixr", &ours[0].1, None, Channels::Other), Adjustment::ChannelMixer { .. })); assert!(matches!(parse(b"mixr", &[0, 1, 0, 0, 0, 100], None, Channels::Rgb), Adjustment::Unsupported { .. })); - // Photo Filter: unknown version, HSB colour space, out-of-range version-3 Lab, truncated. + // Photo Filter: unknown version, HSB colour space, out-of-range version-3 XYZ, truncated. assert!(matches!(parse(b"phfl", &[0, 4, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"phfl", &[0, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 25, 1], None, Channels::Rgb), Adjustment::Unsupported { .. })); - let mut lab = vec![0, 3]; - for x in [20000i32, 0, 0] { - lab.extend(x.to_be_bytes()); + for bad in [[200_000u32, 0, 0], [0, u32::MAX, 0]] { + let mut xyz = vec![0, 3]; + for x in bad { + xyz.extend(x.to_be_bytes()); + } + xyz.extend([0, 0, 0, 25, 1]); + assert!(matches!(parse(b"phfl", &xyz, None, Channels::Rgb), Adjustment::Unsupported { .. })); } - lab.extend([0, 0, 0, 25, 1]); - assert!(matches!(parse(b"phfl", &lab, None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"phfl", &[0, 2, 0, 0, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); // Arbitrary bytes never panic, whatever the key. for key in ADJUSTMENT_KEYS { @@ -1156,25 +1168,44 @@ mod tests { let Adjustment::PhotoFilter { color, density, preserve_luminosity: false } = parse(b"phfl", &lab, None, Channels::Rgb) else { panic!() }; assert!(color.iter().all(|c| (c - 0.466).abs() < 0.01), "{color:?}"); assert_eq!(density, 0.25); - // Version 3: Lab ×100 as i32 (L 100, a 0, b 0 = white), density 100 %. - let mut v3 = vec![0, 3]; - for x in [10000i32, 0, 0] { - v3.extend(x.to_be_bytes()); - } - v3.extend(100u32.to_be_bytes()); - v3.push(1); + // Version 3: D50 XYZ, 1.0 = 32768. The ICC sRGB white point (D50) is white. + let v3 = phfl_v3([31595, 32768, 27040], 100, true); let Adjustment::PhotoFilter { color, density, preserve_luminosity: true } = parse(b"phfl", &v3, None, Channels::Rgb) else { panic!() }; - assert!(color.iter().all(|c| (c - 1.0).abs() < 0.01), "{color:?}"); + assert!(color.iter().all(|c| (c - 1.0).abs() < 0.005), "{color:?}"); assert_eq!(density, 1.0); - // A negative a* (green) in version 3. - let mut green = vec![0, 3]; - for x in [5000i32, -6000, 0] { - green.extend(x.to_be_bytes()); + // Photoshop's Warming Filter (85) preset, sRGB #EC8A00, is D50 XYZ (15193, 12086, 1192). + let Adjustment::PhotoFilter { color, density, preserve_luminosity: false } = + parse(b"phfl", &phfl_v3([15193, 12086, 1192], 25, false), None, Channels::Rgb) + else { + panic!() + }; + let expected = [0xecu8, 0x8a, 0x00].map(|v| f32::from(v) / 255.0); + assert!(color.iter().zip(expected).all(|(got, want)| (got - want).abs() < 1.5 / 255.0), "{color:?}"); + assert_eq!(density, 0.25); + // The block from #3777 (Photoshop CS2): a warm yellow, density 63 %, preserve luminosity. + let issue = [0, 3, 0, 0, 0x5e, 0x7f, 0, 0, 0x61, 0xaa, 0, 0, 0x0e, 0xf3, 0, 0, 0, 0x3f, 1, 0]; + let Adjustment::PhotoFilter { color, density, preserve_luminosity: true } = parse(b"phfl", &issue, None, Channels::Rgb) else { panic!() }; + assert!(color[0] > color[1] && color[1] > color[2] && color[1] > 0.8 && color[2] < 0.3, "{color:?}"); + assert_eq!(density, 0.63); + // The 19-byte form (no trailing pad) parses the same. + assert_eq!(parse(b"phfl", &issue[..19], None, Channels::Rgb), parse(b"phfl", &issue, None, Channels::Rgb)); + // Version 3 is read, never written: an edited filter is saved as version 2 and keeps its colour. + let a = parse(b"phfl", &issue, None, Channels::Rgb); + let w = write(&a); + assert_eq!(&w[0].1[..2], &[0, 2]); + let Adjustment::PhotoFilter { color: back, .. } = parse(b"phfl", &w[0].1, None, Channels::Rgb) else { panic!() }; + assert!(back.iter().zip(color).all(|(b, c)| (b - c).abs() <= 1.0 / 65535.0)); + } + + /// A version 3 Photo Filter block: version, three u32 XYZ components, density, flag, pad. + fn phfl_v3(xyz: [u32; 3], density: u32, preserve_luminosity: bool) -> Vec { + let mut d = vec![0, 3]; + for x in xyz { + d.extend(x.to_be_bytes()); } - green.extend(30u32.to_be_bytes()); - green.push(0); - let Adjustment::PhotoFilter { color, .. } = parse(b"phfl", &green, None, Channels::Rgb) else { panic!() }; - assert!(color[1] > color[0] && color[1] > color[2], "{color:?}"); + d.extend(density.to_be_bytes()); + d.extend([u8::from(preserve_luminosity), 0]); + d } #[test] diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 3d8d637eb4..4c7463a3c2 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1727,6 +1727,36 @@ mod tests { assert_phfl(&PsdFile::from_bytes(&repaired_bytes).unwrap()); } + #[test] + fn photo_filter_version_3_is_kept_until_edited() { + // The #3777 block (Photoshop CS2, XYZ colour): saved back byte-exact while unchanged. + let v3 = vec![0, 3, 0, 0, 0x5e, 0x7f, 0, 0, 0x61, 0xaa, 0, 0, 0x0e, 0xf3, 0, 0, 0, 0x3f, 1, 0]; + let adjustment = crate::adjust_map::parse(b"phfl", &v3, None, crate::adjust_map::Channels::Rgb); + let photocraft_doc::Adjustment::PhotoFilter { color, .. } = adjustment else { panic!("{adjustment:?}") }; + let mut doc = + Document::with_background("Photo Filter v3", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE); + let mut layer = Layer::new("Photo Filter 1", LayerContent::Adjustment(adjustment.clone())); + layer.psd_blocks = vec![(*b"phfl", std::sync::Arc::new(v3.clone()))]; + doc.layers.push(layer); + let phfl = |doc: &Document| { + let file = PsdFile::from_bytes(&document_to_psd(doc).to_bytes().unwrap()).unwrap(); + let records = &file.layer_info.as_ref().unwrap().layers; + records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *b"phfl").map(|b| b.data.clone()).expect("Photo Filter adjustment data") + }; + assert_eq!(phfl(&doc), v3); + // Once edited it is written as version 2 (RGB), and the colour survives. + let edited = photocraft_doc::Adjustment::PhotoFilter { color, density: 0.5, preserve_luminosity: true }; + if let Some(l) = doc.layers.last_mut() { + l.content = LayerContent::Adjustment(edited.clone()); + } + let saved = phfl(&doc); + assert_eq!(&saved[..2], &[0, 2]); + let reread = crate::adjust_map::parse(b"phfl", &saved, None, crate::adjust_map::Channels::Rgb); + let photocraft_doc::Adjustment::PhotoFilter { color: back, density, .. } = reread else { panic!("{reread:?}") }; + assert!(back.iter().zip(color).all(|(b, c)| (b - c).abs() <= 1.0 / 65535.0), "{back:?} vs {color:?}"); + assert_eq!(density, 0.5); + } + #[test] fn size_limit_boundary_keeps_limit_and_switches_above_it() { assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE))); From dcc9f86aa5826af846deb67d99f9fac002e3dcdc Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 20:43:48 +0800 Subject: [PATCH 112/523] Workspace: wire Enable Narrow Options Bar (#204) (#3870) * engine: expose workspace.enableNarrowOptionsBar through a Workspace helper * ui: the options bar honors the narrow options bar preference * docs: scorecard, settings that do nothing 30 -> 29 of 154 --- crates/engine/src/prefs.rs | 9 +++++++- crates/ui-egui/src/panels.rs | 42 +++++++++++++++++++++++++++++++++++- docs/scorecard.md | 6 +++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 208cee016f..beef58c920 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -341,6 +341,14 @@ impl Default for Workspace { } } +impl Workspace { + /// Preferences ▸ Workspace ▸ Enable Narrow Options Bar: the options bar drops to its compact + /// height, as in Photoshop, so the document gets more room (see `panels::options_bar`). + pub fn narrow_options_bar(&self) -> bool { + self.enable_narrow_options_bar + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Tools { @@ -1046,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.autoCollapseIconPanels", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", - "workspace.enableNarrowOptionsBar", "fileHandling.saveInBackground", "fileHandling.maximizePsdCompatibility", "performance.cacheLevels", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d908af7afa..0f6a1b83bf 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -852,10 +852,22 @@ fn reveal_focused_control(ui: &egui::Ui) { // ----------------------------------------------------------------------------- options bar +/// Options bar height: Preferences ▸ Workspace ▸ Enable Narrow Options Bar trims it, as in +/// Photoshop, leaving the document more room. +fn options_bar_height(pro: bool, narrow: bool) -> f32 { + match (pro, narrow) { + (true, false) => 36.0, + (true, true) => 30.0, + (false, false) => 42.0, + (false, true) => 34.0, + } +} + pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + let narrow = app.session.prefs().workspace.narrow_options_bar(); egui::Panel::top("options_bar") - .exact_size(if t.pro { 36.0 } else { 42.0 }) + .exact_size(options_bar_height(t.pro, narrow)) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0))) .show(ui, |ui| { ui.style_mut().spacing.scroll = egui::style::ScrollStyle { bar_width: 4.0, ..egui::style::ScrollStyle::floating() }; @@ -5556,6 +5568,34 @@ mod compact_visual_regressions { } } + #[test] + fn the_narrow_options_bar_preference_shortens_the_bar() { + assert_eq!(options_bar_height(true, false), 36.0); + assert_eq!(options_bar_height(true, true), 30.0); + assert_eq!(options_bar_height(false, false), 42.0); + assert_eq!(options_bar_height(false, true), 34.0); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::ThemeKind::ALL[0]); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 900.0)); + let run = |app: &mut PhotocraftApp| { + let mut top = 0.0; + let mut out = ctx.run_ui(input_at_scale(screen, 1.0), |ui| { + options_bar(app, ui); + top = ui.available_rect_before_wrap().top(); + }); + out.textures_delta.clear(); + top + }; + let normal = run(&mut app); + assert_eq!(normal, options_bar_height(Tokens::get(&ctx).pro, false)); + app.session.execute("prefs.set", json!({"path": "workspace.enableNarrowOptionsBar", "value": true})).unwrap(); + let narrow = run(&mut app); + assert_eq!(narrow, options_bar_height(Tokens::get(&ctx).pro, true)); + assert!(narrow < normal, "narrow {narrow} must trim the bar under {normal}"); + } + #[test] fn narrow_tool_options_can_scroll_to_the_last_control() { for kind in crate::theme::ThemeKind::ALL { diff --git a/docs/scorecard.md b/docs/scorecard.md index 01296e3739..fb430469a2 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 29 of 155 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 28 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 29 of 155** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 28 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `workspace.enable_narrow_options_bar`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 7890b277a0ae0679afbf31489ca5cd423a5fe3f7 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:45:09 +0200 Subject: [PATCH 113/523] fix(adjust): absolute Selective Color scales by the room the ink has (#3793) Photoshop's absolute mode scales each range's ink change by how much the ink can still move: adding ink by min(1, 2 x (1 - ink)), removing it by min(1, 2 x ink). PhotoCraft added the full percentage and clipped, so Neutrals Cyan +50 darkened a grey 64 to 0 instead of 32. Measured in Photoshop 27.11 over every range x ink at +-50 (72 cases): all within 1 level now (8 of 72 within 3 before). CPU and GPU paths. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 43 +++++++++++++++++++++++++++++++----- crates/gpu/src/compose.wgsl | 7 +++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index a39f3307f9..cc96e697f6 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -606,10 +606,12 @@ pub fn selective_color_weights(c: [f32; 3]) -> [f32; 9] { ] } -/// Photoshop Selective Color (approximation): each range shifts the cyan, magenta and yellow ink -/// (1 − R, G, B) by its percentage, plus its black percentage on every ink, weighted by how much -/// the colour belongs to the range. Relative mode scales the shift by the ink already present -/// (so it can't tint pure white); absolute adds it outright. +/// Photoshop Selective Color: each range shifts the cyan, magenta and yellow ink (1 − R, G, B) by +/// its percentage, plus its black percentage on every ink, weighted by how much the colour belongs +/// to the range. Relative mode scales the shift by the ink already present (so it can't tint pure +/// white). Absolute mode scales it by the room the ink has: adding ink by min(1, 2 × (1 − ink)), +/// removing it by min(1, 2 × ink), so Neutrals Cyan +50 darkens red 64 by 32, not 64 (all 72 +/// absolute range × ink cases within 1 level of Photoshop 27.11). pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32; 3] { let w = selective_color_weights(c); let mut delta = [0.0f32; 3]; @@ -620,7 +622,14 @@ pub fn selective_color(c: [f32; 3], relative: bool, adj: &[[f32; 4]; 9]) -> [f32 let a = adj[r].map(|v| v / 100.0); for i in 0..3 { let ink = 1.0 - c[i]; - let d = if relative { (a[i] + a[3]) * ink } else { a[i] + a[3] }; + let d = a[i] + a[3]; + let d = if relative { + d * ink + } else if d > 0.0 { + d * (2.0 * (1.0 - ink)).min(1.0) + } else { + d * (2.0 * ink).min(1.0) + }; delta[i] += d * wr; } } @@ -1042,6 +1051,30 @@ mod lookup_tests { } } + /// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's + /// cyan change). + #[test] + fn absolute_selective_color_matches_photoshop() { + // (range index, cyan %, grey, Photoshop's red) + let measured = [ + (7, 50.0, 64u8, 32u8), + (7, 50.0, 192, 129), + (7, -50.0, 192, 223), + (7, -50.0, 64, 128), + (6, -50.0, 192, 224), + (6, -50.0, 240, 253), + (8, 50.0, 64, 32), + (8, 50.0, 16, 2), + ]; + for (range, cyan, grey, want) in measured { + let mut adj = [[0.0f32; 4]; 9]; + adj[range][0] = cyan; + let g = grey as f32 / 255.0; + let got = selective_color([g; 3], false, &adj)[0] * 255.0; + assert!((got - want as f32).abs() <= 1.5, "range {range} cyan {cyan} grey {grey}: {got} vs {want}"); + } + } + #[test] fn selective_color_zero_is_identity_and_ranges_are_local() { let zero = [[0.0f32; 4]; 9]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index bf535b08f0..edfe140be4 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -527,7 +527,12 @@ fn color_lookup(c: vec3, n: i32, tetra: bool) -> vec3 { fn selective_row(r: i32, w: f32, c: vec3, relative: bool) -> vec3 { let k = lut_at(r * 4 + 3) / 100.0; var d = vec3(lut_at(r * 4), lut_at(r * 4 + 1), lut_at(r * 4 + 2)) / 100.0 + k; - if (relative) { d = d * (1.0 - c); } + if (relative) { + d = d * (1.0 - c); + } else { + // Absolute: scaled by the room the ink has (adding: 2 × (1 − ink), removing: 2 × ink, capped at 1). + d = d * select(min(2.0 * (1.0 - c), vec3(1.0)), min(2.0 * c, vec3(1.0)), d > vec3(0.0)); + } return select(vec3(0.0), d * w, w > 0.0); } From 8af15cf72a95eac97261b2fc7c3277269dd039ef Mon Sep 17 00:00:00 2001 From: Desayuno64 <66540128+Desayuno64@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:45:25 -0300 Subject: [PATCH 114/523] Shift Edge moves by a fraction of a pixel so the slider does not snap. (#3800) 100% is still max(radius, 4) pixels. Amounts between whole pixels blend the two neighbouring morphs. --- crates/algo/src/matting.rs | 56 ++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index a9dcac98b9..e40b687040 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -35,7 +35,8 @@ pub struct RefineParams { pub feather: f32, /// 0–100. pub contrast: f32, - /// −100–100 (% of `max(radius, 4 px)`). + /// −100–100. 100% moves the edge by `max(radius, 4)` pixels. Values between whole + /// pixels blend the two neighbouring shifts, so the slider does not jump. pub shift_edge: f32, } @@ -59,8 +60,18 @@ impl RefineParams { fn feather_sigma(&self) -> f32 { self.feather.max(0.0) / 2.0 } + /// Signed shift in pixels, fractional. 100% is `max(radius, 4)` px, clamped to ±64. + fn shift_amount(&self) -> f32 { + let px = self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0); + if px.is_finite() { px.clamp(-64.0, 64.0) } else { 0.0 } + } + /// How many pixels the mask can move: the farther of the two integer radii a fractional + /// shift blends. Whole-pixel amounts stay exact (`ceil(4) == 4`). fn shift_px(&self) -> i32 { - (self.shift_edge.clamp(-100.0, 100.0) / 100.0 * self.radius.max(4.0)).round().clamp(-64.0, 64.0) as i32 + let px = self.shift_amount(); + let reach = px.abs().ceil(); + let signed = if px.is_sign_negative() { -reach } else { reach }; + if signed.is_finite() { signed as i32 } else { 0 } } fn uses_image(&self) -> bool { self.radius > 0.0 @@ -370,13 +381,38 @@ where *v = ((*v - 0.5) * gain + 0.5).clamp(0.0, 1.0); } } - let d = p.shift_px(); - if d != 0 { - a = morphology(&a, w, h, d.unsigned_abs() as usize, d > 0); + let amount = p.shift_amount(); + if amount.abs() >= 1.0e-4 { + a = shift_mask(&a, w, h, amount, morphology); } a } +/// Grow or shrink `src` by a fractional pixel distance. Whole pixels use one morphology; +/// the remainder blends that result with the next pixel, so 0.3 px is not the same step as 1 px. +fn shift_mask(src: &[f32], w: usize, h: usize, amount: f32, morphology: F) -> Vec +where + F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy, +{ + let mag = amount.abs(); + if !mag.is_finite() || mag < 1.0e-4 { + return src.to_vec(); + } + let grow = amount > 0.0; + let whole = mag.floor() as usize; + let frac = mag - whole as f32; + if frac < 1.0e-4 { + return morphology(src, w, h, whole, grow); + } + let lo = if whole == 0 { src.to_vec() } else { morphology(src, w, h, whole, grow) }; + let hi = morphology(src, w, h, whole + 1, grow); + let n = w.saturating_mul(h); + if lo.len() < n || hi.len() < n || lo.len() != hi.len() { + return hi; + } + lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect() +} + /// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds /// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image /// (only read when `radius > 0`). Returns `None` when nothing remains selected. @@ -720,6 +756,16 @@ mod tests { let shrunk = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -50.0, ..Default::default() }); assert_eq!(shrunk[5 * w + 27], 1.0); assert_eq!(shrunk[5 * w + 28], 0.0); + // Radius 0 scales 100% to 4 px, so 12.5% is half a pixel and 25% is one pixel. + // The pixel just outside a hard edge fades in instead of snapping on. + let half = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 12.5, ..Default::default() }); + let one = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: 25.0, ..Default::default() }); + assert_eq!(m[5 * w + 30], 0.0); + assert!((half[5 * w + 30] - 0.5).abs() < 0.02, "half pixel {}", half[5 * w + 30]); + assert_eq!(one[5 * w + 30], 1.0); + assert_eq!(half[5 * w + 31], 0.0, "the blend must not jump a second pixel"); + let pulled = refine_buffer(None, &m, w, h, &RefineParams { shift_edge: -12.5, ..Default::default() }); + assert!((pulled[5 * w + 29] - 0.5).abs() < 0.02, "half-pixel contract {}", pulled[5 * w + 29]); let sm = refine_buffer(None, &m, w, h, &RefineParams { smooth: 50.0, ..Default::default() }); assert!(sm[5 * w + 10] == 1.0 && sm[5 * w + 50] == 0.0); } From 04d2874e380d949981bc78d5d73589420b894222 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:45:48 +0200 Subject: [PATCH 115/523] fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792) PhotoCraft treats 32-bit documents as linear light (Transfer::for_document, Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB chart, every sample = the sRGB-to-linear value). The conversion now decodes colour samples through the document's tone curve after widening to 32 bits, and encodes them before narrowing (so no linear value is quantised to 8 or 16 bits). Alpha, masks and alpha channels keep their values. RGB and Grayscale only (the 32-bit modes). With it, Photoshop's 32-bit results match exactly for the conversion itself, Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80 levels off in sRGB terms). Adds Surface::map_color (colour samples only, including the default pixel) and makes Transfer::decode/encode public. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/adjust.rs | 6 ++++-- crates/engine/src/image_cmds.rs | 38 +++++++++++++++++++++++++++++++++ crates/raster/src/lib.rs | 25 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index cc96e697f6..edba488262 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -45,14 +45,16 @@ impl Transfer { t => t, } } - fn decode(self, v: f32) -> f32 { + /// Tone-encoded value → linear light. + pub fn decode(self, v: f32) -> f32 { match self { Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)), Transfer::Gamma(1.0) => v.max(0.0), Transfer::Gamma(g) => v.max(0.0).powf(g), } } - fn encode(self, v: f32) -> f32 { + /// Linear light → tone-encoded value. + pub fn encode(self, v: f32) -> f32 { match self { Transfer::Srgb => photocraft_color::convert::linear_to_srgb(v.max(0.0)), Transfer::Gamma(1.0) => v.max(0.0), diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 9ccca87ada..84474d57d1 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -650,7 +650,20 @@ fn convert_depth(s: &mut Session, depth: SampleType) -> Result { return Ok(Value::Null); } s.edit("Bit Depth", |doc, _| { + // 32-bit documents hold linear light (as Photoshop's do: grey 128 becomes 0.2159, measured + // in Photoshop 27.11), so the colour samples cross the document's tone curve when the + // depth crosses 32 bits: decoded after widening, encoded before narrowing (so no linear + // value is quantised to 8 or 16 bits). Masks, alpha and channels are not colour. + let crosses = (current == SampleType::F32) != (depth == SampleType::F32) && matches!(doc.mode, ColorMode::Rgb | ColorMode::Grayscale); + let to_linear = depth == SampleType::F32; + let transfer = photocraft_compose::adjust::Transfer::for_document(doc.mode, if to_linear { current } else { depth }); + if crosses && !to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.encode(v).clamp(0.0, 1.0))); + } convert_layers_depth(&mut doc.layers, depth); + if crosses && to_linear { + for_each_surface(&mut doc.layers, false, &mut |surf, _| surf.map_color(|v| transfer.decode(v))); + } for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { let f = ch.surface.format().with_sample(depth); ch.surface = ch.surface.convert(f); @@ -1337,6 +1350,31 @@ mod tests { assert_eq!(doc(&s).size, Size::new(10, 10)); } + #[test] + fn thirty_two_bit_holds_linear_light() { + // Photoshop 27.11: an 8-bit sRGB grey 128 becomes 0.2159 in 32 bits (and 32 → 0.0144), + // and comes back unchanged; alpha keeps its value. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 1, "background": "white"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 2, 1), &[128.0 / 255.0, 32.0 / 255.0, 1.0, 0.5]); + Ok(()) + }) + .unwrap(); + s.execute("image.mode.bits32", json!({})).unwrap(); + let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0); + for (got, want) in px.iter().zip([0.2159, 0.0144, 1.0, 128.0 / 255.0]) { + assert!((got - want).abs() < 2e-4, "{px:?}"); + } + s.execute("image.mode.bits8", json!({})).unwrap(); + let px = doc(&s).layers[1].surface().unwrap().pixel(0, 0); + for (got, want) in px.iter().zip([128.0 / 255.0, 32.0 / 255.0, 1.0, 128.0 / 255.0]) { + assert!((got - want).abs() < 0.6 / 255.0, "{px:?}"); + } + } + #[test] fn mode_and_depth_conversions() { let mut s = session(); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index c80dce82c8..dbb6c0a107 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -732,6 +732,31 @@ impl Surface { out } + /// Applies `f` to every colour sample (not alpha), including the default pixel: a tone-curve + /// change such as linearising when a document becomes 32-bit. + pub fn map_color(&mut self, f: impl Fn(f32) -> f32) { + let format = self.format; + let color = format.mode.color_channels(); + let n = format.channels(); + let mut dp = decode_pixel(&format, &self.default_pixel); + for v in dp.iter_mut().take(color) { + *v = f(*v); + } + encode_pixel(&format, &dp, &mut self.default_pixel); + let coords: Vec = self.tiles.keys().copied().collect(); + let px_count = (TILE_SIZE * TILE_SIZE) as usize; + for c in coords { + let tile = self.tile_mut(c); + for i in 0..px_count { + for k in 0..color { + let at = i * n + k; + let v = read_sample(&tile.data, format.sample, at); + write_sample(&mut tile.data, format.sample, at, f(v)); + } + } + } + } + /// Convert to another pixel format (depth and/or model; colour models via [`convert_pixel`]). pub fn convert(&self, to: PixelFormat) -> Surface { let mut out = Surface::with_default(to, &convert_pixel(&self.format, &to, &self.default_pixel())); From 3cc0d48410b00d21da06a9f9b391d52a79f6a938 Mon Sep 17 00:00:00 2001 From: Scott C Hughes <1591011+dottedt@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:46:04 -0500 Subject: [PATCH 116/523] fix(engine): Flatten All Layer Effects keeps layer and vector masks (#3804) (#3835) Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op. Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored --- crates/engine/src/file_cmds.rs | 18 +++-- crates/engine/src/file_cmds/tests.rs | 112 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index d175473370..5aa2441a1f 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -1035,8 +1035,10 @@ fn has_live_effects(l: &Layer) -> bool { l.effects.enabled && l.effects.items.iter().any(|e| e.enabled()) } -/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. -fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surface { +/// The layer rendered alone (keeping what `keep` says), as a surface in `fmt`. With `apply_mask` +/// off the layer's pixel and vector masks are left out of the render, so a caller that keeps them +/// live on the baked raster does not mask the pixels twice. +fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool, apply_mask: bool) -> Surface { let mut tmp = l.clone(); tmp.opacity = 1.0; tmp.blend = photocraft_color::BlendMode::Normal; @@ -1046,6 +1048,10 @@ fn bake(l: &Layer, canvas: Rect, fmt: PixelFormat, keep_effects: bool) -> Surfac tmp.effects.items.clear(); tmp.fill_opacity = 1.0; } + if !apply_mask { + tmp.mask = None; + tmp.vector_mask = None; + } // Render over the union of the canvas and the content so off-canvas pixels survive. let area = l.surface().map_or(canvas, |s| s.content_bounds().union(&canvas)); let area = if keep_effects { area.inflate(256) } else { area }; @@ -1066,12 +1072,12 @@ fn flatten_all_effects(s: &mut Session) -> Result { if !has_live_effects(l) || matches!(l.content, LayerContent::Group(_) | LayerContent::Adjustment(_)) { continue; } - let px = bake(l, canvas, fmt, true); + // The pixel and vector masks stay on the layer (baking must not destroy them), so the + // pixels are baked without them and each mask then applies once to the baked raster. + let px = bake(l, canvas, fmt, true, false); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.effects = Default::default(); - l.mask = None; - l.vector_mask = None; l.fill_opacity = 1.0; l.fill_cache = None; l.psd_blocks.retain(|(k, _)| !matches!(k, b"TySh" | b"SoLd" | b"PlLd" | b"SoLE" | b"vmsk" | b"vsms" | b"vogk" | b"vscg" | b"vstk")); @@ -1099,7 +1105,7 @@ fn flatten_all_masks(s: &mut Session) -> Result { skipped += 1; continue; } - let px = bake(l, canvas, fmt, false); + let px = bake(l, canvas, fmt, false, true); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; l.content = LayerContent::Raster(px); l.mask = None; diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index f301d4c9a6..a4100a138d 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -554,6 +554,118 @@ fn flatten_all_layer_effects_and_masks() { } } +/// Flatten All Layer Effects must bake the effect visuals without destroying the layer's masks: +/// an enabled raster mask and a vector mask on one layer, a disabled raster mask on another +/// (#3804). The command is a visual no-op; the masks (pixels and state) survive, including a +/// `.pcraft` round trip. +#[test] +fn flatten_all_layer_effects_keeps_masks() { + for depth in [8, 16, 32] { + let mut s = session(40, 40, depth); + // Layer 1: a red square with a green colour overlay, an enabled raster mask revealing + // x < 20 and a vector mask revealing x 15..30. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id1 = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + let l = doc.layer_mut(id1).unwrap(); + l.effects.enabled = true; + l.effects.items.push(photocraft_doc::Effect::ColorOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), + }); + l.vector_mask = Some(photocraft_doc::VectorMask::new(photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[ + (15.0, 15.0), + (30.0, 15.0), + (30.0, 30.0), + (15.0, 30.0), + ])]))); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 40})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + // Layer 2: a red square at the bottom-right with the same overlay and a *disabled* mask. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 30, "y": 30, "width": 8, "height": 8})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id2 = s.active().unwrap().active_layer.unwrap(); + s.edit("fx", |doc, _| { + let l = doc.layer_mut(id2).unwrap(); + l.effects.enabled = true; + l.effects.items.push(photocraft_doc::Effect::ColorOverlay { + common: photocraft_doc::effects::FxCommon::new(photocraft_color::BlendMode::Normal, 1.0), + color: photocraft_color::Color::rgb(0.0, 1.0, 0.0), + }); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 34, "y": 34, "width": 2, "height": 2})).unwrap(); + s.execute("layer.layerMask.revealSelection", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.edit("disable", |doc, _| { + if let Some(m) = doc.layer_mut(id2).and_then(|l| l.mask.as_mut()) { + m.enabled = false; + } + Ok(()) + }) + .unwrap(); + let m1 = doc(&s).layer(id1).unwrap().mask.clone(); + let v1 = doc(&s).layer(id1).unwrap().vector_mask.clone(); + let m2 = doc(&s).layer(id2).unwrap().mask.clone(); + assert_eq!(m1.as_ref().map(|m| m.enabled), Some(true), "layer 1 raster mask is enabled"); + assert!(v1.as_ref().is_some_and(|v| v.enabled), "layer 1 vector mask is enabled"); + assert_eq!(m2.as_ref().map(|m| m.enabled), Some(false), "layer 2 raster mask is disabled"); + let before = [ + composite(&s, 17, 15), // layer 1: inside both masks + composite(&s, 22, 15), // layer 1: raster mask hides + composite(&s, 12, 15), // layer 1: vector mask hides + composite(&s, 34, 34), // layer 2: the disabled mask does not hide + ]; + assert!(before[0][1] > 0.99 && before[0][0] < 0.01, "overlay renders green: {:?}", before[0]); + // The white background shows where a mask hides the layer. + assert!(before[1][0] > 0.99 && before[1][1] > 0.99, "the raster mask hides: {:?}", before[1]); + assert!(before[2][0] > 0.99 && before[2][1] > 0.99, "the vector mask hides: {:?}", before[2]); + assert!(before[3][1] > 0.99, "the disabled mask does not hide"); + let r = s.execute("file.scripts.flattenAllLayerEffects", json!({})).unwrap(); + assert_eq!(r["flattened"], 2); + { + let d = doc(&s); + assert_eq!(d.layer(id1).unwrap().mask, m1, "layer 1 raster mask survives"); + assert_eq!(d.layer(id1).unwrap().vector_mask, v1, "layer 1 vector mask survives"); + assert_eq!(d.layer(id2).unwrap().mask, m2, "the disabled mask survives"); + } + let after = [composite(&s, 17, 15), composite(&s, 22, 15), composite(&s, 12, 15), composite(&s, 34, 34)]; + for (a, b) in after.iter().zip(&before) { + for k in 0..4 { + assert!((a[k] - b[k]).abs() < 0.01, "flattening is a visual no-op: {after:?} vs {before:?}"); + } + } + // One history step: undo restores the effects, redo re-flattens, and the masks stay. + assert!(s.undo()); + assert!(!doc(&s).layer(id1).unwrap().effects.items.is_empty()); + assert_eq!(doc(&s).layer(id1).unwrap().mask, m1); + assert!(s.redo()); + assert_eq!(doc(&s).layer(id1).unwrap().mask, m1); + assert_eq!(doc(&s).layer(id1).unwrap().vector_mask, v1); + assert_eq!(doc(&s).layer(id2).unwrap().mask, m2); + // The masks survive a `.pcraft` save and reopen. + let dir = tmp("flatten-effects-masks"); + let path = join(&dir, "masks.pcraft"); + save_doc(doc(&s), &path, None).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + let mut s2 = Session::new(); + open_bytes_as(&mut s2, "masks.pcraft", &bytes, None, None).unwrap(); + let layers: Vec<_> = doc(&s2).walk().into_iter().map(|(_, _, l)| l).collect(); + assert!(layers.iter().any(|l| l.mask == m1 && l.vector_mask == v1), "the raster and vector masks survive .pcraft"); + assert!(layers.iter().any(|l| l.mask == m2), "the disabled mask survives .pcraft"); + } +} + #[test] fn layers_to_files_writes_each_visible_layer() { let dir = tmp("layers"); From 9ff6c57e0335f87f5d2b97e3ff3e8a47b4aa28fc Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:16 +0200 Subject: [PATCH 117/523] GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844) The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack recorded every pass into a single command buffer: 700 full-size layers make 1403 render passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command buffers per queue, so a few thousand passes in one cell can lose the device. run_cell now counts copies and render passes and, when a refresh may submit in between, submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the queue keeps the order, so a chunk continues in the next command buffer. A device lost in such a submit ends the refresh with an error instead of recording more work. Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise. Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes without a flush. --- crates/gpu/src/lib.rs | 86 ++++++++++++++++++++++++++++---------- crates/gpu/tests/parity.rs | 19 +++++++++ 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 2efd7f6a49..854db1b91d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -82,6 +82,12 @@ const FLUSH_BYTES: u64 = 512 << 20; /// command buffer holding a whole huge refresh can run for seconds, long enough for the OS to /// reset the GPU. const FLUSH_WORK: u64 = 1 << 30; +/// Copies and render passes [`Compositor::render`] records before submitting. wgpu opens up to +/// two backend command buffers for each (a render pass and its barrier pre-pass), and Metal +/// treats more than 4096 outstanding ones as a lost device: a refresh of a document with a few +/// hundred layers reached that inside a single page cell (P12, "pc_compose_uniforms is +/// invalid"), where the work and byte limits above never trigger. +const FLUSH_OPS: u32 = 1000; /// A finished chunk: straight-alpha RGBA32F pixels of `rect` (document coordinates) at the /// texture's origin. @@ -422,6 +428,8 @@ pub struct Compositor { stamp: u64, /// Bytes staged by uploads since the last submit. staged: u64, + /// Copies and render passes recorded since the last submit. + recorded: u32, acc_format: wgpu::TextureFormat, /// Whether the effect-map pipelines could be built (else documents with layer effects are /// [`Unsupported`] and use the CPU compositor). @@ -617,6 +625,7 @@ impl Compositor { frame: 0, stamp: 0, staged: 0, + recorded: 0, acc_format, effect_maps: map_error.is_none(), cmyk: 0, @@ -907,6 +916,7 @@ impl Compositor { self.frame += 1; self.stamp += 1; self.staged = 0; + self.recorded = 0; // Only render owns submission. A caller may discard encode's command buffer, so it // cannot establish (or consume) a checkpoint whose GPU copy might never execute. @@ -937,9 +947,7 @@ impl Compositor { } let flush_bytes = FLUSH_BYTES.min(self.memory_budget() / 8).max(16 << 20); stats.cells = cells.len(); - // The previous intermediate submit (waited for before the next is queued; the web has - // no blocking wait and relies on the browser's own scheduling). - #[cfg(not(target_arch = "wasm32"))] + // The previous intermediate submit (see `submit_batch`). let mut last_submit: Option = None; // Work recorded and bytes evicted since the last submit. let (mut work, mut freed) = (0u64, 0u64); @@ -958,29 +966,19 @@ impl Compositor { let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); work += part.width() as u64 * part.height() as u64 * plan.passes.len() as u64; - self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink); + let mut batch = flush.then_some(&mut last_submit); + self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch); + // The device was lost in a submit inside the cell: issue no more work. + if let Some(f) = self.fault() { + return Err(Unsupported(f.to_string())); + } let (evicted, bytes) = self.evict(); stats.evicted += evicted; freed += bytes; - if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes) { - // Submit what's recorded, and wait for the previous submit, so at most two - // batches of uploads and evicted pages are in flight. - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); - let index = queue.submit([done.finish()]); - #[cfg(not(target_arch = "wasm32"))] - if let Some(prev) = last_submit.replace(index) { - match &self.health { - Some(h) => { - h.wait(device, Some(prev)); - } - None => { - let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None }); - } - } - } - #[cfg(target_arch = "wasm32")] - let _ = index; + if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { + submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); self.staged = 0; + self.recorded = 0; work = 0; freed = 0; stats.flushes += 1; @@ -1135,6 +1133,8 @@ impl Compositor { checkpoint: Option, stats: &mut Stats, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), + // The previous intermediate submit, when this refresh may submit in between. + batch: &mut Option<&mut Option>, ) { if chunks.is_empty() { return; @@ -1220,6 +1220,19 @@ impl Compositor { if i < resume { continue; } + // Chunk textures, uniforms and bind groups outlive the submit, and the queue + // keeps the order, so a chunk may continue in the next command buffer. + if self.recorded >= FLUSH_OPS + && let Some(last) = batch.as_deref_mut() + { + submit_batch(device, queue, encoder, self.health.as_ref(), last); + self.staged = 0; + self.recorded = 0; + stats.flushes += 1; + if self.fault().is_some() { + return; + } + } if resume == 0 && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) @@ -1257,10 +1270,12 @@ impl Compositor { }, wgpu::Extent3d { width: scissor.width(), height: scissor.height(), depth_or_array_layers: 1 }, ); + self.recorded += 1; continue; } _ => {} } + self.recorded += 1; let target = &self.pool[p.dst as usize].1; let clear = p.kernel == Kernel::Clear; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -1778,6 +1793,33 @@ fn write_tile(queue: &wgpu::Queue, r: &Resident, c: TileCoord, bytes: &[u8]) { ); } +/// Submit what `encoder` recorded and continue in a fresh one, then wait for the previous +/// intermediate submit (`last`), so at most two batches of uploads and evicted pages are in +/// flight. The web has no blocking wait and relies on the browser's own scheduling. +fn submit_batch( + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + health: Option<&DeviceHealth>, + last: &mut Option, +) { + let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let prev = last.replace(queue.submit([done.finish()])); + #[cfg(not(target_arch = "wasm32"))] + if let Some(prev) = prev { + match health { + Some(h) => { + h.wait(device, Some(prev)); + } + None => { + let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None }); + } + } + } + #[cfg(target_arch = "wasm32")] + let _ = (prev, health); +} + /// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles /// of a 16-bit page are 32 MiB). const CONVERT_BATCH: usize = 64; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index fca32a02c4..1bdd597a36 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1061,6 +1061,25 @@ fn small_layers_composite_over_their_bounds() { check(&mut g, &big, "small layers across chunks"); } +#[test] +fn many_layers_submit_inside_one_chunk() { + // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few + // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is + // invalid"). 700 layers need well over 1000 passes in the document's only chunk. + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let modes: Vec = BlendMode::layer_modes().collect(); + for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() { + let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); + l.blend = mode; + l.opacity = 0.9; + d.layers.push(l); + } + let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.chunks, 1); + assert!(stats.flushes > 0, "{stats:?}"); +} + fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer { let mut l = Layer::raster(name, fmt); let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32); From 3e428e7302f556ad8e6e48bca51dff1091a63254 Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:28 +0200 Subject: [PATCH 118/523] perf(gpu): a duplicated layer's texture starts as a GPU copy of its source's (#3848) A duplicate shares its source's tiles (Arc), but its resident texture was new, so the compositor converted and uploaded every tile again: duplicating a 20 MP photo layer uploaded 330 tiles. When a new resident's surface shares tiles with another resident of the same role, region, texel kind, format and default pixel, its texture now starts as a copy of that one (Stats.tiles_shared counts the tiles taken) and the usual per-tile pass fixes up whatever differs. Layer textures gain COPY_SRC. The copy is recorded in the frame's encoder, after whatever brought the donor up to date this frame. Queued writes land before the encoder runs, so both textures are marked `ordered` and their writes go through the encoder from then on; otherwise a write to either in the same refresh would be overwritten by the copy or leak into it. A resident whose region only moved still keeps its own texels (keep_overlap) first. Duplicating a 20 MP photo layer in perf_scenarios: 25.1 -> 6.3 ms, 330 tile uploads -> 0 (RTX 3090, Vulkan). Paste is unchanged: the pasted layer has new tiles. Test: duplicates_copy_their_source_texture covers a plain duplicate (no uploads), a duplicate and its source each painted with one tile in the same refresh, and a duplicate missing a tile, all against the CPU composite. Without the `ordered` routing the second case is off by 167.6/255. --- crates/gpu/src/lib.rs | 106 +++++++++++++++++++++++++++---------- crates/gpu/tests/parity.rs | 36 +++++++++++++ 2 files changed, 115 insertions(+), 27 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 854db1b91d..992e26dc31 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -105,6 +105,9 @@ pub struct Stats { pub slots: u32, pub tiles_uploaded: usize, pub bytes_uploaded: usize, + /// Tiles a new layer texture took from another layer's texture by a GPU copy (a duplicate + /// shares its source's tiles) instead of uploading them. + pub tiles_shared: usize, /// Effect layers whose shape was (re)rendered, fully or partially. pub fx_shapes: usize, /// Effect map programs run (one per effect whose maps were rebuilt). @@ -193,6 +196,10 @@ struct Resident { stamp: u64, /// CMYK profile the texels were converted with (`CmykSpace::id`, 0 = built-in / not CMYK). cmyk: u64, + /// Its texels were copied to or from another layer texture by the encoder, so every write + /// to it goes through the encoder too: a queued write lands before the encoder runs, and + /// would be overwritten by the copy, or leak into it. + ordered: bool, } impl Resident { @@ -1353,37 +1360,80 @@ impl Compositor { }); let view = texture.create_view(&Default::default()); let default_nonzero = default_pixel.iter().any(|v| *v != 0.0); - let mut r = - Resident { texture, view, region, kind, format, tiles: HashMap::new(), default_nonzero, default_bits, doc, last_used: 0, stamp: 0, cmyk }; + let mut r = Resident { + texture, + view, + region, + kind, + format, + tiles: HashMap::new(), + default_nonzero, + default_bits: default_bits.clone(), + doc, + last_used: 0, + stamp: 0, + cmyk, + ordered: false, + }; // Only the region moved (painting past a layer's tiles grows it): keep the texels the // old texture already holds instead of converting and uploading every tile again. let kept = self.residents.remove(&key).map_or(Rect::EMPTY, |old| keep_overlap(device, queue, &old, &mut r)); - // A new texture starts zeroed: write the tiles that are present, and the default pixel - // where they are missing if it isn't zero. Coordinates sharing a tile (a solid fill, - // the default) take one upload and GPU copies (#1774). Kept tiles are brought up to - // date below, like those of a resident that didn't move. - let mut present = Vec::new(); - let mut missing = Vec::new(); - for c in region.tiles().filter(|c| !kept.contains_rect(&c.rect())) { - match surface.tile(c) { - Some(t) => present.push((c, t.clone())), - None if default_nonzero => missing.push(c), - None => {} + // A new layer whose tiles another resident already holds (a duplicate shares its + // source's tiles) starts as a GPU copy of that texture. Duplicating a 20 MP photo + // converted and uploaded every tile again. + let shares = + |o: &Resident| region.tiles().filter(|c| surface.tile(*c).is_some_and(|t| o.tiles.get(c).is_some_and(|ot| Arc::ptr_eq(ot, t)))).count(); + let donor = self + .residents + .iter() + .filter(|(k, o)| k.1 == key.1 && o.region == region && o.kind == kind && o.format == format && o.cmyk == cmyk && o.default_bits == default_bits) + .map(|(k, o)| (shares(o), *k)) + .filter(|(n, _)| *n > 0) + .max_by_key(|(n, _)| *n); + if kept.is_empty() + && let Some((n, o)) = donor.and_then(|(n, k)| Some((n, self.residents.get_mut(&k)?))) + { + // Recorded in the frame's encoder, after the writes and copies that brought the + // donor up to date ([`keep_overlap`]'s own submission would run before those). + o.ordered = true; + encoder.copy_texture_to_texture( + o.texture.as_image_copy(), + r.texture.as_image_copy(), + wgpu::Extent3d { width: region.width(), height: region.height(), depth_or_array_layers: 1 }, + ); + r.tiles = o.tiles.clone(); + r.ordered = true; + stats.tiles_shared += n; + } else { + // A new texture starts zeroed: write the tiles that are present, and the default + // pixel where they are missing if it isn't zero. Coordinates sharing a tile (a + // solid fill, the default) take one upload and GPU copies (#1774). Kept tiles are + // brought up to date below, like those of a resident that didn't move. + let mut present = Vec::new(); + let mut missing = Vec::new(); + for c in region.tiles().filter(|c| !kept.contains_rect(&c.rect())) { + match surface.tile(c) { + Some(t) => present.push((c, t.clone())), + None if default_nonzero => missing.push(c), + None => {} + } } - } - if !missing.is_empty() { - let bytes = convert_tile(surface, None, kind, TileCoord::new(0, 0)); - self.staged += put_tiles(device, queue, encoder, &r, &missing, &bytes); - } - let staged = &mut self.staged; - convert_tiles(surface, present, kind, |t, cs, bytes| { - *staged += put_tiles(device, queue, encoder, &r, cs, bytes); - for &c in cs { - stats.tiles_uploaded += 1; - stats.bytes_uploaded += bytes.len(); - r.tiles.insert(c, t.clone()); + if !missing.is_empty() { + let bytes = convert_tile(surface, None, kind, TileCoord::new(0, 0)); + self.staged += put_tiles(device, queue, encoder, &r, &missing, &bytes); } - }); + let staged = &mut self.staged; + convert_tiles(surface, present, kind, |t, cs, bytes| { + *staged += put_tiles(device, queue, encoder, &r, cs, bytes); + for &c in cs { + stats.tiles_uploaded += 1; + stats.bytes_uploaded += bytes.len(); + r.tiles.insert(c, t.clone()); + } + }); + } + // Tiles the donor held that this surface doesn't, or holds differently, are fixed up + // by the pass over the region below. self.residents.insert(key, r); } // Present: inserted above when it was missing or stale. @@ -1829,7 +1879,9 @@ const CONVERT_BATCH: usize = 64; /// a scratch tile and GPU copies from it: writing each one from the CPU sent 387 MB per refresh /// of a filled 50 MP 16-bit page (#1774). fn put_tiles(device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, r: &Resident, cs: &[TileCoord], bytes: &[u8]) -> u64 { - if let [c] = cs { + if let [c] = cs + && !r.ordered + { write_tile(queue, r, *c, bytes); return bytes.len() as u64; } diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 1bdd597a36..47837a8b65 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -900,6 +900,42 @@ fn incremental_updates_follow_the_document() { check(&mut g, &d, "opacity"); } +/// A duplicate shares its source's tiles: its texture is a GPU copy of the source's, not an +/// upload. Tiles written afterwards, to the copy or to the source in the same refresh, must not be +/// lost to the copy or leak through it (queued writes land before the encoder's copy). +#[test] +fn duplicates_copy_their_source_texture() { + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(600, 300); + let mut src = noise_layer("src", PixelFormat::RGBA8, Rect::new(0, 0, 600, 300), 71, 0.0); + src.opacity = 0.7; + d.layers.push(src); + check(&mut g, &d, "source"); + // A duplicate with no other change: nothing uploads. + let mut dup = d.layers[1].duplicate(); + dup.blend = BlendMode::Difference; + d.layers.push(dup); + let stats = diff_rect(&mut g, &d, d.bounds(), "duplicate").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.tiles_uploaded, 0, "{stats:?}"); + assert!(stats.tiles_shared > 0, "{stats:?}"); + // Another duplicate, then one tile painted on it and one on its source, in the same refresh. + let mut dup2 = d.layers[1].duplicate(); + dup2.blend = BlendMode::Screen; + dup2.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + d.layers.push(dup2); + d.layers[1].surface_mut().unwrap().fill_rect(Rect::new(300, 100, 310, 110), &[0.0, 0.0, 1.0, 1.0]); + check(&mut g, &d, "duplicate and source painted"); + // And a tile removed from a duplicate: it reads as transparent again. + let mut dup3 = d.layers[1].duplicate(); + *dup3.surface_mut().unwrap() = { + let mut s = photocraft_raster::Surface::new(PixelFormat::RGBA8); + s.put_tiles(d.layers[1].surface().unwrap().tiles().skip(1).map(|(c, t)| (*c, t.clone()))); + s + }; + d.layers.push(dup3); + check(&mut g, &d, "duplicate missing a tile"); +} + /// #2422: a mask whose default pixel changes without touching its tiles (Invert on a sparse mask) /// must re-upload the absent tiles between its stored ones, not keep the old default on the GPU. #[test] From 05c4cd685eac3cc8dc58e68169649bb72f5fa101 Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:53 +0200 Subject: [PATCH 119/523] perf: the Navigator redraws only the area an edit changed (#3845) With the Navigator open, every edit re-rendered the whole document into a new thumbnail on the UI thread: a full-resolution CPU composite, 303 ms after an opacity change and 486 ms after a brush stroke on a 20 MP layered document. The navigator cache now records the document revision of its image. When the document is exactly one edit ahead, with the same preview key, a bounded last_damage and the same thumbnail size, only the damage (grown by the layers' effect reach) is rendered with render_reduced_damage and written into the texture with set_partial; an edit with empty damage (a rename, a selection, a new empty layer) costs nothing. Anything else renders the whole thumbnail as before. compose gains thumbnail_size, which thumbnail_buffer now uses, so both paths agree on the size. On the 20 MP document: 303 -> 36 ms after an opacity change, 486 -> 171 ms after a stroke (RTX 3090 machine; the navigator renders on the CPU). Test: navigator_damage_updates_match_a_full_thumbnail patches a thumbnail after a translate, an opacity change, a stroke, a blend mode change and a new layer on a document with a drop shadow, and compares it with a full thumbnail; it fails without the effect-reach inflation. --- crates/compose/src/lib.rs | 12 ++++-- crates/ui-egui/src/canvas.rs | 79 ++++++++++++++++++++++++++++++++---- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c059d9c8b2..08dcdeaf6a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -313,13 +313,19 @@ pub fn thumbnail(doc: &Document, max_side: u32) -> Rgba8Image { thumbnail_buffer(doc, max_side).to_rgba8() } +/// Width and height of [`thumbnail`]`(doc, max_side)`. +pub fn thumbnail_size(doc: &Document, max_side: u32) -> (u32, u32) { + let b = doc.bounds(); + let longest = b.width().max(b.height()).max(1); + let scale = (max_side as f32 / longest as f32).min(1.0); + (((b.width() as f32 * scale).round() as u32).max(1), ((b.height() as f32 * scale).round() as u32).max(1)) +} + /// [`thumbnail`] as straight-alpha floats (for callers that colour-convert it first). pub fn thumbnail_buffer(doc: &Document, max_side: u32) -> Buffer { let b = doc.bounds(); let longest = b.width().max(b.height()).max(1); - let scale = (max_side as f32 / longest as f32).min(1.0); - let w = ((b.width() as f32 * scale).round() as u32).max(1); - let h = ((b.height() as f32 * scale).round() as u32).max(1); + let (w, h) = thumbnail_size(doc, max_side); let k = longest / max_side.max(1).saturating_mul(2); if k >= 2 && doc.size.area() > PROXY_THUMBNAIL_PIXELS && proxy::proxy_faithful(doc) { render_reduced(&proxy::proxy_document(doc, k), w, h) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c9e4c6ce9c..bd3b64f329 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1080,7 +1080,8 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar /// Longest side of the Navigator panel's image (about twice the panel's width, for HiDPI). pub const NAVIGATOR_SIDE: u32 = 512; -pub(crate) type NavigatorCache = (std::sync::Weak, u64, egui::TextureHandle); +/// The navigator image of a document: its snapshot, preview key and revision, and the texture. +pub(crate) type NavigatorCache = (std::sync::Weak, u64, u64, egui::TextureHandle); /// How long adjustment-dialog settings must stay unchanged before the navigator shows them. const NAVIGATOR_SETTLE_MS: f64 = 200.0; @@ -1099,25 +1100,26 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz return ensure_texture(app, ctx, idx, app.session.color.main_display).map(|(t, _)| t); } // Keyed by the document snapshot, not its revision: selecting a layer changes no pixels. - let (snapshot, id) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id))?; + let (snapshot, id, revision, last_damage) = + app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id, st.revision, st.last_damage))?; let (doc, preview_key) = display_doc(app, idx); let (display, display_key) = canvas_display(app, &doc, None); let preview_key = preview_key ^ display_key; let cached = app.navigator_textures.get(&id).cloned(); - if let Some((r, p, t)) = &cached + if let Some((r, p, _, t)) = &cached && r.ptr_eq(&snapshot) && *p == preview_key { return Some(t.id()); } - if let Some((_, _, t)) = &cached + if let Some((_, _, _, t)) = &cached && navigator_waits_for_drag(app, idx) { return Some(t.id()); } // While an adjustment dialog's settings are changing, the navigator keeps its image and // catches up once they settle (a thumbnail composite per change would cost more than the canvas). - if let Some((r, _, t)) = &cached + if let Some((r, _, _, t)) = &cached && r.ptr_eq(&snapshot) && crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS) { @@ -1125,16 +1127,38 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz return Some(t.id()); } let t0 = crate::gpu_canvas::now_ms(); + // One edit since the cached image, with the area it changed: update just that part (nothing + // for a rename, a selection or a new empty layer). A thumbnail composite of the whole + // document on every edit cost a full-resolution CPU composite on the UI thread. + let size = photocraft_compose::thumbnail_size(&doc, NAVIGATOR_SIDE); + if let Some((_, p, rev, t)) = &cached + && *p == preview_key + && rev.checked_add(1) == Some(revision) + && let Some(d) = last_damage + && t.size() == [size.0 as usize, size.1 as usize] + { + let mut t = t.clone(); + if !d.is_empty() { + let buf = photocraft_compose::render_reduced_damage(&doc, size.0, size.1, d.inflate(effect_reach(&doc.layers))); + let r = buf.rect; + if !r.is_empty() { + t.set_partial([r.x0 as usize, r.y0 as usize], display_image(display.as_deref(), &buf), TextureOptions::LINEAR); + } + } + app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0); + app.navigator_textures.insert(id, (snapshot, preview_key, revision, t.clone())); + return Some(t.id()); + } let image = display_image(display.as_deref(), &photocraft_compose::thumbnail_buffer(&doc, NAVIGATOR_SIDE)); let tex = match cached { - Some((_, _, mut t)) => { + Some((_, _, _, mut t)) => { t.set(image, TextureOptions::LINEAR); t } None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR), }; app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0); - app.navigator_textures.insert(id, (snapshot, preview_key, tex.clone())); + app.navigator_textures.insert(id, (snapshot, preview_key, revision, tex.clone())); Some(tex.id()) } @@ -5499,7 +5523,7 @@ mod tests { // The same handle ownership as navigator_texture, without requiring a GPU adapter. let tex = ctx.load_texture("navigator-test", egui::ColorImage::filled([64, 64], egui::Color32::WHITE), TextureOptions::LINEAR); let nav = tex.id(); - app.navigator_textures.insert(first.id, (std::sync::Arc::downgrade(&first), 0, tex)); + app.navigator_textures.insert(first.id, (std::sync::Arc::downgrade(&first), 0, 0, tex)); app.run("file.new", json!({"width":32,"height":32})).unwrap(); let other = app.session.active().unwrap().doc.id; let b = ensure_texture(&mut app, &ctx, 1, Some(1)).unwrap().0; @@ -5591,6 +5615,45 @@ mod tests { assert_eq!(texture_key(canvas_display(&app, &doc, Some(1)).0.as_deref()), texture_key(canvas_display(&app, &doc, Some(4)).0.as_deref())); } + /// The navigator's update after an edit (its image with the edit's damage, grown by the + /// effect reach, rendered over it) equals a new thumbnail of the edited document. + #[test] + fn navigator_damage_updates_match_a_full_thumbnail() { + use photocraft_compose::{render_reduced_damage, thumbnail_buffer, thumbnail_size}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 1500, "height": 900, "background": "white"})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 200, "y": 150, "width": 300, "height": 200})).unwrap(); + app.run("edit.fill", json!({"color": "#3070a0"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + app.run("layer.layerStyle.dropShadow", json!({"distance": 30, "size": 20})).unwrap(); + let steps = [ + ("layer.translate", json!({"dx": 37, "dy": -11})), + ("layer.setProps", json!({"opacity": 0.4})), + ("paint.stroke", json!({"points": [[700, 500], [900, 520]], "size": 25})), + ("layer.setProps", json!({"blend": "Multiply"})), + ("layer.new.layer", json!({})), + ]; + for (id, p) in steps { + let before = app.session.active().unwrap().doc.clone(); + app.run(id, p).unwrap(); + let st = app.session.active().unwrap(); + let d = st.last_damage.unwrap_or_else(|| panic!("{id}: a bounded damage")); + let (w, h) = thumbnail_size(&st.doc, NAVIGATOR_SIDE); + let mut shown = thumbnail_buffer(&before, NAVIGATOR_SIDE); + assert_eq!((shown.rect.width(), shown.rect.height()), (w, h)); + if !d.is_empty() { + let patch = render_reduced_damage(&st.doc, w, h, d.inflate(effect_reach(&st.doc.layers))); + let r = patch.rect; + for y in 0..r.height() as usize { + let at = (r.y0 as usize + y) * w as usize + r.x0 as usize; + shown.px[at..at + r.width() as usize].copy_from_slice(&patch.px[y * r.width() as usize..][..r.width() as usize]); + } + } + assert!(shown.px == thumbnail_buffer(&st.doc, NAVIGATOR_SIDE).px, "{id}: the updated navigator differs from a new thumbnail"); + } + } + #[test] fn tab_context_uses_clicked_document_and_existing_close_commands() { let items = tab_context_items(2, 3, true); From 286ff7353340ea7b2f83d632f8fc223edc6cee4e Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:47:05 +0200 Subject: [PATCH 120/523] perf: edits, undo and redo recomposite only where added or removed layers draw (#3847) Session::edit reported damage only for edits that changed no pixels (same_pixels) and left every other edit to recomposite the whole document unless the command reported its own damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and the undo/redo of a duplicate, a paste or a delete all recomposited everything. - Session::edit now reports step_damage(before, after), the same bound undo and redo use. - step_damage bounds layers only one side has in both directions, so a removed layer counts where it drew like an added one; the bounded_additions flag and insert_damage go, and added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of clipped layers above it, an adjustment, a video layer or one excluding channels still recomposites everything (#3090). - The subject-mask cache is dropped only when the step can have changed pixels. perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan): delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square 17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded). Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its undo and its redo report the same bounded damage and that no pixel outside it changes. new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage for a painted or empty new layer and None for a new clipping base or an adjustment; deleting and restoring a layer reports its bounds. --- .../engine/src/layer_mask_props_cmds/tests.rs | 6 +- crates/engine/src/layer_multi_cmds.rs | 155 ++++++++++++------ crates/engine/src/lib.rs | 15 +- 3 files changed, 115 insertions(+), 61 deletions(-) diff --git a/crates/engine/src/layer_mask_props_cmds/tests.rs b/crates/engine/src/layer_mask_props_cmds/tests.rs index b183b1f879..cd32de150f 100644 --- a/crates/engine/src/layer_mask_props_cmds/tests.rs +++ b/crates/engine/src/layer_mask_props_cmds/tests.rs @@ -44,7 +44,11 @@ fn edits_preserve_mask_pixels_and_layer_at_all_depths_with_undo_redo() { assert_eq!(*s.active().unwrap().doc, expected, "only density and feather change"); assert_eq!(s.active().unwrap().active_layer, Some(background)); assert_eq!(s.active().unwrap().history.entries().len(), count + 1); - assert!(s.active().unwrap().last_damage.is_none(), "properties invalidate the composite"); + // Properties invalidate the composite as far as their undo would (the masked layer is + // empty here; the adjustment reaches everything). + let damage = s.active().unwrap().last_damage; + assert_eq!(damage, crate::layer_multi_cmds::step_damage(&before, &s.active().unwrap().doc)); + assert_eq!(damage.is_none(), adjustment); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(*s.active().unwrap().doc, *before); s.execute("edit.redo", json!({})).unwrap(); diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index f0bf4a503e..2d51dfdf0c 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -667,35 +667,31 @@ pub fn same_pixels(a: &Document, b: &Document) -> bool { } /// Pixels a single history step can have changed between `a` and `b` (the documents on either -/// side of an undo or redo): the union of the change bounds of every layer whose pixels -/// differ, so undoing a local edit recomposites only that area instead of the whole canvas. -/// `None` (composite everything, as before) when the step touched anything the layer walk -/// cannot bound: document-level fields the compositor reads (canvas size, mode, depth, -/// profile, global light, patterns, channels, palettes), the layer structure (order, -/// additions, removals, kind changes; adding or removing a layer that draws nothing, see -/// [`inert_at`], is no change), layer properties that reach outside their own bounds -/// (clipping, excluded channels — what `layer.setProps` already reports as unbounded), and -/// video layers. -pub fn step_damage(a: &Document, b: &Document) -> Option { - damage_between(a, b, false) -} - -/// [`step_damage`] for an edit from `before` to `after` that may also have added layers which -/// change pixels only within their own bounds (see [`added_bounds`]), as Duplicate Layer does: -/// each contributes its change bounds instead of making the whole canvas recomposite. Inserting a -/// copy of a 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in +/// side of an edit, its undo or its redo): the union of the change bounds of every layer whose +/// pixels differ, so a local edit recomposites only that area instead of the whole canvas. A +/// layer only one side has (a duplicate, a paste, a deleted layer) counts where it draws (see +/// [`one_sided_bounds`]); one that draws nothing ([`inert_at`]) changes nothing. `None` +/// (composite everything) when the step touched anything the layer walk cannot bound: +/// document-level fields the compositor reads (canvas size, mode, depth, profile, global light, +/// patterns, channels, palettes), layer order and kind changes, layers added or removed that +/// reach outside their own bounds, layer properties that do (clipping, excluded channels — +/// what `layer.setProps` already reports as unbounded), and video layers. Inserting a copy of a +/// 12 MP layer used to recomposite every layer of the document (#2883, P8/P9 in /// `perf/budgets.toml`). -pub(crate) fn insert_damage(before: &Document, after: &Document) -> Option { - damage_between(before, after, true) -} - -fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option { +pub fn step_damage(a: &Document, b: &Document) -> Option { if !doc_pixels_equal(a, b) { return None; } - fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, added: bool, out: &mut Rect) -> bool { - // Pair the layers by id, stepping over layers only one side has when they draw nothing - // (a new empty layer or group, #1771): adding or deleting one changes no pixels. + fn walk(xs: &[Layer], ys: &[Layer], a: &Document, b: &Document, out: &mut Rect) -> bool { + // Pair the layers by id, stepping over layers only one side has: one that draws nothing + // changes no pixels (a new empty layer or group, #1771), any other only where it draws. + let one_sided = |layers: &[Layer], k: usize, canvas: Rect, out: &mut Rect| { + let Some(r) = one_sided_bounds(layers, k, canvas) else { return false }; + if !r.is_empty() { + *out = if out.is_empty() { r } else { out.union(&r) }; + } + true + }; let (mut i, mut j) = (0, 0); loop { let (x, y) = match (xs.get(i), ys.get(j)) { @@ -705,19 +701,25 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option j += 1; continue; } - _ if added && ys.get(j).is_some_and(|y| xs.iter().all(|x| x.id != y.id)) => { - // A layer only `b` has: bounded when it is self-contained. - let Some(r) = added_bounds(ys, j, b.bounds()) else { return false }; - if !r.is_empty() { - *out = if out.is_empty() { r } else { out.union(&r) }; - } - j += 1; - continue; - } _ if inert_at(xs, i) => { i += 1; continue; } + (_, Some(y)) if xs.iter().all(|x| x.id != y.id) => { + if !one_sided(ys, j, b.bounds(), out) { + return false; + } + j += 1; + continue; + } + (Some(x), _) if ys.iter().all(|y| y.id != x.id) => { + if !one_sided(xs, i, a.bounds(), out) { + return false; + } + i += 1; + continue; + } + // Both sides have the layer, in another order. _ => return false, }; i += 1; @@ -739,7 +741,7 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option } match (x.children(), y.children()) { (Some(xc), Some(yc)) => { - if !walk(xc, yc, a, b, added, out) { + if !walk(xc, yc, a, b, out) { return false; } } @@ -749,15 +751,15 @@ fn damage_between(a: &Document, b: &Document, bounded_additions: bool) -> Option } } let mut out = Rect::EMPTY; - if walk(&a.layers, &b.layers, a, b, bounded_additions, &mut out) { Some(out) } else { None } + if walk(&a.layers, &b.layers, a, b, &mut out) { Some(out) } else { None } } -/// The pixels adding `layers[k]` can change, when that is bounded: its change bounds +/// The pixels adding or removing `layers[k]` can change, when that is bounded: its change bounds /// (`photocraft_compose::change_bounds`: not for an adjustment, or a pass-through group holding -/// one), provided it doesn't become the clipping base of clipped layers above it, and neither it -/// nor anything inside it is a video layer or excludes channels (both reach outside the layer's -/// bounds, see [`step_damage`]). -fn added_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { +/// one), provided it isn't the clipping base of clipped layers above it (they would change base), +/// and neither it nor anything inside it is a video layer or excludes channels (both reach +/// outside the layer's bounds, see [`step_damage`]). +fn one_sided_bounds(layers: &[Layer], k: usize, canvas: Rect) -> Option { fn contained(l: &Layer, depth: usize) -> bool { depth < 256 && l.video.is_none() && l.excluded_channels == 0 && l.children().is_none_or(|c| c.iter().all(|c| contained(c, depth + 1))) } @@ -804,11 +806,12 @@ pub(crate) fn note_inert_insert(s: &mut Session, id: LayerId) { } } -/// Report the pixels an edit from `before` that added layers changed ([`insert_damage`]), so -/// the canvas recomposites only where the new layers draw instead of the whole document. +/// Report the pixels an edit from `before` that added layers changed ([`step_damage`]), for +/// commands made of more than one edit, so the canvas recomposites only where the new layers +/// draw instead of the whole document. pub(crate) fn note_insert(s: &mut Session, before: &Document) { let Some(st) = s.active_mut() else { return }; - st.last_damage = insert_damage(before, &st.doc); + st.last_damage = step_damage(before, &st.doc); } /// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their @@ -1588,7 +1591,7 @@ mod tests { } #[test] - fn new_layers_that_change_pixels_still_recomposite_everything() { + fn new_layers_that_change_clipping_recomposite_everything() { let mut s = session(8); let base = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); let clipped = rect_layer(&mut s, Rect::new(0, 0, 100, 80)); @@ -1612,21 +1615,33 @@ mod tests { assert_eq!(step_damage(&before, &split), None); assert_eq!(step_damage(&split, &before), None); s.execute("edit.undo", json!({})).unwrap(); - // A step that adds a layer with pixels (Layer via Copy, paste…) is not inert either. + // A step that adds a layer with pixels (Layer via Copy, paste…) changes where it draws. let before = doc(&s).clone(); let mut after = before.clone(); let mut l = Layer::raster("painted", after.pixel_format()); l.surface_mut().unwrap().fill_rect(Rect::new(1, 1, 2, 2), &[0.0, 1.0, 0.0, 1.0]); after.insert_above(None, l); - assert_eq!(step_damage(&before, &after), None); - assert_eq!(step_damage(&after, &before), None); - // Nor, conservatively, an empty layer in another blend mode. + assert_eq!(step_damage(&before, &after), Some(Rect::new(1, 1, 2, 2))); + assert_eq!(step_damage(&after, &before), Some(Rect::new(1, 1, 2, 2))); + // Unless it becomes the base of clipped layers above it. + let mut based = before.clone(); + let mut l = Layer::raster("base", based.pixel_format()); + l.surface_mut().unwrap().fill_rect(Rect::new(1, 1, 2, 2), &[0.0, 1.0, 0.0, 1.0]); + let under = based.layers.iter().position(|l| l.id == clipped).unwrap(); + based.layers.insert(under, l); + assert_eq!(step_damage(&before, &based), None); + assert_eq!(step_damage(&based, &before), None); + // An adjustment reaches every pixel below it. + let mut adjusted = before.clone(); + adjusted.insert_above(None, Layer::new("inv", LayerContent::Adjustment(photocraft_doc::Adjustment::Invert))); + assert_eq!(step_damage(&before, &adjusted), None); + assert_eq!(step_damage(&adjusted, &before), None); + // Empty layers draw nothing, in any blend mode. let mut styled = before.clone(); let mut l = Layer::raster("styled", styled.pixel_format()); l.blend = photocraft_doc::BlendMode::Multiply; styled.insert_above(None, l); - assert_eq!(step_damage(&before, &styled), None); - // An empty layer in Normal mode is. + assert_eq!(step_damage(&before, &styled), Some(Rect::EMPTY)); let mut plain = before.clone(); plain.insert_above(None, Layer::raster("plain", plain.pixel_format())); assert_eq!(step_damage(&before, &plain), Some(Rect::EMPTY)); @@ -1669,6 +1684,40 @@ mod tests { } } + /// The composite of `after` equals `before`'s outside `damage`. + fn unchanged_outside(before: &Document, after: &Document, damage: Rect) -> bool { + let (x, y) = (photocraft_compose::render(before, before.bounds()), photocraft_compose::render(after, after.bounds())); + let w = before.bounds().width() as i32; + x.px.iter().zip(&y.px).enumerate().all(|(i, (p, q))| p == q || damage.contains(i as i32 % w, i as i32 / w)) + } + + /// Duplicate, paste and delete, and their undo and redo, recomposite only where the layer + /// draws; undoing a duplicate used to recomposite the whole document. + #[test] + fn duplicate_paste_and_delete_report_where_the_layer_draws() { + let mut s = session(8); + let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20)); + let full = doc(&s).bounds(); + let step = |s: &mut Session, id: &str, p: serde_json::Value| { + let before = doc(s).clone(); + s.execute(id, p).unwrap(); + let d = damage(s).unwrap_or_else(|| panic!("{id}: unbounded damage")); + assert!(d.width() < full.width(), "{id}: {d:?}"); + assert!(unchanged_outside(&before, doc(s), d), "{id}: pixels changed outside {d:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(damage(s), Some(d), "undo {id}"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(damage(s), Some(d), "redo {id}"); + }; + step(&mut s, "layer.duplicate", json!({"layer": a.0})); + s.execute("layer.select", json!({"layer": a.0})).unwrap(); + s.execute("select.rect", json!({"x": 12, "y": 12, "width": 4, "height": 4})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + step(&mut s, "edit.paste", json!({})); + step(&mut s, "layer.delete", json!({"layer": a.0})); + } + #[test] fn the_move_preview_matches_the_move() { let mut s = session(8); @@ -2205,12 +2254,12 @@ mod tests { assert_ne!(doc(&s).size, photocraft_doc::Size::new(100, 80)); s.undo(); assert_eq!(damage(&s), None, "a size change recomposites the whole canvas"); - // A structural change (a layer removed and restored) neither. + // A layer removed and restored changes only where it draws. let b = rect_layer(&mut s, Rect::new(0, 0, 5, 5)); select_all(&mut s, &[b]); s.execute("layer.delete", json!({})).unwrap(); s.undo(); - assert_eq!(damage(&s), None); + assert_eq!(damage(&s), Some(Rect::new(0, 0, 5, 5))); } #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21f3c8adb0..c9cb725518 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -644,11 +644,12 @@ impl Session { st.color_samplers.retain(|p| bounds.contains(p[0].floor() as i32, p[1].floor() as i32)); samplers_removed = n - st.color_samplers.len(); } - // An edit that changes no pixels (a selection, guides, a layer's name) leaves the canvas - // as it is; anything else recomposites the whole document unless the command reports - // its own damage after this. A new lasso selection on a large document used to cost a - // full recomposite (seconds on the CPU path, #1773). - let unchanged = layer_multi_cmds::same_pixels(&before, &st.doc); + // The pixels the edit can have changed, as its undo would report them: nothing for a + // selection, guides or a layer's name (a new lasso selection used to recomposite a large + // document, seconds on the CPU path, #1773), a new or deleted layer's bounds (a + // duplicate or a paste), and everything when that can't be bounded. A command may still + // report its own, tighter damage after this. + let damage = layer_multi_cmds::step_damage(&before, &st.doc); let key = self.coalesce_request.clone(); let st = self.active_mut().ok_or(EngineError::NoDocument)?; let layers = st.layer_target(); @@ -661,9 +662,9 @@ impl Session { } st.coalesce = key; st.revision += 1; - st.last_damage = unchanged.then_some(photocraft_geom::Rect::EMPTY); + st.last_damage = damage; self.samplers_removed += samplers_removed; - if !unchanged { + if damage.is_none_or(|d| !d.is_empty()) { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); } From de22bca76005262a669f2e2da76a542b0806fb06 Mon Sep 17 00:00:00 2001 From: Diego Chagas Date: Mon, 12 Oct 2026 09:51:56 -0300 Subject: [PATCH 121/523] Remove tool: Create a new layer, and Remove after each stroke off (#1750) (#3808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above the target, only where the hole is, leaving the target as it was (with Sample All Layers it fills from the composite). The options bar shows Photoshop's three options; with Remove after each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them. --- crates/engine/src/retouch_cmds.rs | 2 +- crates/engine/src/retouch_cmds/remove.rs | 108 ++++++++++++-- crates/engine/src/retouch_cmds/tests.rs | 60 ++++++++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 8 ++ crates/ui-egui/src/retouch_ui.rs | 175 ++++++++++++++++++++++- crates/ui-egui/src/shortcuts.rs | 11 ++ crates/ui-egui/src/state.rs | 7 + docs/scorecard.md | 2 +- docs/target-app-parity.md | 5 +- scorecard/tools.toml | 2 +- 28 files changed, 414 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 9923f7a3dd..833e2db62e 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -1170,7 +1170,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false → {"damage"} (a background job)"# + r#","strokes":[[[x,y,pressure?],…],…]? (several strokes removed together, as one job and one undo step; else "points"),"closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false,"newLayer":bool=false (the fill goes to a new layer above the target, which stays as it was) → {"damage","layer"?} (a background job)"# ), enabled: has_pixel_layer, run: remove::remove, diff --git a/crates/engine/src/retouch_cmds/remove.rs b/crates/engine/src/retouch_cmds/remove.rs index 889cd2c1d3..4e256fb7fc 100644 --- a/crates/engine/src/retouch_cmds/remove.rs +++ b/crates/engine/src/retouch_cmds/remove.rs @@ -7,10 +7,17 @@ //! selection, when there is one, limits what changes. Large holes take seconds, so the command //! runs as a background job (#210): with progress, cancellable, and the document unchanged until //! it applies, in one undo step. +//! +//! Several strokes can go in one removal (`strokes`): Photoshop's *Remove after each stroke* +//! off, where the strokes collect and are applied together, so the fill sees every hole at once +//! instead of sampling from an area the next stroke is about to remove. With `newLayer` the fill +//! lands on a new layer above the target, only where the hole is, and the target stays as it was +//! (Photoshop's *Create a new layer*; with Sample All Layers it fills from the composite). use super::content_aware_move::{Target, replace}; use super::patch::coverage; use super::*; +use photocraft_doc::Layer; const CMD: &str = "paint.remove"; @@ -38,11 +45,14 @@ fn sampling_margin(area: Rect) -> i32 { /// What a removal does, checked before any work starts. struct Plan { id: Option, - stroke: Stroke, - /// Where the fill samples from: the stroke plus a margin, on the canvas. + /// One stroke (`points`), or several applied together (`strokes`). + strokes: Vec, + /// Where the fill samples from: the strokes plus a margin, on the canvas. window: Rect, close_loops: bool, all_layers: bool, + /// The fill goes to a new layer above the target instead of into it. + new_layer: bool, } /// The stroke's bounding box (points ± the brush radius), before any coverage is rendered. @@ -56,11 +66,37 @@ fn stroke_bounds(stroke: &Stroke) -> Rect { Rect::new((x0 - r).floor() as i32, (y0 - r).floor() as i32, (x1 + r).ceil() as i32, (y1 + r).ceil() as i32) } +/// The strokes of the request: `strokes` (several, each a list of points, sharing the brush +/// and the target) or the one in `points`. +fn parse_strokes(s: &Session, p: &Value) -> Result<(Vec, Option)> { + let Some(list) = p.get("strokes") else { + let (stroke, id) = parse_brush(s, p, CMD)?; + return Ok((vec![stroke], id)); + }; + let list = list.as_array().ok_or_else(|| bad(CMD, "`strokes` is a list of point lists"))?; + if list.is_empty() { + return Err(bad(CMD, "`strokes` is empty")); + } + let mut strokes = Vec::with_capacity(list.len()); + let mut id = None; + for points in list { + let mut one = p.clone(); + if let Some(o) = one.as_object_mut() { + o.remove("strokes"); + o.insert("points".into(), points.clone()); + } + let (stroke, layer) = parse_brush(s, &one, CMD)?; + strokes.push(stroke); + id = layer; + } + Ok((strokes, id)) +} + fn plan(s: &Session, p: &Value) -> Result { - let (stroke, id) = parse_brush(s, p, CMD)?; + let (strokes, id) = parse_strokes(s, p)?; let d = s.active().ok_or(EngineError::NoDocument)?; let canvas = d.doc.bounds(); - let bounds = stroke_bounds(&stroke); + let bounds = strokes.iter().map(stroke_bounds).reduce(|a, b| a.union(&b)).ok_or_else(|| bad(CMD, "`strokes` is empty"))?; let pixels = u64::from(bounds.width()) * u64::from(bounds.height()); if pixels > MAX_PIXELS { return Err(bad(CMD, format!("the stroke is too large to remove ({pixels} pixels, at most {MAX_PIXELS})"))); @@ -72,23 +108,27 @@ fn plan(s: &Session, p: &Value) -> Result { let window = area.inflate(sampling_margin(area).min(MAX_MARGIN)).intersect(&canvas); let close_loops = flag(p, "closeLoops", true); let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { id, stroke, window, close_loops, all_layers }) + // A mask or channel has no "above": the fill goes where the stroke went. + let new_layer = flag(p, "newLayer", false) && targets_pixels(p); + Ok(Plan { id, strokes, window, close_loops, all_layers, new_layer }) } -/// The hole over the plan's window: the stroke's footprint, its enclosed areas and a margin, +/// The hole over the plan's window: the strokes' footprints, their enclosed areas and a margin, /// limited to the selection. Empty when nothing would change. fn hole(plan: &Plan, sel: Option<&Surface>) -> Vec { let win = plan.window; let (w, h) = (win.width() as usize, win.height() as usize); - let (bounds, cov) = stroke_coverage(&plan.stroke); let mut m = vec![false; w * h]; - let bw = bounds.width() as usize; - for (i, c) in cov.iter().enumerate() { - let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); - if *c > 0.0 && win.contains(x, y) { - let j = (y - win.y0) as usize * w + (x - win.x0) as usize; - if let Some(v) = m.get_mut(j) { - *v = true; + for stroke in &plan.strokes { + let (bounds, cov) = stroke_coverage(stroke); + let bw = bounds.width() as usize; + for (i, c) in cov.iter().enumerate() { + let (x, y) = (bounds.x0 + (i % bw.max(1)) as i32, bounds.y0 + (i / bw.max(1)) as i32); + if *c > 0.0 && win.contains(x, y) { + let j = (y - win.y0) as usize * w + (x - win.x0) as usize; + if let Some(v) = m.get_mut(j) { + *v = true; + } } } } @@ -135,6 +175,34 @@ fn run_remove(doc: &mut Document, plan: &Plan, p: &Value, ctx: &crate::jobs::Job Ok(Some((Region { rect: win, ch: img.ch, data }, hole))) } +/// The fill on a new layer above `id`: the hole's pixels (opaque where the source had no alpha), +/// transparent elsewhere. Returns the damage and the new layer. +fn fill_to_new_layer(doc: &mut Document, id: Option, out: &Region, hole: &[bool]) -> Result<(Rect, LayerId)> { + let base = doc.pixel_format(); + let fmt = PixelFormat::new(base.mode, base.sample, true); + let n = fmt.channels(); + let colours = n.saturating_sub(1); + if out.ch != n && out.ch != colours { + return Err(EngineError::Other("internal error: the fill's channels don't match the document".into())); + } + let mut data = vec![0.0f32; hole.len().saturating_mul(n)]; + for ((dst, src), m) in data.chunks_exact_mut(n).zip(out.data.chunks_exact(out.ch)).zip(hole) { + if !*m { + continue; + } + for (d, s) in dst.iter_mut().zip(src.iter().take(colours)) { + *d = *s; + } + if let Some(a) = dst.last_mut() { + *a = if out.ch == n { src.last().copied().unwrap_or(1.0) } else { 1.0 }; + } + } + let mut l = Layer::raster(doc.next_layer_name("Layer"), fmt); + crate::pixels_mut(&mut l)?.write_region(out.rect, &data); + let nid = doc.insert_above(id, l); + Ok((out.rect, nid)) +} + pub(super) fn remove(s: &mut Session, p: &Value) -> Result { let plan = plan(s, p)?; let label = "Remove"; @@ -142,6 +210,7 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result { let doc = d.doc.clone(); let p = p.clone(); let id = plan.id; + let new_layer = plan.new_layer; crate::jobs::run( s, label, @@ -156,6 +225,17 @@ pub(super) fn remove(s: &mut Session, p: &Value) -> Result { }, move |s, filled| { let Some((out, hole)) = filled else { return Ok(json!({"changed": false})) }; + if new_layer { + let (damage, nid) = s.edit(label, |doc, active| { + let r = fill_to_new_layer(doc, id, &out, &hole)?; + *active = Some(r.1); + Ok(r) + })?; + if let Some(st) = s.active_mut() { + st.last_damage = Some(damage); + } + return Ok(json!({ "damage": damage_json(damage), "layer": nid.0 })); + } let damage = s.edit(label, |doc, _| replace(doc, Target { id, all_layers: false }, &p, &out, &hole))?; if let Some(st) = s.active_mut() { st.last_damage = Some(damage); diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 7104c69aa9..a0fac91b2a 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1580,3 +1580,63 @@ fn spot_healing_keeps_film_grain_through_overlapping_strokes() { } } } + +#[test] +fn remove_several_strokes_apply_as_one_job_and_one_undo_step() { + let mut s = square_session(8, "rgb"); + let bg = rgba(&s, 10, 10); + let past = s.active().unwrap().history.past_len(); + // Each stroke covers half of the square; together they remove it. + let r = s.execute("paint.remove", json!({"strokes": [[[38, 30], [45, 30]], [[47, 30], [54, 30]]], "size": 16})).unwrap(); + assert!(r["damage"].is_array(), "{r}"); + for (x, y) in [(41, 30), (46, 30), (51, 30)] { + assert!(near(rgba(&s, x, y), bg, 0.03), "({x}, {y}): {:?} vs {bg:?}", rgba(&s, x, y)); + } + assert_eq!(s.active().unwrap().history.past_len(), past + 1, "one undo step for both strokes"); + for p in [ + json!({"strokes": [], "size": 16}), + json!({"strokes": "x", "size": 16}), + json!({"strokes": [[]], "size": 16}), + json!({"strokes": [[[1, 1]], "x"], "size": 16}), + ] { + assert!(s.execute("paint.remove", p).is_err()); + } +} + +#[test] +fn remove_to_a_new_layer_leaves_the_target_untouched() { + let mut s = square_session(8, "rgb"); + let target = s.active().unwrap().active_layer.unwrap(); + let bg = rgba(&s, 10, 10); + let square = rgba(&s, 46, 30); + let past = s.active().unwrap().history.past_len(); + let r = s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16, "newLayer": true})).unwrap(); + let nid = LayerId(r["layer"].as_u64().expect("the new layer's id")); + let d = s.active().unwrap(); + assert_eq!(d.active_layer, Some(nid), "the new layer is active"); + assert_ne!(nid, target); + let order: Vec = d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); + assert_eq!(order, vec![target, nid], "the new layer sits above the target"); + assert_eq!(d.doc.layer(nid).unwrap().name, "Layer 1"); + // The target keeps its pixels; the new layer carries the fill inside the hole only. + let old = d.doc.layer(target).unwrap().surface().unwrap(); + assert!(near(old.rgba(46, 30), square, 0.001)); + let new = d.doc.layer(nid).unwrap().surface().unwrap(); + assert!(near(new.rgba(46, 30), bg, 0.03), "{:?}", new.rgba(46, 30)); + assert_eq!(new.rgba(10, 10)[3], 0.0, "transparent away from the hole"); + assert_eq!(d.history.past_len(), past + 1); + s.execute("edit.undo", json!({})).unwrap(); + let d = s.active().unwrap(); + assert!(d.doc.layer(nid).is_none(), "undo removes the layer"); + assert_eq!(d.active_layer, Some(target)); + // On an empty layer with Sample All Layers, the composite is what gets filled, onto a new layer. + s.execute("layer.new.layer", json!({})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + let r = s.execute("paint.remove", json!({"points": [[38, 30], [54, 30]], "size": 16, "sampleAllLayers": true, "newLayer": true})).unwrap(); + let nid = LayerId(r["layer"].as_u64().unwrap()); + let d = s.active().unwrap(); + let order: Vec = d.doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); + assert_eq!(order, vec![target, empty, nid]); + assert!(near(d.doc.layer(nid).unwrap().surface().unwrap().rgba(46, 30), bg, 0.03)); + assert!(near(d.doc.layer(target).unwrap().surface().unwrap().rgba(46, 30), square, 0.001)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bd3b64f329..804744d39d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4251,6 +4251,8 @@ fn draw_smoothing_string(painter: &egui::Painter, xf: &ViewXform, (brush, pointe fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos())); + // Remove strokes waiting for ↩ stay tinted between drags. + crate::retouch_ui::draw_remove_pending(app, painter, xf); let Some(d) = &app.drag else { app.trail = None; return; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bb7f34d11d..0b74504d51 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3661,3 +3661,6 @@ navigator Zoom Zvětšení Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá + Remove after each stroke Odstranit po každém tahu + Apply the removal ({key}) Použít odstranění ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 6836a381dc..604dcb43f0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2687,3 +2687,6 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Remove after each stroke Nach jedem Strich entfernen + Apply the removal ({key}) Entfernen anwenden ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e994a26bd5..17677731dd 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3745,3 +3745,6 @@ curves Smooth Εξομάλυνση Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε Light Gray Ανοιχτό γκρι + Remove after each stroke Αφαίρεση μετά από κάθε πινελιά + Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9cfe73f89..7ad6d980e8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2728,3 +2728,6 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Remove after each stroke Eliminar después de cada trazo + Apply the removal ({key}) Aplicar la eliminación ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 90c075c426..7863936bfc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3048,3 +3048,6 @@ curves Smooth Lisser Light Red Rouge clair Medium Blue Bleu moyen Light Gray Gris clair + Remove after each stroke Supprimer après chaque trait + Apply the removal ({key}) Appliquer la suppression ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4ef02d9ecc..74bd4462f5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3035,3 +3035,6 @@ curves Smooth Haluskan Light Red Merah Terang Medium Blue Biru Sedang Light Gray Abu-abu Muda + Remove after each stroke Hapus setelah setiap goresan + Apply the removal ({key}) Terapkan penghapusan ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ad8dc89f5f..3d2a0efb34 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4011,3 +4011,6 @@ navigator Zoom Zoom {index} of {total} {index} di {total} {shortcut} assigned. {shortcut} assegnata. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. + Remove after each stroke Rimuovi dopo ogni tratto + Apply the removal ({key}) Applica la rimozione ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4cc90fa0b4..2196441a31 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3657,3 +3657,6 @@ curves Smooth スムーズ Light Red ライトレッド Medium Blue ミディアムブルー Light Gray ライトグレー + Remove after each stroke ストロークごとに削除 + Apply the removal ({key}) 削除を適用 ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 62495a8d44..9476f0dab4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3217,3 +3217,6 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Remove after each stroke 획마다 제거 + Apply the removal ({key}) 제거 적용 ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c0abbe55ed..fda481dc97 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3807,3 +3807,6 @@ curves Smooth Vloeiend maken Light Red Lichtrood Medium Blue Middelblauw Light Gray Lichtgrijs + Remove after each stroke Verwijderen na elke streek + Apply the removal ({key}) Verwijdering toepassen ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 557b193e11..ed63d43905 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3644,3 +3644,6 @@ curves Smooth Wygładź Light Red Jasnoczerwony Medium Blue Średnioniebieski Light Gray Jasnoszary + Remove after each stroke Usuwaj po każdym pociągnięciu + Apply the removal ({key}) Zastosuj usuwanie ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7aea50bf0..a4242dc8c8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3837,3 +3837,6 @@ layerLabel No Color Sem cor Light Red Vermelho-claro Medium Blue Azul médio Light Gray Cinza-claro + Remove after each stroke Remover após cada traço + Apply the removal ({key}) Aplicar a remoção ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 75d3dd8a84..ead84742f9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2992,3 +2992,6 @@ curves Smooth Сгладить Light Red Светло-красный Medium Blue Средне-синий Light Gray Светло-серый + Remove after each stroke Удалять после каждого штриха + Apply the removal ({key}) Применить удаление ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ffaaca59c6..7f59884e80 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3745,3 +3745,6 @@ curves Smooth Згладити Light Red Світло-червоний Medium Blue Середньо-синій Light Gray Світло-сірий + Remove after each stroke Видаляти після кожного штриха + Apply the removal ({key}) Застосувати видалення ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6fb20b3d84..c05376c1ee 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3531,3 +3531,6 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + Remove after each stroke Xóa sau mỗi nét vẽ + Apply the removal ({key}) Áp dụng việc xóa ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 187f63e18d..08f00a0fa5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2652,3 +2652,6 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Remove after each stroke 每次描边后移除 + Apply the removal ({key}) 应用移除 ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3e0f8902cd..f2bcab8538 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2637,3 +2637,6 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Remove after each stroke 每次描邊後移除 + Apply the removal ({key}) 套用移除 ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 323d559a8e..1ba0bce83d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -484,6 +484,11 @@ pub struct PhotocraftApp { committed_stroke: Option, /// Footprint trail of a retouching drag (see `stroke_trail`). trail: Option, + /// Remove tool strokes waiting to be applied together (Remove after each stroke off), the + /// document they belong to, and their tint on the canvas (`retouch_ui`). + pub(crate) remove_pending: Vec>, + pub(crate) remove_pending_doc: Option, + pub(crate) remove_trail: Option, /// The open menus' items, reused between frames (`menus::ItemCache`). pub(crate) menu_cache: Option, /// Move tool drag shown live (`move_ui`). @@ -709,6 +714,9 @@ impl PhotocraftApp { live_stroke: None, committed_stroke: None, trail: None, + remove_pending: Vec::new(), + remove_pending_doc: None, + remove_trail: None, menu_cache: None, move_preview: None, move_hover: None, diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 37168ab4b8..a9e1241e07 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -84,7 +84,13 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m let pts = json!(points); let (cmd, mut p): (&str, Value) = match tool { Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), - Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})), + Tool::Remove => { + if !o.remove_after_each_stroke { + remove_pending_add(app, points); + return true; + } + ("paint.remove", remove_params(&o)) + } Tool::MixerBrush => ("paint.mixerBrush", json!({})), Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), Tool::PatternStamp => { @@ -154,6 +160,87 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m true } +/// `paint.remove` params from the options bar (without the strokes). +fn remove_params(o: &crate::state::ToolOptions) -> Value { + json!({"sampleAllLayers": o.sample_all_layers, "newLayer": o.remove_new_layer}) +} + +/// Remove after each stroke off: a finished stroke waits with the others, tinted on the canvas, +/// until ↩ or the options bar's ✓ applies them all as one removal (Esc or ⊘ drops them). +pub fn remove_pending_add(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let Some(st) = app.session.active() else { return }; + let (doc, size) = (st.doc.id, [st.doc.size.width, st.doc.size.height]); + if app.remove_pending_doc != Some(doc) { + app.remove_pending.clear(); + app.remove_trail = None; + app.remove_pending_doc = Some(doc); + } + let diameter = app.session.tools.brush.size; + app.remove_trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size)).feed(points, diameter); + app.remove_pending.push(points.to_vec()); + app.ui.status = crate::i18n::fmt(tl!("Strokes to remove: {n} (Enter applies, Esc cancels)"), &[("n", &app.remove_pending.len().to_string())]); + app.ui.status_error = false; +} + +/// Apply the waiting strokes as one removal (one job, one undo step). False when none wait. +pub fn remove_pending_commit(app: &mut PhotocraftApp) -> bool { + if app.remove_pending.is_empty() { + return false; + } + let strokes = std::mem::take(&mut app.remove_pending); + app.remove_trail = None; + app.remove_pending_doc = None; + let mut p = remove_params(&app.ui.tool_options); + p["strokes"] = json!(strokes); + p["target"] = crate::canvas::paint_target(app); + app.ui.status.clear(); + if let Err(e) = app.run("paint.remove", p) { + app.ui.status = e; + app.ui.status_error = true; + } + true +} + +/// Drop the waiting strokes. False when none wait. +pub fn remove_pending_cancel(app: &mut PhotocraftApp) -> bool { + if app.remove_pending.is_empty() { + return false; + } + app.remove_pending.clear(); + app.remove_trail = None; + app.remove_pending_doc = None; + app.ui.status.clear(); + app.ui.status_error = false; + true +} + +/// Whether Remove strokes are still waiting: they are dropped when the tool or the document +/// changed under them (checked every frame). +pub fn remove_pending_live(app: &mut PhotocraftApp) -> bool { + if app.remove_pending.is_empty() { + return false; + } + let doc = app.session.active().map(|st| st.doc.id); + if app.ui.tool == Tool::Remove && doc.is_some() && app.remove_pending_doc == doc { + return true; + } + remove_pending_cancel(app); + false +} + +/// The tint of the waiting Remove strokes, drawn every frame. +pub fn draw_remove_pending(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) { + if !remove_pending_live(app) { + return; + } + let Some(bounds) = app.session.active().map(|st| st.doc.bounds()) else { return }; + let doc_rect = xf.doc_rect(bounds); + let col = Tokens::get(painter.ctx()).danger.gamma_multiply(0.45); + if let Some(trail) = app.remove_trail.as_mut() { + trail.draw(painter, doc_rect, xf.flip, col); + } +} + /// Patch Tool and Content-Aware Move Tool: a drag that starts inside the selection (without ⇧ or /// ⌥) drags the selection; any other drag draws a lasso selection. pub fn patch_drags_selection(app: &PhotocraftApp, at: [f64; 2], mods: egui::Modifiers) -> bool { @@ -356,7 +443,23 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); } Tool::Remove => { + // Photoshop's options bar: Sample All Layers, Create a new layer, Remove after each + // stroke; with strokes waiting, ✓ applies them and ⊘ drops them. crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); + crate::widgets::checkbox(ui, &mut o.remove_new_layer, tl!("Create a new layer")); + crate::widgets::checkbox(ui, &mut o.remove_after_each_stroke, tl!("Remove after each stroke")); + if !app.remove_pending.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::primary_button(ui, "✓", 32.0) + .on_hover_text(crate::i18n::fmt(tl!("Apply the removal ({key})"), &[("key", &crate::shortcuts::pretty("Enter"))])) + .clicked() + { + remove_pending_commit(app); + } else if crate::widgets::secondary_button(ui, "⊘", 32.0).on_hover_text(tl!("Cancel (Esc)")).clicked() { + remove_pending_cancel(app); + } + }); + } } Tool::Patch => { // Photoshop's options bar: Patch: Normal (Source / Destination) or Content-Aware @@ -955,6 +1058,76 @@ mod tests { assert!(px.iter().zip(bg).all(|(a, b)| (a - b).abs() < 0.02), "{px:?} vs {bg:?}"); } + #[test] + fn remove_after_each_stroke_off_collects_strokes_until_enter_or_escape() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 200, "color": "#808080"})).unwrap(); + app.run("paint.pencil", json!({"points": [[30, 20], [30, 40]], "size": 4, "color": "#000000"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + let m = egui::Modifiers::NONE; + let journal = app.session.journal.len(); + for x in [28.0, 32.0] { + tool_event(&mut app, ToolEvent::Down { x, y: 18.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x, y: 42.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x, y: 42.0 }, m); + } + // Nothing ran yet: the strokes wait, tinted, and the status says so. + assert_eq!(app.session.journal.len(), journal, "no command before Enter"); + assert_eq!(app.remove_pending.len(), 2); + assert!(app.remove_trail.is_some()); + assert!(app.ui.status.contains("2") && !app.ui.status_error, "{}", app.ui.status); + // Esc drops them. + assert!(remove_pending_cancel(&mut app)); + assert!(app.remove_pending.is_empty() && app.remove_trail.is_none()); + assert!(!remove_pending_cancel(&mut app)); + // Collected again, Enter applies them as one `paint.remove` with both strokes. + for x in [28.0, 32.0] { + tool_event(&mut app, ToolEvent::Down { x, y: 18.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x, y: 42.0 }, m); + } + let bg = active(&app).surface().unwrap().rgba(70, 30); + assert!(remove_pending_commit(&mut app)); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, p) = app.session.journal.last().cloned().expect("a journaled command"); + assert_eq!(id, "paint.remove"); + assert_eq!(p["strokes"].as_array().map(Vec::len), Some(2)); + assert!(p.get("points").is_none()); + assert!(app.remove_pending.is_empty()); + let px = active(&app).surface().unwrap().rgba(30, 30); + assert!(px.iter().zip(bg).all(|(a, b)| (a - b).abs() < 0.02), "{px:?} vs {bg:?}"); + assert!(!remove_pending_commit(&mut app), "nothing left to apply"); + } + + #[test] + fn remove_strokes_waiting_are_dropped_when_the_tool_changes() { + let mut app = app(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_after_each_stroke = false; + remove_pending_add(&mut app, &[[10.0, 10.0, 1.0], [20.0, 10.0, 1.0]]); + assert_eq!(app.remove_pending.len(), 1); + assert!(remove_pending_live(&mut app)); + app.ui.tool = Tool::SpotHealing; + assert!(!remove_pending_live(&mut app)); + assert!(app.remove_pending.is_empty(), "the strokes belong to the Remove tool"); + } + + #[test] + fn create_a_new_layer_sends_new_layer_to_the_command() { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[0, 30], [100, 30]], "size": 200, "color": "#808080"})).unwrap(); + app.ui.tool = Tool::Remove; + app.ui.tool_options.remove_new_layer = true; + let layers = app.session.active().unwrap().doc.walk().len(); + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 30.0, y: 18.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 42.0 }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!((id.as_str(), p["newLayer"].as_bool()), ("paint.remove", Some(true))); + assert_eq!(app.session.active().unwrap().doc.walk().len(), layers + 1, "the fill went to a new layer"); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = [Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye]; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 00280102bc..511265e1aa 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -481,6 +481,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // brush keys [ ] ⇧[ ⇧] (`tools.decreaseBrushSize`…) are commands, dispatched above with any // Keyboard Shortcuts override. let pressed = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)); + // Remove tool with strokes waiting (Remove after each stroke off): ↩ applies them, Esc drops them. + if !app.remove_pending.is_empty() { + if pressed(Key::Enter) { + crate::retouch_ui::remove_pending_commit(app); + return; + } + if pressed(Key::Escape) { + crate::retouch_ui::remove_pending_cancel(app); + return; + } + } // Enter / Escape commit or cancel in-progress tool state (polygonal lasso, crop). if crate::lasso_ui::active(app) { let mods = ctx.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3b7b02d8f5..ee348d982b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -508,6 +508,11 @@ pub struct ToolOptions { pub contiguous: bool, pub anti_alias: bool, pub sample_all_layers: bool, + /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). + pub remove_new_layer: bool, + /// Remove tool: each stroke is filled when released (Photoshop's default). Off, the strokes + /// collect as a tint on the canvas and ↩ applies them together (Esc drops them). + pub remove_after_each_stroke: bool, pub feather: f32, /// linear | radial | angle | reflected | diamond pub gradient_style: String, @@ -744,6 +749,8 @@ impl Default for ToolOptions { contiguous: true, anti_alias: true, sample_all_layers: false, + remove_new_layer: false, + remove_after_each_stroke: true, feather: 0.0, gradient_style: "linear".into(), gradient_reverse: false, diff --git a/docs/scorecard.md b/docs/scorecard.md index fb430469a2..46f62ffd49 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -171,7 +171,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. Use Classic Mode / Auto Center Preview: TOOL-214-23 | | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | -| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result | +| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result | | TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | ## Workspace and UI diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 472a0f7cfc..ae64d0c9c1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -481,7 +481,10 @@ best-patch copy and gradient-domain seam hiding. On 20 holes in public-domain ph original's texture (Wexler/PatchMatch completion as Content-Aware Fill uses it: 0.54). On a 24 MP document a 100 px ring takes 120 ms and a 1000 px ring 2.5 s (`remove_bench`). It is not generative: large objects over complex structure fill less convincingly than Photoshop's AI mode -(#41). +(#41). 2026-10-12: the options bar has Photoshop's three options: Sample All Layers, Create a new +layer (the fill lands on a new layer above the target, `paint.remove` `newLayer`) and Remove after +each stroke; off, the strokes collect as a tint and ↩ (or ✓) applies them together as one job and +one undo step (`strokes`), Esc (or ⊘) drops them (#1750). 2026-10-08: the tools update above is checked against the current toolbar groups in [`panels.rs`](../crates/ui-egui/src/panels.rs), the Pencil and tool-cycle tests in diff --git a/scorecard/tools.toml b/scorecard/tools.toml index ec9bc2a7e9..3f22fa9019 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -297,7 +297,7 @@ id = "TOOL-213-19" target = "Remove tool: paint over an object (or circle it) and it is filled from its surroundings" status = "partial" issue = 213 -note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result" +note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Options bar: Sample All Layers, Create a new layer (the fill on a new layer above, `newLayer`), Remove after each stroke off (strokes collect as a tint and ↩ applies them as one `strokes` job, Esc drops them). Missing: generative AI mode, live preview of the result" [[item]] id = "TOOL-1045-1" From 6c7328b7093a39d35e6b2e7421baf7dcd3679072 Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Mon, 12 Oct 2026 20:52:32 +0800 Subject: [PATCH 122/523] Workspace: wire Auto-Collapse Iconic Panels (#204) (#3880) * ui: a panel expanded from its collapsed strip auto-collapses on an outside click * ui: test the Auto-Collapse Iconic Panels peek open and outside close * engine: show Auto-Collapse Iconic Panels in Preferences, it is wired * docs: scorecard, settings that do nothing 30 -> 29 of 154 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/prefs.rs | 1 - crates/ui-egui/src/dock.rs | 41 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/dock_tests.rs | 37 ++++++++++++++++++++++++++++ docs/scorecard.md | 6 ++--- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index beef58c920..cffc39bccf 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1051,7 +1051,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "general.colorPicker", "general.animatedZoom", "general.zoomResizesWindows", - "workspace.autoCollapseIconPanels", "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", "fileHandling.saveInBackground", diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 089fb6cd2b..6115e06a35 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -424,6 +424,26 @@ fn rects_id() -> egui::Id { egui::Id::new("dock-group-rects") } +/// The group expanded from its collapsed tab strip (see [`show`]), if any: with Preferences ▸ +/// Workspace ▸ Auto-Collapse Iconic Panels on, the first click outside it collapses it again. +fn peek_id() -> egui::Id { + egui::Id::new("dock-peek") +} + +fn auto_collapse_peek(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(g) = ctx.data(|d| d.get_temp::>(peek_id())).flatten() else { return }; + let Some(p) = ctx.input(|i| i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten()) else { + return; + }; + let inside = last_rects(ctx).into_iter().find(|(x, _)| *x == g).is_some_and(|(_, r)| r.contains(p)); + if !inside { + app.ui.dock.set_collapsed(g, true); + ctx.data_mut(|d| { + d.insert_temp(peek_id(), None::); + }); + } +} + /// A group's tab strip as drawn last frame (screen points), for tests and automation. #[derive(Clone, Debug, PartialEq)] pub struct StripRects { @@ -451,6 +471,14 @@ fn strips_id() -> egui::Id { /// group's tab content. pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) { let t = Tokens::get(ui.ctx()); + let auto_collapse = app.session.prefs().workspace.auto_collapse_icon_panels; + if auto_collapse { + auto_collapse_peek(app, ui.ctx()); + } else { + ui.ctx().data_mut(|d| { + d.insert_temp(peek_id(), None::); + }); + } let strip = if t.pro { 28.0 } else { 40.0 }; let order: Vec = app.ui.dock.order().into_iter().filter(|g| shown.contains(g)).collect(); let area = ui.available_rect_before_wrap(); @@ -458,6 +486,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod let locked = app.session.prefs().workspace_locked; let rects = rects_after_layout(&heights, area); let mut actions: Vec = Vec::new(); + let mut peek_after: Option = None; let mut tab_drops: Vec<(Group, usize, Pos2)> = Vec::new(); let mut dragging: Option = None; let mut strips: Vec = Vec::with_capacity(rects.len()); @@ -525,6 +554,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } if resp.strip.double_clicked() || resp.tab_double_clicked || (collapsed && resp.tab_clicked) { actions.push(Action::ToggleCollapse(g)); + // A single click on a collapsed strip's tab expands the group as a peek: the next + // click outside closes it again, with Auto-Collapse Iconic Panels on. + if collapsed && resp.tab_clicked && !resp.tab_double_clicked && auto_collapse { + peek_after = Some(g); + } } // The strip uses visible indices; reorder in original ones. if !locked @@ -663,6 +697,13 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod } } } + if let Some(g) = peek_after + && !app.ui.dock.is_collapsed(g) + { + ui.ctx().data_mut(|d| { + d.insert_temp(peek_id(), Some(g)); + }); + } for (panel, anchor, before) in panel_moves { let params = if before { json!({"panel":panel,"anchor":anchor,"before":anchor}) } else { json!({"panel":panel,"anchor":anchor}) }; if let Err(error) = app.run("window.panel.move", params) { diff --git a/crates/ui-egui/src/dock_tests.rs b/crates/ui-egui/src/dock_tests.rs index b5ade1d580..beb6b913de 100644 --- a/crates/ui-egui/src/dock_tests.rs +++ b/crates/ui-egui/src/dock_tests.rs @@ -350,6 +350,43 @@ fn a_single_tab_click_expands_a_collapsed_panel() { } } +/// Auto-Collapse Iconic Panels: a group expanded by clicking a tab on its collapsed strip +/// closes again when the next click lands outside it; with the preference off it stays open. +#[test] +fn auto_collapse_icon_panels_closes_a_peeked_panel_on_an_outside_click() { + for auto in [true, false] { + let (mut app, _, _) = app_with_layers(); + app.session.prefs.edit(|p| p.workspace.auto_collapse_icon_panels = auto); + app.ui.dock.set_collapsed(Group::Color, true); + let mut h = harness(app, vec2(1200.0, 800.0), ThemeKind::ProMedium); + let collapsed_height = rect_of(&h, Group::Color).height(); + let tab = last_strips(&h.ctx).iter().find(|s| s.group == Group::Color).unwrap().tabs[0].1.center(); + h.event(egui::Event::PointerMoved(tab)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: tab, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(!h.state().ui.dock.is_collapsed(Group::Color), "auto={auto}: the tab click expands the panel"); + let rect = rect_of(&h, Group::Color); + // A click inside the panel leaves it open; one outside (the canvas) closes it again. + for p in [rect.center(), Pos2::new(rect.left() / 2.0, rect.center().y)] { + h.event(egui::Event::PointerMoved(p)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + } + assert_eq!(h.state().ui.dock.is_collapsed(Group::Color), auto, "auto={auto}: outside closes it when enabled"); + if auto { + assert_eq!(rect_of(&h, Group::Color).height(), collapsed_height); + } else { + assert!(rect_of(&h, Group::Color).height() > collapsed_height); + } + } +} + #[test] fn double_clicking_a_tab_collapses_and_dragging_a_strip_reorders() { let (app, _, _) = app_with_layers(); diff --git a/docs/scorecard.md b/docs/scorecard.md index 46f62ffd49..78a0864549 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 28 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 28 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 27 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_collapse_icon_panels`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From bbe77354e03248fdb0fba8dbe59f1fc6b034eefe Mon Sep 17 00:00:00 2001 From: Unemars <62496697+Unemars@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:45 +0200 Subject: [PATCH 123/523] Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's Measured as a black box on Photoshop 25.4's Camera Raw Filter. Whites and Blacks are global curves of the grey level: the measured rows per slider value, Whites first, after Shadows and Highlights. The clipping directions adapt to the image's black and white points (Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2). On ten public-domain photos the mean error against Photoshop drops from 4.2-36.8 to 0.9-8.9 levels of 255. Shadows and Highlights are a local Laplacian filter (Paris et al. 2011, fast form of Aubry et al. 2014) on the log luminance, the method Adobe credits for process version 2012. Shadows raises pixels darker than their surroundings by an amount set by their own and their surroundings' tone, so big edges keep no halo, and keeps the 99th percentile; Highlights pulls pixels brighter than their surroundings toward them and keeps the 1st. The strengths are fitted to the photos (mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator was not recovered. The tone stages decode light with the document's curve: filter.cameraRaw stores encodingGamma (1.8 for ProPhoto, where raws open). Co-Authored-By: Claude Opus 5.5 (1M context) * Camera Raw Shadows/Highlights: no contour lines on smooth gradients Twelve reference values over a raw's whole range left more than a stop between them, and the remapping changes within half a stop: coefficients interpolated across that gap drew contour lines in out-of-focus areas (Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop apart over the image's tones (the deepest 0.1 % share the lowest), on a copy at most 1600 px across whose change comes back to full size by a guided upsampling, so a 16-megapixel image takes about 2 s per slider and the preview matches the result. Co-Authored-By: Claude Opus 5.5 (1M context) * Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at +-100 and +50, exposure +-1) against our own linear development of the same files. - Shadows and Highlights of the open-time dialog now act while developing, in linear light before the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2 gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy 1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the preview undoes the camera curve to show the same (photocraft_raw::retone). - The D4 profile copies Camera Raw's default rendering: look matrix folded into the ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of the luminance, Sensor::black_point). Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference 0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights -100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464). Co-Authored-By: Claude Opus 5.5 (1M context) * docs: the open-time Camera Raw re-develop covers Shadows and Highlights Co-Authored-By: Claude Opus 5.5 (1M context) * Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs: with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38 stop lighter. Two causes: - The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting first let the lift wash out the black point's deep-shadow contrast). - The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near strong edges the effect still matches Photoshop as well as elsewhere. Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) * Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99). - Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus a tone term; carried to full size with the Shadows / Highlights change. - Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response (small detail raised more than strong edges, flat areas unchanged), guided-upsampled. - Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3, airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100; −100 a uniform veil. The black point stays the default rendering's. Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85; Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886). The open-time Camera Raw dialog re-develops for them and its preview shows them (retone). Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- crates/algo/src/camera_raw.rs | 111 ++- crates/algo/src/camera_raw/local_tone.rs | 428 ++++++++++++ crates/algo/src/camera_raw/whites_blacks.rs | 418 +++++++++++ crates/engine/src/lens_cmds.rs | 47 ++ crates/io/src/raw.rs | 78 ++- crates/io/tests/raw.rs | 31 +- crates/raw/README.md | 8 + crates/raw/src/cameras.rs | 105 ++- crates/raw/src/cr2.rs | 1 + crates/raw/src/develop.rs | 43 +- crates/raw/src/dng.rs | 1 + crates/raw/src/lib.rs | 2 + crates/raw/src/orf.rs | 1 + crates/raw/src/raf.rs | 1 + crates/raw/src/rw2.rs | 1 + crates/raw/src/sensor.rs | 4 + crates/raw/src/tiffep.rs | 1 + crates/raw/src/tone.rs | 730 ++++++++++++++++++++ crates/raw/src/tone/tables.rs | 66 ++ crates/ui-egui/src/camera_raw_ui.rs | 91 ++- docs/control-protocol.md | 5 +- 21 files changed, 2085 insertions(+), 88 deletions(-) create mode 100644 crates/algo/src/camera_raw/local_tone.rs create mode 100644 crates/algo/src/camera_raw/whites_blacks.rs create mode 100644 crates/raw/src/tone.rs create mode 100644 crates/raw/src/tone/tables.rs diff --git a/crates/algo/src/camera_raw.rs b/crates/algo/src/camera_raw.rs index 2c1b0c9229..f855b08bfe 100644 --- a/crates/algo/src/camera_raw.rs +++ b/crates/algo/src/camera_raw.rs @@ -9,9 +9,10 @@ //! with the guided filter); negative amounts add haze toward the atmospheric light. //! 3. **Tone** on luminance with colour ratios kept: Highlights / Shadows as exposure changes //! weighted by an edge-preserving base layer (guided filter: K. He, J. Sun, X. Tang, *Guided -//! Image Filtering*, ECCV 2010), so local contrast survives; Whites / Blacks move the ends of -//! the curve; Contrast is an S-curve about middle grey; Clarity (large radius, midtone -//! weighted) and Texture (small radius) add band-pass local contrast. +//! Image Filtering*, ECCV 2010), so local contrast survives; Contrast is an S-curve about +//! middle grey; Clarity (large radius, midtone weighted) and Texture (small radius) add +//! band-pass local contrast. Whites / Blacks follow as Photoshop's Camera Raw applies them: +//! image-adaptive global curves measured on Photoshop 25.4 (see `whites_blacks`). //! 4. **Presence**: Vibrance (weighted toward less saturated colours) and Saturation. //! 5. **Tone Curve**: parametric regions (Highlights, Lights, Darks, Shadows with movable splits) //! then point curves (master and per channel), monotone cubic interpolation (F. Fritsch, @@ -54,6 +55,38 @@ pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> { use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear}; +mod local_tone; +mod whites_blacks; + +/// How the pixels encode light: the sRGB curve (gamma 0) or a pure power law (ProPhoto's 1.8, +/// Adobe RGB's 2.2). The tone controls work on light, so the document's own curve decodes it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Encoding(f32); + +impl Encoding { + pub(crate) fn of(gamma: f32) -> Self { + Encoding(if gamma.is_finite() && gamma > 0.0 { gamma.clamp(0.5, 4.0) } else { 0.0 }) + } + pub(crate) fn decode(self, v: f32) -> f32 { + if self.0 > 0.0 { v.max(0.0).powf(self.0) } else { srgb_to_linear(v) } + } + pub(crate) fn encode(self, v: f32) -> f32 { + if self.0 > 0.0 { v.max(0.0).powf(1.0 / self.0) } else { linear_to_srgb(v) } + } +} + +fn srgb_encode(v: f32) -> f32 { + linear_to_srgb(v) +} + +fn srgb_decode(v: f32) -> f32 { + srgb_to_linear(v) +} + +fn luma_linear(c: [f32; 3]) -> f32 { + luma(c) +} + /// One colour grading wheel. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] #[serde(default)] @@ -136,6 +169,9 @@ pub struct CameraRaw { pub seed: u32, /// Pixel-size multiplier for previews on downsampled proxies (radii in px scale by it). pub pixel_scale: f32, + /// The document's transfer curve: 0 for sRGB's, else the gamma of a power-law space (1.8 + /// for ProPhoto). Set from the document's profile; Whites and Blacks act on light. + pub encoding_gamma: f32, } impl Default for CameraRaw { @@ -191,6 +227,7 @@ impl Default for CameraRaw { vignette_style: "highlightPriority".into(), seed: 0, pixel_scale: 1.0, + encoding_gamma: 0.0, } } } @@ -491,7 +528,11 @@ impl CameraRaw { self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0 } fn tone_neutral(&self) -> bool { - [self.contrast, self.highlights, self.shadows, self.whites, self.blacks, self.texture, self.clarity].iter().all(|v| *v == 0.0) + self.local_tone_neutral() && self.whites == 0.0 && self.blacks == 0.0 + } + /// Contrast, Highlights, Shadows, Texture and Clarity (the luminance stage). + fn local_tone_neutral(&self) -> bool { + [self.contrast, self.highlights, self.shadows, self.texture, self.clarity].iter().all(|v| *v == 0.0) } fn hsl_neutral(&self) -> bool { self.hsl_hue.iter().chain(&self.hsl_sat).chain(&self.hsl_lum).all(|v| *v == 0.0) @@ -561,16 +602,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo if p.dehaze != 0.0 { dehaze(px, w, h, p.dehaze / 100.0); } - // 3. Tone on luminance. - if !p.tone_neutral() { + // 3. Tone. Highlights and Shadows: a local Laplacian filter (see `local_tone`). + if p.highlights != 0.0 || p.shadows != 0.0 { + local_tone::apply(px, w, h, p.shadows, p.highlights, Encoding::of(p.encoding_gamma)); + } + if !p.local_tone_neutral() && [p.contrast, p.texture, p.clarity].iter().any(|v| *v != 0.0) { let l: Vec = px.iter().map(|q| luma([q[0], q[1], q[2]]).max(0.0)).collect(); let lc: Vec = l.iter().map(|v| v.min(1.0)).collect(); - let base = if p.highlights != 0.0 || p.shadows != 0.0 { - let r = ((long * 0.015) as usize).max(2); - Some(guided_fast(&lc, &lc, w, h, r, 0.02)) - } else { - None - }; let clar = (p.clarity != 0.0).then(|| guided_fast(&lc, &lc, w, h, ((long * 0.02) as usize).max(3), 0.005)); let tex = (p.texture != 0.0).then(|| gauss(&lc, w, h, (long * 0.002).max(1.5 * ps))); let k_con = p.contrast / 100.0 * 0.6; @@ -580,12 +618,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo let i = y * w + x; let l0 = l[i]; let mut lv = lc[i]; - if let Some(b) = &base { - let bv = b[i].clamp(0.0, 1.0); - let hm = smoothstep(0.45, 0.95, bv); - let sm = 1.0 - smoothstep(0.05, 0.5, bv); - lv *= 2f32.powf(p.highlights / 100.0 * 1.2 * hm + p.shadows / 100.0 * 1.4 * sm); - } if let Some(c) = &clar { let mw = 1.0 - (2.0 * lv.clamp(0.0, 1.0) - 1.0).powi(2); lv += p.clarity / 100.0 * 0.8 * (lc[i] - c[i]) * mw; @@ -593,12 +625,6 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo if let Some(t) = &tex { lv += p.texture / 100.0 * 0.9 * (lc[i] - t[i]).clamp(-0.1, 0.1); } - if p.whites != 0.0 { - lv += p.whites / 100.0 * 0.25 * smoothstep(0.4, 1.0, lv); - } - if p.blacks != 0.0 { - lv += p.blacks / 100.0 * 0.15 * (1.0 - smoothstep(0.0, 0.45, lv)); - } if k_con != 0.0 { let xx = lv.clamp(0.0, 1.0); lv += -k_con * (tau * xx).sin() / tau; @@ -611,6 +637,13 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } }); } + // 3b. Whites and Blacks: global curves adapted to the image's black and white points. + if p.whites != 0.0 || p.blacks != 0.0 { + let enc = Encoding::of(p.encoding_gamma); + let (bp, wp) = luminance_points(px, w, h, enc); + let (xw, xb) = whites_blacks::amounts(p.whites.clamp(-100.0, 100.0), p.blacks.clamp(-100.0, 100.0), bp, wp); + whites_blacks::apply(px, w, xw, xb, enc); + } // 4–7: per-pixel colour stages. let need_color = p.vibrance != 0.0 || p.saturation != 0.0 || !p.curves_neutral() || !p.hsl_neutral() || !p.grade_neutral(); if need_color { @@ -732,6 +765,36 @@ pub fn develop(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw, float: bo } } +/// The image's black and white points as sRGB-encoded grey levels: the 3rd percentile of the +/// luminance and the 99.5th of the brightest channel, on a copy at most 256 pixels across (so a +/// preview proxy and the full image agree). +fn luminance_points(px: &[[f32; 4]], w: usize, h: usize, enc: Encoding) -> (f32, f32) { + let f = w.max(h).div_ceil(256).max(1); + let (sw, sh) = (w.div_ceil(f), h.div_ceil(f)); + let mut sum = vec![[0.0f64; 4]; sw * sh]; + for y in 0..h { + for x in 0..w { + let q = px[y * w + x]; + let s = &mut sum[(y / f) * sw + x / f]; + for c in 0..3 { + s[c] += f64::from(enc.decode(q[c]).clamp(0.0, 1.0)); + } + s[3] += 1.0; + } + } + let cells: Vec<[f32; 3]> = sum.iter().filter(|s| s[3] > 0.0).map(|s| [0, 1, 2].map(|c| (s[c] / s[3]) as f32)).collect(); + if cells.is_empty() { + return (0.0, 1.0); + } + let pct = |mut v: Vec, q: f32| { + v.sort_by(f32::total_cmp); + v[((v.len() - 1) as f32 * q).round() as usize] + }; + let bp = pct(cells.iter().map(|c| luma(*c)).collect(), 0.03); + let wp = pct(cells.iter().map(|c| c[0].max(c[1]).max(c[2])).collect(), 0.995); + (linear_to_srgb(bp), linear_to_srgb(wp)) +} + fn value_noise(x: f32, y: f32, seed: u64) -> f32 { let (xi, yi) = (x.floor(), y.floor()); let (tx, ty) = (x - xi, y - yi); diff --git a/crates/algo/src/camera_raw/local_tone.rs b/crates/algo/src/camera_raw/local_tone.rs new file mode 100644 index 0000000000..7844f8d74b --- /dev/null +++ b/crates/algo/src/camera_raw/local_tone.rs @@ -0,0 +1,428 @@ +//! Shadows and Highlights as a local Laplacian filter. +//! +//! Adobe has said Camera Raw's process version 2012 rests on local Laplacian filters (S. Paris, +//! S. W. Hasinoff, J. Kautz, *Local Laplacian Filters: Edge-aware Image Processing with a +//! Laplacian Pyramid*, SIGGRAPH 2011). This follows that paper, in its fast form (M. Aubry et +//! al., *Fast Local Laplacian Filters*, ACM TOG 2014: the remapping sampled at a few reference +//! values, each pyramid coefficient interpolated between them), on the log2 luminance: +//! +//! - **Shadows** raises pixels darker than their surroundings, by an amount set by their own and +//! their surroundings' place in the image's range (1st to 99th percentile); **Highlights** +//! pulls pixels brighter than their surroundings toward them. Differences under a threshold +//! are left alone, so fine detail stays. +//! - Shadows then keeps the image's 99th percentile, Highlights its 1st (Photoshop keeps the +//! brightest content under Shadows and the darkest under Highlights). +//! - A uniform image is unchanged, edges keep no halo, and the result does not depend on the +//! image size. +//! +//! The strengths were fitted to Photoshop 25.4's Camera Raw Filter on ten public-domain photos +//! (black-box measurements, no Adobe code or data). It is an approximation: on those photos the +//! mean error against Photoshop is 2.8–9.4 levels (of 255) where the previous heuristic's was +//! 3.7–14.3. + +use super::{Encoding, luma}; +use crate::photo_util::par_rows; + +/// Spacing of the fast filter's reference values in stops. Its remapping changes within about +/// half a stop, and coefficients interpolated across wider gaps show as contour lines on smooth +/// gradients (12 references over a whole raw's range left visible steps; at an eighth of a stop a +/// ramp's slope varies by under a third, as the remapping itself makes it). +const REF_STEP: f32 = 0.125; +/// The filter runs on a copy at most this many pixels across; the change it makes is brought back +/// to full size by a guided upsampling, so a preview and the full image agree. +const WORK_SIDE: usize = 1600; +/// Knots of the strength curves over the surroundings' place in the image range (0 … 1). +const KNOTS: usize = 5; + +/// Shadows: how far a pixel darker than its surroundings rises, in stops per unit slider, as +/// a function of its own place in the image range (`own`) times a factor of its surroundings' +/// place (`around`); it rises fully once it is `sigma` stops below them. The lift depends on +/// the pixel's tone, not on the size of the difference, so it is the same at every pyramid +/// level and a big edge keeps no halo. Lowering (negative values) is `down` times as strong. +const SHADOWS: Lift = Lift { own: [-0.017, 0.466, 0.886, 1.221, -0.081], around: [4.566, 4.067, 1.088, 0.149, 0.878], sigma: 0.571, down: 0.808 }; +const HIGHLIGHTS: Side = Side { pull: [0.179, 0.141, 0.196, 0.629, 2.07], push: [0.156, 0.08, 0.175, 0.049, 1.261], sigma: 0.05 }; + +/// See [`SHADOWS`]. +struct Lift { + own: [f32; KNOTS], + around: [f32; KNOTS], + sigma: f32, + down: f32, +} + +/// Highlights: strengths per unit slider over the surroundings' place in the image range, for +/// lowering (`pull`, toward the surroundings) and raising (`push`), and the detail threshold in +/// stops. +struct Side { + pull: [f32; KNOTS], + push: [f32; KNOTS], + sigma: f32, +} + +/// One channel, `w × h`. +struct Plane { + w: usize, + h: usize, + v: Vec, +} + +/// Mirrors `i` into `0..n` (the edge sample is not repeated). +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + if n == 1 { + return 0; + } + let p = 2 * (n - 1); + let m = i.rem_euclid(p); + (if m >= n { p - m } else { m }) as usize +} + +const TAPS: [f32; 5] = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0]; + +/// The binomial 5-tap blur, sampled every `step` pixels from 0 (1 = full resolution). +fn blur_step(p: &Plane, step: usize) -> Plane { + let (w2, h2) = (p.w.div_ceil(step), p.h.div_ceil(step)); + // Columns first at the kept rows, then rows at the kept columns. + let mut tmp = vec![0.0f32; p.w * h2]; + par_rows(&mut tmp, p.w, 1, |y2, row| { + let y = (y2 * step) as isize; + for (x, o) in row.iter_mut().enumerate() { + *o = (0..5).map(|k| TAPS[k] * p.v[mirror(y + k as isize - 2, p.h) * p.w + x]).sum(); + } + }); + let mut out = vec![0.0f32; w2 * h2]; + par_rows(&mut out, w2, 1, |y2, row| { + let src = &tmp[y2 * p.w..(y2 + 1) * p.w]; + for (x2, o) in row.iter_mut().enumerate() { + let x = (x2 * step) as isize; + *o = (0..5).map(|k| TAPS[k] * src[mirror(x + k as isize - 2, p.w)]).sum(); + } + }); + Plane { w: w2, h: h2, v: out } +} + +/// The next coarser level. +fn down(p: &Plane) -> Plane { + blur_step(p, 2) +} + +/// `p` brought up to `w × h`: zeros between samples, then the blur times four. +fn up(p: &Plane, w: usize, h: usize) -> Plane { + let mut z = Plane { w, h, v: vec![0.0; w * h] }; + for y in 0..p.h.min(h.div_ceil(2)) { + for x in 0..p.w.min(w.div_ceil(2)) { + z.v[2 * y * w + 2 * x] = p.v[y * p.w + x] * 4.0; + } + } + blur_step(&z, 1) +} + +/// Levels down to about 2 pixels on the short side. +fn depth(w: usize, h: usize) -> usize { + let mut s = w.min(h); + let mut n = 1; + while s > 2 { + s = s.div_ceil(2); + n += 1; + } + n +} + +fn gaussian(p: Plane, n: usize) -> Vec { + let mut g = vec![p]; + for _ in 1..n { + let next = down(&g[g.len() - 1]); + g.push(next); + } + g +} + +/// The local Laplacian filter of `u` with `remap(value, reference)`; the coarsest level is kept. +fn llf(u: &Plane, remap: impl Fn(f32, f32) -> f32 + Sync) -> Plane { + let n = depth(u.w, u.h); + let g = gaussian(Plane { w: u.w, h: u.h, v: u.v.clone() }, n); + // References span the image's tones; the deepest 0.1 % (noise down to the floor) share the + // lowest reference instead of stretching the spacing. + let hi = u.v.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v)); + let lo = quantile(&u.v, 0.001).min(hi); + if hi - lo <= 1e-6 || (hi - lo).is_nan() { + return Plane { w: u.w, h: u.h, v: u.v.clone() }; + } + let refs = (((hi - lo) / REF_STEP).ceil() as usize + 1).clamp(2, 96); + let step = (hi - lo) / (refs - 1) as f32; + let mut lap: Vec> = g[..n - 1].iter().map(|p| vec![0.0; p.v.len()]).collect(); + for k in 0..refs { + let r = lo + step * k as f32; + let mut m = Plane { w: u.w, h: u.h, v: u.v.clone() }; + par_rows(&mut m.v, u.w, 1, |_, row| { + for v in row.iter_mut() { + *v = remap(*v, r); + } + }); + let gr = gaussian(m, n); + for l in 0..n - 1 { + let coarse = up(&gr[l + 1], gr[l].w, gr[l].h); + let (gl, fine, lw) = (&g[l].v, &gr[l].v, g[l].w); + par_rows(&mut lap[l], lw, 1, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = y * lw + x; + let wgt = (1.0 - (gl[i].clamp(lo, hi) - r).abs() / step).max(0.0); + if wgt > 0.0 { + *o += wgt * (fine[i] - coarse.v[i]); + } + } + }); + } + } + let mut o = Plane { w: g[n - 1].w, h: g[n - 1].h, v: g[n - 1].v.clone() }; + for l in (0..n - 1).rev() { + let mut next = up(&o, g[l].w, g[l].h); + for (a, b) in next.v.iter_mut().zip(&lap[l]) { + *a += b; + } + o = next; + } + o +} + +/// The `q` quantile (0 … 1) of `v`. +fn quantile(v: &[f32], q: f32) -> f32 { + let mut s = v.to_vec(); + s.sort_by(f32::total_cmp); + s[((s.len() - 1) as f32 * q).round() as usize] +} + +/// 1st and 99th percentiles of the log luminance on a copy at most 256 pixels across. +fn range(u: &Plane) -> (f32, f32) { + let f = u.w.max(u.h).div_ceil(256).max(1); + let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f)); + let mut sum = vec![(0.0f64, 0u32); sw * sh]; + for y in 0..u.h { + for x in 0..u.w { + let c = &mut sum[(y / f) * sw + x / f]; + c.0 += f64::from(u.v[y * u.w + x]); + c.1 += 1; + } + } + let cells: Vec = sum.iter().filter(|c| c.1 > 0).map(|c| (c.0 / f64::from(c.1)) as f32).collect(); + (quantile(&cells, 0.01), quantile(&cells, 0.99)) +} + +/// `d` past the detail threshold: about 0 below `sigma`, `d − sigma` well above it. +fn excess(d: f32, sigma: f32) -> f32 { + d - sigma * (d / sigma).tanh() +} + +fn knot(t: f32, k: &[f32; KNOTS]) -> f32 { + let x = t.clamp(0.0, 1.0) * (KNOTS - 1) as f32; + let i = (x as usize).min(KNOTS - 2); + k[i] + (k[i + 1] - k[i]) * (x - i as f32) +} + +/// The Highlights pass on the log luminance `u` (`amount` −1 … 1): pixels brighter than their +/// surroundings move toward them (or away, for positive values) by the part of the difference +/// past `sigma`, then the darkest content is put back where it was. +fn highlights_pass(u: &Plane, amount: f32, side: &Side) -> Plane { + let (lo, hi) = range(u); + let span = (hi - lo).max(1.0); + let strengths = if amount < 0.0 { &side.pull } else { &side.push }; + let o = llf(u, |x, g| x + knot((g - lo) / span, strengths) * amount * excess((x - g).max(0.0), side.sigma)); + let shift = quantile(&u.v, 0.01) - quantile(&o.v, 0.01); + Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() } +} + +/// The Shadows pass on the log luminance `u` (`amount` −1 … 1). +fn shadows_pass(u: &Plane, amount: f32) -> Plane { + let (lo, hi) = range(u); + let span = (hi - lo).max(1.0); + let k = if amount > 0.0 { amount } else { amount * SHADOWS.down }; + let o = llf(u, |x, g| { + let below = ((g - x) / SHADOWS.sigma).clamp(0.0, 1.0); + if below == 0.0 { + return x; + } + let w = below * below * (3.0 - 2.0 * below); + x + k * knot((x - lo) / span, &SHADOWS.own) * knot((g - lo) / span, &SHADOWS.around) * w + }); + // Keep the brightest content. + let shift = quantile(&u.v, 0.99) - quantile(&o.v, 0.99); + Plane { w: o.w, h: o.h, v: o.v.iter().map(|v| v + shift).collect() } +} + +/// Applies Shadows and Highlights (−100 … 100) to straight RGBA pixels encoded with `enc`. +pub(crate) fn apply(px: &mut [[f32; 4]], w: usize, h: usize, shadows: f32, highlights: f32, enc: Encoding) { + if (shadows == 0.0 && highlights == 0.0) || w < 2 || h < 2 { + return; + } + const FLOOR: f32 = 1.0 / 65536.0; + let u = Plane { w, h, v: px.iter().map(|q| (luma([0, 1, 2].map(|c| enc.decode(q[c]))).max(0.0) + FLOOR).log2()).collect() }; + // The filter on a copy at most WORK_SIDE across (box-averaged in log). + let f = w.max(h).div_ceil(WORK_SIDE).max(1); + let small = if f == 1 { Plane { w, h, v: u.v.clone() } } else { shrink(&u, f) }; + let mut o = Plane { w: small.w, h: small.h, v: small.v.clone() }; + if highlights != 0.0 { + o = highlights_pass(&o, highlights.clamp(-100.0, 100.0) / 100.0, &HIGHLIGHTS); + } + if shadows != 0.0 { + o = shadows_pass(&o, shadows.clamp(-100.0, 100.0) / 100.0); + } + // The change in log luminance, at full size. + let change: Vec = if f == 1 { + o.v.iter().zip(&u.v).map(|(a, b)| a - b).collect() + } else { + let d: Vec = o.v.iter().zip(&small.v).map(|(a, b)| a - b).collect(); + guided_upsample(&small, &d, &u) + }; + par_rows(px, w, 1, |y, row| { + for (x, q) in row.iter_mut().enumerate() { + let gain = change[y * w + x].exp2(); + for c in 0..3 { + q[c] = enc.encode(enc.decode(q[c]) * gain); + } + } + }); +} + +/// `u` averaged over `f × f` blocks. +fn shrink(u: &Plane, f: usize) -> Plane { + let (sw, sh) = (u.w.div_ceil(f), u.h.div_ceil(f)); + let mut v = vec![0.0f32; sw * sh]; + par_rows(&mut v, sw, 1, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let (x0, y0) = (x * f, y * f); + let (x1, y1) = ((x0 + f).min(u.w), (y0 + f).min(u.h)); + let mut s = 0.0f64; + for yy in y0..y1 { + for xx in x0..x1 { + s += f64::from(u.v[yy * u.w + xx]); + } + } + *o = (s / ((x1 - x0) * (y1 - y0)) as f64) as f32; + } + }); + Plane { w: sw, h: sh, v } +} + +/// The change `d` computed on `small` brought to the size of `full`, following its edges: a local +/// linear model of the change against the log luminance (the guided filter of K. He, J. Sun, +/// *Fast Guided Filter*, 2015), fitted on the small copy and evaluated on the full one. +fn guided_upsample(small: &Plane, d: &[f32], full: &Plane) -> Vec { + let (sw, sh) = (small.w, small.h); + let r = 2; + let eps = 0.01; + let mean = |v: &[f32]| super::box_mean(v, sw, sh, r); + let ud: Vec = small.v.iter().zip(d).map(|(a, b)| a * b).collect(); + let uu: Vec = small.v.iter().map(|a| a * a).collect(); + let (mu, md, mud, muu) = (mean(&small.v), mean(d), mean(&ud), mean(&uu)); + let a: Vec = (0..sw * sh).map(|i| (mud[i] - mu[i] * md[i]) / (muu[i] - mu[i] * mu[i] + eps)).collect(); + let b: Vec = (0..sw * sh).map(|i| md[i] - a[i] * mu[i]).collect(); + let (ma, mb) = (mean(&a), mean(&b)); + let (fx, fy) = (sw as f32 / full.w as f32, sh as f32 / full.h as f32); + let mut out = vec![0.0f32; full.w * full.h]; + par_rows(&mut out, full.w, 1, |y, row| { + let sy = (y as f32 + 0.5) * fy - 0.5; + for (x, o) in row.iter_mut().enumerate() { + let sx = (x as f32 + 0.5) * fx - 0.5; + let av = crate::photo_util::bilinear(&ma, sw, sh, 1, 0, sx, sy); + let bv = crate::photo_util::bilinear(&mb, sw, sh, 1, 0, sx, sy); + *o = av * full.v[y * full.w + x] + bv; + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const SRGB: Encoding = Encoding(0.0); + + fn grey(w: usize, h: usize, f: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { + (0..w * h).map(|i| f(i % w, i / w)).map(|v| [v, v, v, 1.0]).collect() + } + + #[test] + fn a_flat_image_is_unchanged() { + let mut px = grey(64, 48, |_, _| 0.4); + apply(&mut px, 64, 48, 100.0, -100.0, SRGB); + assert!(px.iter().all(|q| (q[0] - 0.4).abs() < 1e-5)); + } + + #[test] + fn shadows_raise_a_dark_area_without_a_halo() { + // A 0.1 | 0.6 step (the dark half is the image's black point, which Photoshop barely + // moves either): the bright half stays, and no halo forms on either side of the edge. + let (w, h) = (256, 128); + let mut px = grey(w, h, |x, _| if x < w / 2 { 0.1 } else { 0.6 }); + apply(&mut px, w, h, 100.0, 0.0, SRGB); + let row = &px[64 * w..65 * w]; + assert!((row[w / 2 + 1][0] - 0.6).abs() < 0.002 && (row[w - 5][0] - 0.6).abs() < 0.002); + let (far, near) = (row[10][0], row[w / 2 - 1][0]); + assert!((far - 0.1).abs() < 0.004 && (far - near).abs() < 0.004, "{far} {near}"); + } + + #[test] + fn grey_moves_as_in_photoshop() { + // Mid-grey with 1 % black (0.015) and white (0.97) corners, Photoshop 25.4: Shadows +100 + // takes the grey from 127.5 to 165.3, Highlights −100 to 89.6 (of 255). + let n = 256; + let image = |c: f32| { + grey(n, n, |x, y| { + if x >= n - 26 && y >= n - 26 { + 0.97 + } else if x >= n - 26 && y < 26 { + 0.015 + } else { + c + } + }) + }; + for (s, hl, want) in [(100.0, 0.0, 165.3), (0.0, -100.0, 89.6)] { + let mut px = image(0.5); + apply(&mut px, n, n, s, hl, SRGB); + let got = px[175 * n + 100][0] * 255.0; + assert!((got - want).abs() < 5.0, "{s} {hl}: {got} vs {want}"); + } + } + + #[test] + fn highlights_lower_a_bright_area_and_keep_the_darkest() { + let (w, h) = (192, 192); + let mut px = grey(w, h, |x, y| if (64..128).contains(&x) && (64..128).contains(&y) { 0.8 } else { 0.2 }); + apply(&mut px, w, h, 0.0, -100.0, SRGB); + assert!(px[96 * w + 96][0] < 0.75, "{}", px[96 * w + 96][0]); + assert!((px[5 * w + 5][0] - 0.2).abs() < 0.01, "{}", px[5 * w + 5][0]); + } + + #[test] + fn smooth_gradients_stay_smooth() { + // A soft ramp over eight stops, as in an out-of-focus background. Coefficients interpolated + // between references too far apart drew contour lines across such ramps (Shadows +100 on + // a D4 raw): the output must rise steadily, without ripples. + let (w, h) = (640, 48); + let mut px = grey(w, h, |x, _| crate::photo_util::linear_to_srgb((x as f32 / (w - 1) as f32 * 8.0 - 9.0).exp2())); + for (s, hl) in [(100.0, 0.0), (0.0, -100.0), (-100.0, 100.0)] { + let mut p = px.clone(); + apply(&mut p, w, h, s, hl, SRGB); + let row: Vec = (0..w).map(|x| crate::photo_util::srgb_to_linear(p[24 * w + x][0]).max(1e-6).log2()).collect(); + let steps: Vec = row.windows(2).map(|d| d[1] - d[0]).collect(); + assert!(steps.iter().all(|d| *d > 0.0), "{s} {hl}: the ramp reverses"); + let jumps: Vec = steps.windows(2).map(|d| (d[1] - d[0]).abs()).collect(); + let ripple = jumps[16..jumps.len() - 16].iter().fold(0.0f32, |a, b| a.max(*b)); + // The ramp rises 0.0125 stops per pixel; its slope may change by a third at most. + assert!(ripple < 0.004, "{s} {hl}: slope jumps by {ripple} stops per pixel"); + } + px.clear(); + } + + #[test] + fn the_result_does_not_depend_on_the_image_size() { + let at = |n: usize| { + let mut px = grey(n, n, |x, y| if x < n / 3 || y < n / 4 { 0.08 } else { 0.7 }); + apply(&mut px, n, n, 70.0, -60.0, SRGB); + [px[(n / 8) * n + n / 8][0], px[(3 * n / 4) * n + 3 * n / 4][0]] + }; + let (a, b) = (at(96), at(384)); + assert!((a[0] - b[0]).abs() < 0.02 && (a[1] - b[1]).abs() < 0.02, "{a:?} {b:?}"); + } +} diff --git a/crates/algo/src/camera_raw/whites_blacks.rs b/crates/algo/src/camera_raw/whites_blacks.rs new file mode 100644 index 0000000000..382ade50e9 --- /dev/null +++ b/crates/algo/src/camera_raw/whites_blacks.rs @@ -0,0 +1,418 @@ +//! Whites and Blacks as Adobe Camera Raw (process version 2012) applies them. +//! +//! Measured as a black box on the Camera Raw Filter of Photoshop 25.4: grey blocks with chosen +//! histograms went through the filter by script and came back exactly; no Adobe code or data +//! was used. +//! +//! - Both are global curves of the grey level, applied after Shadows and Highlights, Whites +//! first. On an image whose darkest and lightest percent reach black and white the curve of a +//! slider value `x` is the measured row at `x` of [`WHITES`] or [`BLACKS`]. +//! - The direction that only compresses (Whites down, Blacks up) is that fixed curve on every +//! image. The direction that clips (Whites up, Blacks down) adapts: the row used is +//! `x · A(BP, WP)`. On the block images BP and WP are their darkest and lightest levels; on +//! photographs Photoshop's amounts follow best with BP the 3rd percentile of the luminance and +//! WP the 99.5th of the brightest channel, and Blacks at least 1.2 times. A low +//! white point makes Whites +100 stronger (up to 2.7 times), a raised black point Blacks +//! (up to 1.45 times at −40). Whites rows past +100 come from images where `A > 1`. +//! - Not reproduced: past −40 Photoshop's Blacks grows faster on images with a raised black +//! point (on one spanning 0.6–1 its −100 blacks out everything below 0.74, here below about +//! 0.15); its curves there are not rows of the full-range family. +//! - Colour: the clipping directions act on each channel, the compressing ones scale the pixel +//! by the curve's change of its luminance (closest to Photoshop on saturated colours). + +use super::Encoding; +use crate::photo_util::par_rows; + +/// Grey levels the curves are sampled at: 0, 1/32, …, 1 (sRGB-encoded). +const STEPS: usize = 33; +/// The first row's internal amount; rows are 10 apart. +const WHITES_FROM: f32 = -100.0; +const BLACKS_FROM: f32 = -100.0; +// Measured levels; some happen to lie near π/8 and π/4. +/// Whites curves at internal amounts -100, -90, …, 270 (rows) over grey levels 0, 1/32, …, 1. +#[allow(clippy::approx_constant)] +static WHITES: [[f32; 33]; 38] = [ + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2391, 0.2671, 0.2946, 0.3215, 0.3480, 0.3738, 0.3995, 0.4256, 0.4517, 0.4774, 0.5028, + 0.5278, 0.5524, 0.5768, 0.6008, 0.6245, 0.6482, 0.6717, 0.6957, 0.7207, 0.7483, 0.7814, 0.8263, 0.8949, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1523, 0.1817, 0.2107, 0.2392, 0.2672, 0.2947, 0.3217, 0.3482, 0.3742, 0.4000, 0.4263, 0.4526, 0.4785, 0.5042, + 0.5296, 0.5548, 0.5797, 0.6044, 0.6291, 0.6539, 0.6789, 0.7046, 0.7317, 0.7617, 0.7969, 0.8425, 0.9074, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2108, 0.2393, 0.2673, 0.2950, 0.3221, 0.3487, 0.3748, 0.4008, 0.4274, 0.4540, 0.4803, 0.5064, + 0.5323, 0.5580, 0.5837, 0.6093, 0.6350, 0.6610, 0.6875, 0.7149, 0.7438, 0.7756, 0.8122, 0.8576, 0.9184, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1524, 0.1818, 0.2109, 0.2395, 0.2677, 0.2954, 0.3227, 0.3495, 0.3759, 0.4022, 0.4291, 0.4561, 0.4828, 0.5094, + 0.5360, 0.5624, 0.5888, 0.6154, 0.6423, 0.6695, 0.6973, 0.7261, 0.7566, 0.7897, 0.8271, 0.8716, 0.9280, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0926, 0.1226, 0.1525, 0.1820, 0.2111, 0.2398, 0.2682, 0.2961, 0.3237, 0.3508, 0.3775, 0.4042, 0.4317, 0.4591, 0.4864, 0.5136, + 0.5408, 0.5680, 0.5953, 0.6229, 0.6508, 0.6791, 0.7082, 0.7382, 0.7698, 0.8037, 0.8412, 0.8843, 0.9365, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0927, 0.1227, 0.1527, 0.1822, 0.2116, 0.2404, 0.2690, 0.2972, 0.3251, 0.3527, 0.3799, 0.4071, 0.4351, 0.4630, 0.4910, 0.5189, + 0.5469, 0.5749, 0.6031, 0.6316, 0.6605, 0.6899, 0.7199, 0.7509, 0.7832, 0.8174, 0.8544, 0.8958, 0.9439, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0621, 0.0927, 0.1229, 0.1529, 0.1827, 0.2122, 0.2414, 0.2702, 0.2989, 0.3272, 0.3553, 0.3831, 0.4110, 0.4396, 0.4682, 0.4968, 0.5254, + 0.5542, 0.5830, 0.6121, 0.6414, 0.6712, 0.7014, 0.7322, 0.7637, 0.7964, 0.8305, 0.8668, 0.9062, 0.9503, 0.9999, + ], + [ + 0.0000, 0.0298, 0.0622, 0.0928, 0.1231, 0.1534, 0.1834, 0.2132, 0.2427, 0.2720, 0.3011, 0.3300, 0.3587, 0.3872, 0.4160, 0.4452, 0.4745, 0.5038, 0.5332, + 0.5627, 0.5923, 0.6221, 0.6522, 0.6826, 0.7134, 0.7447, 0.7765, 0.8092, 0.8429, 0.8781, 0.9155, 0.9559, 1.0000, + ], + [ + 0.0000, 0.0298, 0.0623, 0.0930, 0.1236, 0.1541, 0.1844, 0.2145, 0.2445, 0.2744, 0.3041, 0.3337, 0.3632, 0.3925, 0.4221, 0.4520, 0.4820, 0.5120, 0.5421, + 0.5723, 0.6026, 0.6330, 0.6636, 0.6946, 0.7257, 0.7572, 0.7890, 0.8214, 0.8545, 0.8885, 0.9237, 0.9608, 1.0000, + ], + [ + 0.0000, 0.0298, 0.0624, 0.0934, 0.1242, 0.1551, 0.1858, 0.2164, 0.2470, 0.2775, 0.3080, 0.3383, 0.3687, 0.3989, 0.4293, 0.4600, 0.4906, 0.5213, 0.5520, + 0.5827, 0.6136, 0.6445, 0.6755, 0.7067, 0.7380, 0.7694, 0.8011, 0.8330, 0.8652, 0.8978, 0.9310, 0.9651, 1.0000, + ], + [ + 0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625, + 0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0626, 0.0940, 0.1253, 0.1567, 0.1881, 0.2195, 0.2510, 0.2825, 0.3140, 0.3456, 0.3772, 0.4089, 0.4405, 0.4720, 0.5036, 0.5351, 0.5667, + 0.5983, 0.6298, 0.6613, 0.6929, 0.7244, 0.7559, 0.7874, 0.8189, 0.8503, 0.8816, 0.9128, 0.9438, 0.9742, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0627, 0.0941, 0.1257, 0.1573, 0.1890, 0.2208, 0.2526, 0.2846, 0.3167, 0.3488, 0.3811, 0.4134, 0.4456, 0.4777, 0.5098, 0.5419, 0.5741, + 0.6062, 0.6383, 0.6704, 0.7025, 0.7345, 0.7664, 0.7983, 0.8301, 0.8617, 0.8931, 0.9241, 0.9543, 0.9829, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0628, 0.0944, 0.1262, 0.1582, 0.1903, 0.2225, 0.2550, 0.2876, 0.3204, 0.3534, 0.3867, 0.4200, 0.4529, 0.4858, 0.5186, 0.5516, 0.5845, + 0.6174, 0.6502, 0.6830, 0.7157, 0.7482, 0.7807, 0.8131, 0.8451, 0.8769, 0.9082, 0.9385, 0.9673, 0.9928, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0629, 0.0948, 0.1269, 0.1594, 0.1920, 0.2249, 0.2582, 0.2917, 0.3256, 0.3597, 0.3942, 0.4289, 0.4628, 0.4966, 0.5305, 0.5644, 0.5983, + 0.6321, 0.6658, 0.6994, 0.7327, 0.7660, 0.7989, 0.8316, 0.8639, 0.8956, 0.9263, 0.9553, 0.9815, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0631, 0.0953, 0.1280, 0.1610, 0.1944, 0.2282, 0.2625, 0.2973, 0.3325, 0.3683, 0.4045, 0.4407, 0.4758, 0.5109, 0.5460, 0.5811, 0.6161, + 0.6510, 0.6857, 0.7201, 0.7541, 0.7879, 0.8213, 0.8543, 0.8864, 0.9175, 0.9468, 0.9733, 0.9950, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0299, 0.0633, 0.0961, 0.1293, 0.1632, 0.1976, 0.2327, 0.2684, 0.3048, 0.3419, 0.3798, 0.4183, 0.4562, 0.4928, 0.5294, 0.5660, 0.6024, 0.6387, + 0.6748, 0.7104, 0.7456, 0.7803, 0.8145, 0.8480, 0.8807, 0.9121, 0.9417, 0.9684, 0.9907, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0300, 0.0637, 0.0971, 0.1313, 0.1663, 0.2020, 0.2387, 0.2763, 0.3149, 0.3546, 0.3952, 0.4365, 0.4763, 0.5149, 0.5532, 0.5914, 0.6294, 0.6670, + 0.7041, 0.7406, 0.7763, 0.8113, 0.8456, 0.8788, 0.9104, 0.9401, 0.9667, 0.9888, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0300, 0.0641, 0.0984, 0.1338, 0.1703, 0.2079, 0.2468, 0.2870, 0.3286, 0.3717, 0.4161, 0.4602, 0.5024, 0.5430, 0.5833, 0.6232, 0.6627, 0.7015, + 0.7394, 0.7764, 0.8123, 0.8472, 0.8808, 0.9126, 0.9420, 0.9681, 0.9896, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0302, 0.0647, 0.1003, 0.1374, 0.1760, 0.2160, 0.2579, 0.3016, 0.3472, 0.3949, 0.4436, 0.4908, 0.5355, 0.5784, 0.6207, 0.6622, 0.7029, 0.7424, + 0.7806, 0.8176, 0.8530, 0.8868, 0.9185, 0.9474, 0.9726, 0.9926, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0304, 0.0656, 0.1029, 0.1423, 0.1837, 0.2271, 0.2731, 0.3215, 0.3726, 0.4262, 0.4791, 0.5294, 0.5769, 0.6220, 0.6660, 0.7086, 0.7498, 0.7893, + 0.8272, 0.8631, 0.8969, 0.9281, 0.9560, 0.9795, 0.9967, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0305, 0.0679, 0.1094, 0.1509, 0.1968, 0.2459, 0.2956, 0.3510, 0.4094, 0.4680, 0.5248, 0.5781, 0.6278, 0.6742, 0.7195, 0.7624, 0.8031, 0.8417, + 0.8779, 0.9113, 0.9414, 0.9675, 0.9886, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0306, 0.0702, 0.1160, 0.1623, 0.2144, 0.2709, 0.3243, 0.3885, 0.4556, 0.5199, 0.5807, 0.6369, 0.6884, 0.7355, 0.7799, 0.8217, 0.8606, 0.8964, + 0.9288, 0.9570, 0.9803, 0.9958, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0308, 0.0725, 0.1225, 0.1737, 0.2320, 0.2960, 0.3641, 0.4393, 0.5133, 0.5825, 0.6460, 0.7032, 0.7544, 0.8003, 0.8426, 0.8817, 0.9168, 0.9475, + 0.9733, 0.9924, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0322, 0.0751, 0.1303, 0.1918, 0.2594, 0.3348, 0.4189, 0.5044, 0.5841, 0.6565, 0.7208, 0.7771, 0.8262, 0.8692, 0.9074, 0.9408, 0.9686, 0.9900, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0331, 0.0818, 0.1482, 0.2171, 0.2977, 0.3892, 0.4865, 0.5779, 0.6602, 0.7322, 0.7943, 0.8470, 0.8915, 0.9286, 0.9600, 0.9844, 0.9996, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0340, 0.0884, 0.1661, 0.2544, 0.3540, 0.4658, 0.5729, 0.6681, 0.7496, 0.8183, 0.8748, 0.9208, 0.9568, 0.9832, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.3356, 0.5799, 0.6166, 0.6166, 0.6166, 0.6166, 0.6624, 0.7556, 0.8321, 0.8935, 0.9411, 0.9755, 0.9970, 0.9998, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.5007, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.1655, 0.1655, 0.2972, 0.4728, 0.6327, 0.7616, 0.8598, 0.9317, 0.9790, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0457, 0.1830, 0.3836, 0.5790, 0.7379, 0.8549, 0.9360, 0.9869, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0520, 0.2329, 0.4864, 0.6989, 0.8449, 0.9403, 0.9884, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0773, 0.3027, 0.6040, 0.8058, 0.9302, 0.9930, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.1454, 0.4738, 0.7615, 0.9074, 0.9908, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.0000, 0.0964, 0.5416, 0.8589, 0.9897, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.1538, 0.1538, 0.7080, 0.9596, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.8439, 0.8439, 0.8439, 0.9993, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], + [ + 0.9228, 0.9228, 0.9228, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, + ], +]; +/// Blacks curves at internal amounts -100, …, 100 in steps of 10. +#[allow(clippy::approx_constant)] +static BLACKS: [[f32; 33]; 21] = [ + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0290, 0.0572, 0.0879, 0.1222, 0.1580, 0.1935, 0.2287, 0.2636, 0.2988, 0.3355, 0.3741, 0.4151, 0.4571, + 0.4990, 0.5406, 0.5818, 0.6226, 0.6631, 0.7031, 0.7424, 0.7811, 0.8192, 0.8568, 0.8937, 0.9300, 0.9655, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0015, 0.0363, 0.0640, 0.0956, 0.1300, 0.1651, 0.2000, 0.2345, 0.2686, 0.3026, 0.3369, 0.3727, 0.4105, 0.4495, 0.4885, + 0.5272, 0.5657, 0.6039, 0.6419, 0.6796, 0.7169, 0.7538, 0.7903, 0.8264, 0.8621, 0.8974, 0.9322, 0.9664, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0000, 0.0346, 0.0625, 0.0934, 0.1271, 0.1616, 0.1959, 0.2298, 0.2635, 0.2969, 0.3301, 0.3638, 0.3990, 0.4357, 0.4727, 0.5094, + 0.5460, 0.5824, 0.6187, 0.6547, 0.6906, 0.7262, 0.7615, 0.7965, 0.8313, 0.8657, 0.8998, 0.9336, 0.9671, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0000, 0.0235, 0.0554, 0.0850, 0.1175, 0.1514, 0.1852, 0.2187, 0.2519, 0.2849, 0.3178, 0.3505, 0.3837, 0.4183, 0.4537, 0.4891, 0.5243, + 0.5594, 0.5944, 0.6292, 0.6639, 0.6985, 0.7329, 0.7671, 0.8010, 0.8347, 0.8683, 0.9016, 0.9347, 0.9676, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0020, 0.0438, 0.0724, 0.1035, 0.1365, 0.1699, 0.2030, 0.2359, 0.2686, 0.3011, 0.3336, 0.3659, 0.3986, 0.4327, 0.4669, 0.5011, 0.5352, + 0.5692, 0.6031, 0.6369, 0.6706, 0.7043, 0.7378, 0.7711, 0.8043, 0.8373, 0.8702, 0.9029, 0.9355, 0.9679, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0230, 0.0567, 0.0865, 0.1182, 0.1510, 0.1839, 0.2164, 0.2489, 0.2812, 0.3133, 0.3455, 0.3775, 0.4100, 0.4433, 0.4767, 0.5100, 0.5433, + 0.5765, 0.6096, 0.6426, 0.6756, 0.7086, 0.7414, 0.7741, 0.8067, 0.8392, 0.8716, 0.9039, 0.9361, 0.9682, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0368, 0.0670, 0.0976, 0.1294, 0.1619, 0.1943, 0.2265, 0.2586, 0.2906, 0.3225, 0.3544, 0.3862, 0.4184, 0.4512, 0.4839, 0.5166, 0.5492, + 0.5818, 0.6144, 0.6469, 0.6793, 0.7117, 0.7441, 0.7763, 0.8085, 0.8406, 0.8727, 0.9046, 0.9365, 0.9683, 1.0000, + ], + [ + 0.0000, 0.0000, 0.0444, 0.0749, 0.1058, 0.1377, 0.1699, 0.2020, 0.2339, 0.2657, 0.2975, 0.3292, 0.3609, 0.3926, 0.4245, 0.4568, 0.4891, 0.5213, 0.5535, + 0.5857, 0.6179, 0.6499, 0.6820, 0.7140, 0.7460, 0.7779, 0.8098, 0.8416, 0.8734, 0.9052, 0.9368, 0.9685, 1.0000, + ], + [ + 0.0000, 0.0086, 0.0511, 0.0817, 0.1128, 0.1445, 0.1763, 0.2080, 0.2397, 0.2713, 0.3029, 0.3344, 0.3660, 0.3975, 0.4292, 0.4611, 0.4930, 0.5249, 0.5567, + 0.5886, 0.6204, 0.6522, 0.6839, 0.7157, 0.7475, 0.7791, 0.8108, 0.8424, 0.8740, 0.9056, 0.9371, 0.9686, 1.0000, + ], + [ + 0.0000, 0.0198, 0.0564, 0.0874, 0.1186, 0.1501, 0.1817, 0.2133, 0.2447, 0.2762, 0.3076, 0.3390, 0.3705, 0.4018, 0.4333, 0.4649, 0.4965, 0.5281, 0.5597, + 0.5912, 0.6227, 0.6543, 0.6858, 0.7173, 0.7488, 0.7802, 0.8117, 0.8431, 0.8745, 0.9059, 0.9373, 0.9686, 1.0000, + ], + [ + 0.0000, 0.0312, 0.0625, 0.0938, 0.1250, 0.1562, 0.1875, 0.2188, 0.2500, 0.2812, 0.3125, 0.3438, 0.3750, 0.4062, 0.4375, 0.4688, 0.5000, 0.5312, 0.5625, + 0.5938, 0.6250, 0.6562, 0.6875, 0.7188, 0.7500, 0.7812, 0.8125, 0.8438, 0.8750, 0.9062, 0.9375, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0313, 0.0650, 0.0979, 0.1303, 0.1624, 0.1942, 0.2260, 0.2576, 0.2891, 0.3206, 0.3520, 0.3833, 0.4146, 0.4455, 0.4763, 0.5070, 0.5378, 0.5684, + 0.5991, 0.6298, 0.6605, 0.6912, 0.7219, 0.7526, 0.7833, 0.8141, 0.8450, 0.8758, 0.9067, 0.9377, 0.9688, 1.0000, + ], + [ + 0.0000, 0.0326, 0.0677, 0.1022, 0.1357, 0.1686, 0.2010, 0.2332, 0.2652, 0.2969, 0.3286, 0.3601, 0.3915, 0.4227, 0.4532, 0.4835, 0.5138, 0.5440, 0.5741, + 0.6042, 0.6344, 0.6645, 0.6946, 0.7248, 0.7550, 0.7853, 0.8156, 0.8461, 0.8766, 0.9072, 0.9380, 0.9689, 1.0000, + ], + [ + 0.0000, 0.0341, 0.0706, 0.1066, 0.1413, 0.1749, 0.2079, 0.2405, 0.2728, 0.3048, 0.3366, 0.3682, 0.3996, 0.4307, 0.4608, 0.4906, 0.5203, 0.5500, 0.5796, + 0.6092, 0.6387, 0.6683, 0.6979, 0.7276, 0.7573, 0.7871, 0.8170, 0.8472, 0.8773, 0.9077, 0.9382, 0.9690, 1.0000, + ], + [ + 0.0000, 0.0354, 0.0736, 0.1113, 0.1472, 0.1814, 0.2149, 0.2479, 0.2805, 0.3127, 0.3446, 0.3763, 0.4077, 0.4385, 0.4681, 0.4974, 0.5267, 0.5558, 0.5849, + 0.6139, 0.6429, 0.6720, 0.7011, 0.7303, 0.7595, 0.7889, 0.8184, 0.8481, 0.8780, 0.9081, 0.9384, 0.9690, 1.0000, + ], + [ + 0.0000, 0.0368, 0.0768, 0.1162, 0.1531, 0.1881, 0.2221, 0.2554, 0.2882, 0.3206, 0.3527, 0.3843, 0.4158, 0.4461, 0.4753, 0.5041, 0.5328, 0.5615, 0.5900, + 0.6185, 0.6470, 0.6755, 0.7041, 0.7328, 0.7616, 0.7906, 0.8197, 0.8491, 0.8787, 0.9085, 0.9386, 0.9691, 1.0000, + ], + [ + 0.0000, 0.0381, 0.0802, 0.1214, 0.1593, 0.1949, 0.2293, 0.2630, 0.2961, 0.3286, 0.3607, 0.3924, 0.4236, 0.4536, 0.4823, 0.5106, 0.5388, 0.5669, 0.5949, + 0.6229, 0.6509, 0.6789, 0.7070, 0.7352, 0.7636, 0.7922, 0.8209, 0.8500, 0.8793, 0.9089, 0.9388, 0.9691, 1.0000, + ], + [ + 0.0000, 0.0394, 0.0837, 0.1267, 0.1656, 0.2018, 0.2367, 0.2707, 0.3039, 0.3365, 0.3686, 0.4003, 0.4314, 0.4608, 0.4890, 0.5168, 0.5445, 0.5721, 0.5996, + 0.6271, 0.6546, 0.6822, 0.7098, 0.7375, 0.7655, 0.7937, 0.8221, 0.8509, 0.8799, 0.9092, 0.9390, 0.9692, 1.0000, + ], + [ + 0.0000, 0.0407, 0.0875, 0.1322, 0.1720, 0.2088, 0.2441, 0.2784, 0.3118, 0.3444, 0.3765, 0.4082, 0.4389, 0.4679, 0.4955, 0.5229, 0.5501, 0.5771, 0.6042, + 0.6312, 0.6582, 0.6853, 0.7124, 0.7398, 0.7673, 0.7952, 0.8233, 0.8517, 0.8804, 0.9096, 0.9391, 0.9692, 1.0000, + ], + [ + 0.0000, 0.0421, 0.0915, 0.1379, 0.1786, 0.2160, 0.2516, 0.2861, 0.3196, 0.3523, 0.3844, 0.4159, 0.4462, 0.4747, 0.5019, 0.5287, 0.5554, 0.5820, 0.6085, + 0.6351, 0.6616, 0.6882, 0.7149, 0.7419, 0.7690, 0.7965, 0.8243, 0.8524, 0.8810, 0.9099, 0.9393, 0.9693, 1.0000, + ], + [ + 0.0000, 0.0436, 0.0957, 0.1438, 0.1853, 0.2232, 0.2592, 0.2938, 0.3274, 0.3601, 0.3922, 0.4235, 0.4533, 0.4813, 0.5080, 0.5343, 0.5605, 0.5866, 0.6127, + 0.6387, 0.6648, 0.6910, 0.7173, 0.7439, 0.7707, 0.7978, 0.8253, 0.8532, 0.8815, 0.9102, 0.9394, 0.9693, 1.0000, + ], +]; +const BPS: [f32; 9] = [0.0000, 0.0200, 0.0500, 0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000]; +const WPS: [f32; 8] = [0.1000, 0.2000, 0.3000, 0.4500, 0.6000, 0.8000, 0.9000, 1.0000]; +static AW: [[f32; 8]; 9] = [ + [1.7750, 2.3875, 2.4000, 2.1625, 1.9438, 1.6125, 1.3875, 1.0000], + [1.8125, 2.5250, 2.3937, 2.1688, 1.9375, 1.6062, 1.3938, 0.9875], + [2.7000, 2.4688, 2.2750, 2.0688, 1.8875, 1.6000, 1.3875, 0.9938], + [2.3250, 2.3250, 2.1875, 1.9750, 1.7937, 1.5188, 1.3250, 1.0437], + [2.0187, 2.0187, 2.0187, 1.8625, 1.7063, 1.4062, 1.2250, 0.9250], + [1.7500, 1.7500, 1.7500, 1.7500, 1.6438, 1.3562, 1.1687, 0.8625], + [1.4937, 1.4937, 1.4937, 1.4937, 1.4937, 1.2875, 1.1000, 0.7875], + [1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.1687, 1.0375, 0.7562], + [0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.8250, 0.7562], +]; +static AB: [[f32; 8]; 9] = [ + [0.9938, 0.9812, 1.0000, 1.0125, 0.9563, 0.9688, 1.0437, 1.0000], + [1.0437, 1.0250, 1.0125, 1.0000, 1.0000, 1.0375, 1.0000, 0.9938], + [1.2312, 1.2063, 1.1875, 1.1625, 1.1438, 1.1438, 1.1375, 1.1250], + [1.2500, 1.2500, 1.2312, 1.2125, 1.2125, 1.2375, 1.2375, 1.2375], + [1.1750, 1.1750, 1.1750, 1.1750, 1.2063, 1.2688, 1.3125, 1.3375], + [1.1812, 1.1812, 1.1812, 1.1812, 1.2125, 1.2812, 1.3313, 1.4125], + [1.2375, 1.2375, 1.2375, 1.2375, 1.2375, 1.3062, 1.3562, 1.4375], + [1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3250, 1.3687, 1.4500], + [1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.3938, 1.4312], +]; + +/// A curve row at a fractional internal amount `x`, at grey level `v` (both interpolated). +fn curve(rows: &[[f32; STEPS]], from: f32, x: f32, v: f32) -> f32 { + if v >= 1.0 { + return 1.0; + } + let r = ((x - from) / 10.0).clamp(0.0, (rows.len() - 1) as f32); + let i = (r as usize).min(rows.len() - 2); + let t = r - i as f32; + let at = |row: &[f32; STEPS]| { + let p = v.max(0.0) * (STEPS - 1) as f32; + let j = (p as usize).min(STEPS - 2); + let s = p - j as f32; + row[j] + (row[j + 1] - row[j]) * s + }; + at(&rows[i]) * (1.0 - t) + at(&rows[i + 1]) * t +} + +/// Whites at internal amount `x` (−100 … 270) of grey level `v`. +pub(crate) fn whites(v: f32, x: f32) -> f32 { + curve(&WHITES, WHITES_FROM, x, v) +} + +/// Blacks at internal amount `x` (−100 … 100) of grey level `v`. +pub(crate) fn blacks(v: f32, x: f32) -> f32 { + curve(&BLACKS, BLACKS_FROM, x, v) +} + +/// The least Blacks adaptation on photographs. +const PHOTO_BLACKS: f32 = 1.2; + +/// Bilinear lookup of an adaptation table at black point `bp` and white point `wp`. +fn adapt(table: &[[f32; 8]; 9], bp: f32, wp: f32) -> f32 { + let idx = |grid: &[f32], v: f32| { + let v = v.clamp(grid[0], grid[grid.len() - 1]); + let i = grid.windows(2).position(|w| v <= w[1]).unwrap_or(grid.len() - 2); + (i, (v - grid[i]) / (grid[i + 1] - grid[i])) + }; + let (i, s) = idx(&BPS, bp); + let (j, t) = idx(&WPS, wp); + let row = |i: usize| table[i][j] * (1.0 - t) + table[i][j + 1] * t; + row(i) * (1.0 - s) + row(i + 1) * s +} + +/// The internal amounts of the Whites and Blacks sliders (−100 … 100) for an image whose 1st +/// and 99th luminance percentiles are the grey levels `bp` and `wp` (sRGB-encoded). +pub(crate) fn amounts(whites: f32, blacks: f32, bp: f32, wp: f32) -> (f32, f32) { + let w = if whites > 0.0 { whites * adapt(&AW, bp, wp) } else { whites }; + // On photographs Photoshop's Blacks is about 1.2 times the block images' amount even where + // their black point is 0 (ten public-domain photos: 1.08–1.36). + let b = if blacks < 0.0 { blacks * adapt(&AB, bp, wp).max(PHOTO_BLACKS) } else { blacks }; + (w.clamp(-100.0, 270.0), b.clamp(-100.0, 100.0)) +} + +/// Applies Whites and Blacks (internal amounts `w`, `b`) to straight RGBA pixels (`width` per +/// row) encoded with `enc`. Values above 1 (float documents) keep their excess. +pub(crate) fn apply(px: &mut [[f32; 4]], width: usize, w: f32, b: f32, enc: Encoding) { + if w == 0.0 && b == 0.0 { + return; + } + // A grey level's new level, both sRGB-encoded. + let grey = |v: f32| { + let v = if w != 0.0 { whites(v, w) } else { v }; + if b != 0.0 { blacks(v, b) } else { v } + }; + let per_channel = w > 0.0 || b < 0.0; + par_rows(px, width, 1, |_, row| { + for q in row.iter_mut() { + let lin = [0, 1, 2].map(|c| enc.decode(q[c])); + if per_channel { + for c in 0..3 { + let over = (lin[c] - 1.0).max(0.0); + q[c] = enc.encode(super::srgb_decode(grey(super::srgb_encode(lin[c].min(1.0)))) + over); + } + } else { + let y = super::luma_linear(lin).max(0.0); + if y <= 1e-7 { + continue; + } + let y2 = super::srgb_decode(grey(super::srgb_encode(y.min(1.0)))) + (y - 1.0).max(0.0); + let k = y2 / y; + for c in 0..3 { + q[c] = enc.encode(lin[c] * k); + } + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_range_rows_match_photoshop() { + // Photoshop 25.4 on a 64-level block image spanning black to white (sRGB levels). + assert!((whites(97.1 / 255.0, 100.0) * 255.0 - 137.4).abs() < 0.6); + assert!((whites(194.3 / 255.0, -100.0) * 255.0 - 167.6).abs() < 0.6); + assert!((blacks(97.1 / 255.0, -100.0) * 255.0 - 60.0).abs() < 0.6); + assert!((blacks(48.6 / 255.0, 100.0) * 255.0 - 67.0).abs() < 0.6); + // The compressing directions keep black and white. + assert_eq!(whites(1.0, -100.0), 1.0); + assert_eq!(blacks(0.0, 100.0), 0.0); + } + + #[test] + fn the_clipping_directions_adapt_to_the_image() { + // A dark, low-contrast image needs more: Whites +50 acts like +94.5 on [0.05, 0.6]. + let (w, _) = amounts(50.0, 0.0, 0.05, 0.6); + assert!((w - 94.5).abs() < 2.0, "{w}"); + // A raised black point strengthens Blacks −40 to −58 on [0.6, 1]. + let (_, b) = amounts(0.0, -40.0, 0.6, 1.0); + assert!((b + 58.0).abs() < 1.0, "{b}"); + // Never below the photographs' 1.2. + assert!((amounts(0.0, -50.0, 0.0, 1.0).1 + 60.0).abs() < 1e-3); + // The compressing directions never adapt. + assert_eq!(amounts(-50.0, 50.0, 0.3, 0.6), (-50.0, 50.0)); + } +} diff --git a/crates/engine/src/lens_cmds.rs b/crates/engine/src/lens_cmds.rs index 31d69300f4..379ca2402a 100644 --- a/crates/engine/src/lens_cmds.rs +++ b/crates/engine/src/lens_cmds.rs @@ -369,7 +369,28 @@ fn adaptive_wide_angle(s: &mut Session, p: &Value) -> Result { })) } +/// The transfer curve Camera Raw decodes `doc`'s values with ([`CameraRaw::encoding_gamma`]): +/// ProPhoto's gamma 1.8 (raws open in it), Adobe RGB's 563/256, 1 for linear sRGB, else 0 for +/// the sRGB curve. +pub fn encoding_gamma(doc: &Document) -> f32 { + use photocraft_cms::Builtin; + let profile = crate::color_cmds::document_profile(doc); + [(Builtin::ProPhotoCompat, 1.8), (Builtin::AdobeRgbCompat, 563.0 / 256.0), (Builtin::LinearSrgb, 1.0)] + .into_iter() + .find(|(b, _)| profile.same_colors(b.profile())) + .map_or(0.0, |(_, g)| g) +} + fn camera_raw_cmd(s: &mut Session, p: &Value) -> Result { + // The document's transfer curve goes with the settings, so a Smart Filter keeps it. + let mut p = p.clone(); + if p.get("encodingGamma").is_none() + && let Some(g) = s.active().map(|d| encoding_gamma(&d.doc)).filter(|g| *g > 0.0) + && let Value::Object(m) = &mut p + { + m.insert("encodingGamma".into(), json!((f64::from(g) * 1e4).round() / 1e4)); + } + let p = &p; let cr = raw_params(RAW, p)?; // New settings are strict; stored Smart Filters re-apply through the lenient `raw_params`. cr.validate().map_err(|e| bad(RAW, e))?; @@ -583,6 +604,32 @@ mod tests { assert!((p[0] - p[2]).abs() < 0.01, "{p:?}"); } + #[test] + fn camera_raw_decodes_light_with_the_documents_curve() { + // A raw opens in ProPhoto (gamma 1.8): the filter keeps that with its settings, so Whites, + // Blacks, Shadows and Highlights work on the same light as when it was applied. + let mut s = session(16); + s.execute("edit.assignProfile", json!({"profile": "prophoto-compat"})).unwrap(); + assert_eq!(encoding_gamma(&s.active().unwrap().doc), 1.8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute(RAW, json!({"shadows": 40, "whites": -30})).unwrap(); + let d = s.active().unwrap(); + let filters = match &d.doc.layer(d.active_layer.unwrap()).unwrap().content { + LayerContent::Smart(so) => so.smart_filters.clone(), + _ => panic!("smart object expected"), + }; + assert_eq!(filters[0].params["encodingGamma"], json!(1.8)); + // sRGB documents use the sRGB curve, the default, and store nothing. + let mut s = session(8); + assert_eq!(encoding_gamma(&s.active().unwrap().doc), 0.0); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute(RAW, json!({"shadows": 40})).unwrap(); + let d = s.active().unwrap(); + if let LayerContent::Smart(so) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content { + assert!(so.smart_filters[0].params.get("encodingGamma").is_none()); + } + } + #[test] fn camera_raw_rejects_an_area_too_large_to_develop() { // #962: `area.width() * area.height()` overflowed u32 on a legal 65536×65536 canvas, diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 7e1f916481..26e62d25fd 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -15,6 +15,7 @@ use std::sync::Arc; use photocraft_cms::{Builtin, Intent, Profile, Transform}; use photocraft_codecs::{self as codecs, ChannelLayout, Format, Image, SampleType}; use photocraft_raw::{CameraLook, DevelopOptions, Limits, PreviewColorSpace, RawError, Reference, Sensor, WhiteBalance}; +pub use photocraft_raw::{Tone, retone}; use crate::flat::image_to_document; use crate::{ImportResult, IoError}; @@ -41,8 +42,9 @@ fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { Ok(img.oriented(o)?) } -/// Camera Raw's Temperature, Tint and Exposure, applied while developing the sensor data -/// (the open-time Camera Raw dialog) instead of to the developed RGB pixels. +/// Camera Raw's Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture and Dehaze, +/// applied while developing the sensor data (the open-time Camera Raw dialog) instead of to the +/// developed RGB pixels. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct RawTuning { /// −100…100, as `photocraft_algo::camera_raw::CameraRaw::temperature`. @@ -51,11 +53,23 @@ pub struct RawTuning { pub tint: f32, /// EV, as `CameraRaw::exposure`. pub exposure: f32, + /// −100…100, as `CameraRaw::shadows`: in linear light before the tone curve, as Camera Raw + /// does for raw files ([`photocraft_raw::Tone`]). + pub shadows: f32, + /// −100…100, as `CameraRaw::highlights`. + pub highlights: f32, + /// −100…100, as `CameraRaw::clarity`. + pub clarity: f32, + /// −100…100, as `CameraRaw::texture`. + pub texture: f32, + /// −100…100, as `CameraRaw::dehaze`. + pub dehaze: f32, } /// Develops a raw file with `tuning` applied at the raw stage: Exposure as develop exposure, -/// Temperature / Tint as gains on the camera's as-shot white-balance multipliers (the same -/// gains the Camera Raw filter applies to RGB). The second value is `false` when the file has +/// Shadows, Highlights, Clarity, Texture and Dehaze in linear light before the tone curve, Temperature / Tint as gains on the +/// camera's as-shot white-balance multipliers (the same gains the Camera Raw filter applies to +/// RGB). The second value is `false` when the file has /// no as-shot white balance: Temperature / Tint were then not applied, and the caller keeps /// them as RGB adjustments. pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result<(ImportResult, bool), IoError> { @@ -65,13 +79,18 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< return Err(IoError::Raw(RawError::Malformed(format!("exposure {} EV is out of range", tuning.exposure)))); } let sensor = photocraft_raw::decode(bytes, &limits()).map_err(IoError::Raw)?; - let mut opts = DevelopOptions { limits: limits(), ..Default::default() }; - // Use the same camera rendering as normal import. Fit at the camera's default WB and - // exposure before applying user controls, so the fit cannot cancel their adjustments. - if sensor.color.calibrations.is_empty() { - opts.camera_look = fit_preview_look(bytes, &sensor, &opts).ok(); - } - opts.exposure = exposure; + let slider = |v: f32| if v.is_finite() { v.clamp(-100.0, 100.0) } else { 0.0 }; + let tone = photocraft_raw::Tone { + shadows: slider(tuning.shadows), + highlights: slider(tuning.highlights), + clarity: slider(tuning.clarity), + texture: slider(tuning.texture), + dehaze: slider(tuning.dehaze), + }; + // The colour rendering the default open used (a look fitted to the camera's JPEG for files + // without colour calibration), so re-developing changes only what the sliders change. + let camera_look = default_look(bytes, &sensor); + let mut opts = DevelopOptions { limits: limits(), exposure, tone, camera_look, ..Default::default() }; let wb_applied = match sensor.camera_wb { Some(m) => { let g = wb_gains(tuning.temperature, tuning.tint); @@ -86,10 +105,45 @@ pub fn import_raw_tuned(name: &str, bytes: &[u8], tuning: &RawTuning) -> Result< } else { "estimated white balance".into() }; - let summary = format!("developed in Camera Raw (exposure {:+.2} EV, {wb})", tuning.exposure); + let tone = [("shadows", tone.shadows), ("highlights", tone.highlights), ("clarity", tone.clarity), ("texture", tone.texture), ("dehaze", tone.dehaze)] + .iter() + .filter(|(_, v)| *v != 0.0) + .map(|(k, v)| format!(", {k} {v:+.0}")) + .collect::(); + let summary = format!("developed in Camera Raw (exposure {:+.2} EV{tone}, {wb})", tuning.exposure); Ok((developed_document(name, format, dev, &summary)?, wb_applied)) } +/// The tone curve `bytes` develop with by default: the fitted look's for a file without colour +/// calibration (see [`import_raw_with`]), else the camera profile's (empty: none, or not a raw +/// this crate decodes). For previews of the open-time raw-stage sliders +/// ([`photocraft_raw::retone`]), in linear-light knots. +pub fn tone_curve(bytes: &[u8]) -> Vec<[f32; 2]> { + let Ok(sensor) = photocraft_raw::decode(bytes, &limits()) else { return Vec::new() }; + match default_look(bytes, &sensor) { + Some(look) => look.tone.as_ref().map(look_knots).unwrap_or_default(), + None => sensor.tone_curve, + } +} + +/// A fitted look's tone curve as knots for [`photocraft_raw::retone`] (which reads them +/// log-linearly): sampled densely enough that the two agree within a fraction of a code value. +fn look_knots(t: &photocraft_raw::ToneCurve) -> Vec<[f32; 2]> { + const N: usize = 256; + (1..=N).map(|i| (i as f64 / N as f64).powi(2)).map(|x| [x as f32, t.eval(x) as f32]).collect() +} + +/// The look a default open develops `bytes` with: fitted to the embedded JPEG when the file has +/// no colour calibration, is not monochrome and the fit clearly helps (as [`import_raw_with`] +/// does), else `None`. It is fitted at the camera's own white balance and exposure, so a +/// re-develop's sliders are not fitted away. +fn default_look(bytes: &[u8], sensor: &Sensor) -> Option { + if !sensor.color.calibrations.is_empty() || sensor.is_monochrome() { + return None; + } + fit_preview_look(bytes, sensor, &DevelopOptions { limits: limits(), ..Default::default() }).ok() +} + /// "Canon" + "Canon EOS 80D" and "NIKON CORPORATION" + "NIKON D3200" read as the model alone: /// the model already starts with the make's first word. fn camera_name(make: Option<&str>, model: Option<&str>) -> String { diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index d0bc370dbc..7a11f4fb03 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -214,7 +214,11 @@ fn camera_raw_tuning_keeps_the_default_look_and_applies_user_gains_after_fitting assert!(fitted(&normal.warnings)); let sensor = decode(&bytes, &Default::default()).unwrap(); let look = fit_preview_look(&bytes, &sensor, &DevelopOptions::default()).unwrap(); - for tuning in [RawTuning::default(), RawTuning { exposure: 0.5, ..Default::default() }, RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0 }] { + for tuning in [ + RawTuning::default(), + RawTuning { exposure: 0.5, ..Default::default() }, + RawTuning { exposure: -0.5, temperature: 20.0, tint: -15.0, ..Default::default() }, + ] { let (got, applied) = import_raw_tuned("camera.nef", &bytes, &tuning).unwrap(); assert!(applied && fitted(&got.warnings) && !fallback(&got.warnings), "{:?}", got.warnings); assert_eq!(got.document.depth, SampleType::U16); @@ -283,3 +287,28 @@ fn truncated_raws_with_a_fittable_preview_do_not_panic() { let _ = photocraft_io::raw::import_raw_tuned("x.nef", &b[..n], &Default::default()); } } + +/// The open-time Camera Raw re-develop keeps the colour rendering the default open used: a raw +/// without calibration gets the same look fitted to its JPEG, so neutral settings give the same +/// pixels, and the previews' tone curve is the look's. +#[test] +fn a_re_develop_keeps_the_fitted_look() { + let (w, h) = (480, 320); + let cam = patches(w, h); + let nef = nef_uncompressed(&cam, w, h, Some(camera_rendering(&cam, w, h))); + let opened = import("DSC_0003.NEF", &nef).unwrap(); + let (again, _) = photocraft_io::raw::import_raw_tuned("DSC_0003.NEF", &nef, &Default::default()).unwrap(); + assert!(fitted(&again.warnings), "{:?}", again.warnings); + let pixels = |d: &photocraft_doc::Document| { + let s = d.layers[0].surface().unwrap(); + let mut px = vec![[0.0f32; 4]; w * h]; + s.read_rgba_into(photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &mut px); + px + }; + assert_eq!(pixels(&opened.document), pixels(&again.document)); + let curve = photocraft_io::raw::tone_curve(&nef); + assert!(curve.len() > 2 && curve.windows(2).all(|k| k[1][0] > k[0][0] && k[1][1] >= k[0][1]), "{curve:?}"); + // The camera's brightening curve: mid-grey comes out lighter. + let mid = curve.iter().find(|k| k[0] >= 0.18).unwrap(); + assert!(mid[1] > mid[0], "{mid:?}"); +} diff --git a/crates/raw/README.md b/crates/raw/README.md index 7bcfdcfe52..461a0ed0b6 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -122,6 +122,14 @@ brightened shadows locally (Active D-Lighting), which one global curve cannot fo (preview decode included) takes about 50–70 ms on a 24 MP file. It is an estimate of the camera's look, not a measured calibration: picture styles and hue-dependent rendering are approximated. +Cameras with a profile measured against Photoshop's Camera Raw (`src/cameras.rs`: the Nikon D4) carry +a calibration, so no look is fitted for them. On 6 D4 NEFs against Photoshop 25.4's default Camera +Raw rendering (quarter size, medians over the frames) the measured profile is 0.048 stop from its +luminance with a mean CIE76 ΔE of 1.8; the look fitted to the embedded JPEG would be 0.30 stop and +ΔE 6.4, as it follows the camera's picture style instead. The open-time re-develop +(`photocraft-io`'s `import_raw_tuned`) and the tone curve its previews use (`tone_curve`) take the +same look as the default open, so changing a slider does not change the colour rendering. + ## Tools `cargo run --release -p photocraft-raw --example rawinfo -- [--dump] [--demosaic ahd] [--png DIR] FILE...` diff --git a/crates/raw/src/cameras.rs b/crates/raw/src/cameras.rs index 45eb542cb9..7439433c3e 100644 --- a/crates/raw/src/cameras.rs +++ b/crates/raw/src/cameras.rs @@ -11,10 +11,13 @@ //! (2 px of optical black on the left, 50 + 8 on the right show as a purple stripe otherwise). The //! data is already black-subtracted (the darkest image pixels reach 0). Green saturates at //! 15546–15672 while red and blue reach 15687–16383, so one level of 15520 makes clipped -//! highlights white without shifting the white balance. Matrix and curve were fitted to the -//! camera's embedded JPEGs (Picture Control Standard, Active D-Lighting off; 52 frames, smooth -//! areas, 4 × 4 means): on 15 held-out frames the development is 7.2/255 per channel off the -//! camera's JPEG on average, against 34 without a profile. +//! highlights white without shifting the white balance. Matrix, curve and black point copy +//! Photoshop Camera Raw's default rendering of these files, measured black-box: 29 NEFs opened +//! in Photoshop 25.4 against our own linear development of the same data (8 × 8 means, pixels +//! clipped in neither). The matrix is our first ForwardMatrix (fitted to the camera's embedded +//! JPEGs) times a linear-ProPhoto look matrix whose rows sum to 1; the curve applies per channel +//! after it; the black point follows the frame's darkest tones ([`crate::tone`]). Median +//! luminance difference to Camera Raw: 0.04 stop (0.29 with the camera-JPEG curve). //! //! Bodies whose full profile is not measured yet can still get their measured image area — the //! same alignment method, see [`IMAGE_AREAS`] — so their frames crop like the camera's JPEG. @@ -22,39 +25,63 @@ use crate::color::{Calibration, ColorInfo, IDENTITY, Mat3}; use crate::sensor::{Rect, Sensor}; -const D4_FORWARD: Mat3 = [[0.644598, 0.0703598, 0.249262], [0.342249, 0.518929, 0.138822], [0.0568265, -0.269568, 1.03795]]; +const D4_FORWARD: Mat3 = [[0.639454, 0.260359, 0.0643991], [0.273094, 0.796492, -0.0695779], [0.00965321, -0.131427, 0.94699]]; +/// A third of the 0.1th percentile of the luminance, as Camera Raw renders D4 NEFs by default. +const D4_BLACK_POINT: f32 = 0.33; const D4_COLOR: Mat3 = [[0.895936, -0.218092, -0.185988], [-1.09736, 2.06898, -0.0131889], [-0.26977, 0.393884, 0.687829]]; -const D4_TONE: [[f32; 2]; 31] = [ - [0.005524272, 0.00285028], - [0.006569503, 0.00357083], - [0.0078125, 0.00447912], - [0.009290681, 0.00605813], - [0.01104854, 0.00841445], - [0.01313901, 0.011536], - [0.015625, 0.0165399], - [0.01858136, 0.0230234], - [0.02209709, 0.0319205], - [0.02627801, 0.0434858], - [0.03125, 0.0582138], - [0.03716272, 0.0771963], - [0.04419417, 0.0993894], - [0.05255603, 0.125058], - [0.0625, 0.157207], - [0.07432544, 0.194929], - [0.08838835, 0.236109], - [0.1051121, 0.279155], - [0.125, 0.329365], - [0.1486509, 0.388405], - [0.1767767, 0.457944], - [0.2102241, 0.52456], - [0.25, 0.598051], - [0.2973018, 0.668794], - [0.3535534, 0.749954], - [0.4204482, 0.815443], - [0.5, 0.870167], - [0.5946036, 0.907869], - [std::f32::consts::FRAC_1_SQRT_2, 0.939351], - [0.8408964, 0.970567], +const D4_TONE: [[f32; 2]; 53] = [ + [0.00012207031, 7.44377e-05], + [0.00014516688, 8.44049e-05], + [0.00017263349, 9.57067e-05], + [0.00020529698, 0.000108523], + [0.00024414062, 0.000123055], + [0.00029033376, 0.000123202], + [0.00034526698, 0.000123348], + [0.00041059396, 0.000142387], + [0.00048828125, 0.000164366], + [0.0005806675, 0.00020246], + [0.00069053395, 0.000249382], + [0.0008211879, 0.000310083], + [0.0009765625, 0.000385559], + [0.001161335, 0.000474115], + [0.0013810679, 0.00058301], + [0.0016423758, 0.000717142], + [0.001953125, 0.000882133], + [0.00232267, 0.00109054], + [0.0027621358, 0.00134818], + [0.0032847517, 0.00163655], + [0.00390625, 0.0019866], + [0.00464534, 0.00235945], + [0.0055242716, 0.00280228], + [0.0065695033, 0.00342616], + [0.0078125, 0.00418894], + [0.00929068, 0.00540942], + [0.011048543, 0.0069855], + [0.013139007, 0.00959286], + [0.015625, 0.0131734], + [0.01858136, 0.0181471], + [0.022097087, 0.0249987], + [0.026278013, 0.0336416], + [0.03125, 0.0452726], + [0.03716272, 0.0591889], + [0.044194173, 0.077383], + [0.052556027, 0.0990869], + [0.0625, 0.126878], + [0.07432544, 0.161688], + [0.088388346, 0.206049], + [0.10511205, 0.256563], + [0.125, 0.31946], + [0.14865088, 0.382637], + [0.17677669, 0.458308], + [0.2102241, 0.531942], + [0.25, 0.617405], + [0.29730177, 0.683279], + [0.35355338, 0.756182], + [0.4204482, 0.809073], + [0.5, 0.865664], + [0.59460354, 0.900869], + [std::f32::consts::FRAC_1_SQRT_2, 0.937507], + [0.8408964, 0.956434], [1.0, 1.0], ]; @@ -71,6 +98,8 @@ struct Profile { forward: Mat3, color: Mat3, tone: &'static [[f32; 2]], + /// Camera Raw's adaptive black-point factor ([`Sensor::black_point`]). + black_point: f32, } const PROFILES: &[Profile] = &[Profile { @@ -83,6 +112,7 @@ const PROFILES: &[Profile] = &[Profile { forward: D4_FORWARD, color: D4_COLOR, tone: &D4_TONE, + black_point: D4_BLACK_POINT, }]; /// A measured image area of a body whose remaining profile has not been @@ -134,6 +164,7 @@ pub(crate) fn apply(s: &mut Sensor) { ..ColorInfo::default() }; s.tone_curve = p.tone.to_vec(); + s.black_point = p.black_point; } #[cfg(test)] @@ -164,6 +195,7 @@ mod tests { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings: vec!["NEF: black level not recorded in a documented tag; assumed 0".into()], } } @@ -181,6 +213,7 @@ mod tests { assert_eq!(s.color.calibrations.len(), 1); assert_eq!(s.color.calibrations[0].forward_matrix, Some(D4_FORWARD)); assert_eq!(s.tone_curve.len(), D4_TONE.len()); + assert_eq!(s.black_point, D4_BLACK_POINT); assert!(s.warnings.is_empty(), "the D4's black level is known: {:?}", s.warnings); // The profile gives a colour conversion for the as-shot white balance. assert!(s.color.balanced_to_xyz_d50([0.5, 1.0, 0.7]).is_some()); diff --git a/crates/raw/src/cr2.rs b/crates/raw/src/cr2.rs index 5b3601222e..8e851c5d59 100644 --- a/crates/raw/src/cr2.rs +++ b/crates/raw/src/cr2.rs @@ -293,6 +293,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index 8b69cc2919..ebe8656a39 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -1,12 +1,15 @@ //! Raw development: linearize, subtract black, scale to white, white //! balance, clip highlights, demosaic, camera → XYZ (D50) → linear ProPhoto, -//! exposure, gamma 1.8 encode to 16 bits, orientation. +//! exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black +//! point (`tone.rs`), the profile's tone curve, gamma 1.8 encode to 16 bits, +//! orientation. use crate::color::{self, Mat3}; use crate::demosaic::{Demosaic, PAD, Padded, Pattern, demosaic, demosaic_cfa}; use crate::error::{RawError, Result}; use crate::look::{CameraLook, ToneCurve}; use crate::sensor::Sensor; +use crate::tone::{Haze, Tone, ToneMap, black_level}; use crate::{Limits, RawFormat, par}; /// White balance choice. @@ -30,6 +33,9 @@ pub struct DevelopOptions { pub exposure: f64, /// Apply the file's orientation (rotate / flip the pixels). pub orient: bool, + /// Camera Raw's Shadows, Highlights, Clarity, Texture and Dehaze, applied in linear light + /// before the tone curve. + pub tone: Tone, pub limits: Limits, /// A colour rendering fitted to the camera's own JPEG ([`crate::fit_look`]). When set it /// replaces the file's colour calibration (or the neutral fallback) and adds its tone curve. @@ -43,6 +49,7 @@ impl Default for DevelopOptions { white_balance: WhiteBalance::default(), exposure: 0.0, orient: true, + tone: Tone::default(), limits: Limits::default(), camera_look: None, } @@ -330,7 +337,24 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { } } - // Camera → ProPhoto, exposure, the look's tone curve, gamma 1.8, 16 bits. + // Camera → ProPhoto, exposure, Dehaze, Shadows / Highlights / Clarity / Texture and the black + // point, the look's (or the profile's) tone curve, gamma 1.8, 16 bits. + let y_row = color::rgb_to_xyz(color::ROMM, color::D50_XY)[1].map(|v| v as f32); + let lum = |v: &[f32; 3]| y_row[0] * v[0] + y_row[1] * v[1] + y_row[2] * v[2]; + // Monochrome: the one sample, grey; no colour matrix. + let pro = |p: &[f32]| if mono { [grey_gain * p[0]; 3] } else { [0, 1, 2].map(|k| out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]) }; + let haze = Haze::new(w, h, |i| pro(&rgb[i * 3..i * 3 + 3]), opts.tone.dehaze); + // Linear ProPhoto of pixel `i` after Dehaze. + let linear = |i: usize, p: &[f32]| { + let v = pro(p); + haze.as_ref().map_or(v, |z| z.apply(i % w, i / w, v)) + }; + let mut map = ToneMap::new(w, h, |i| lum(&linear(i, &rgb[i * 3..i * 3 + 3])), opts.tone, s.black_point); + if let (Some(m), Some(_)) = (&mut map, &haze) { + // The black point belongs to the default rendering: Dehaze does not move it (taken after + // Dehaze it fell to almost 0, and Dehaze darkened less than Photoshop's). + m.black = black_level(w, h, |i| lum(&pro(&rgb[i * 3..i * 3 + 3])), s.black_point); + } let mut out = vec![0u16; w * h * 3]; let lut = match look { Some(l) => GammaLut::with_tone(l.tone.as_ref()), @@ -339,13 +363,16 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { par::chunks_mut(&mut out, band * w * 3, |b, chunk| { let start = b * band * w * 3; let src = &rgb[start..start + chunk.len()]; - for (o, p) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0) { - if mono { - *o = [lut.encode(grey_gain * p[0]); 3]; - continue; + for (i, (o, p)) in chunk.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<3>().0).enumerate() { + let mut v = linear(b * band * w + i, p); + if let Some(m) = &map { + let g = m.gain(i % w, b * band + i / w, lum(&v)); + // The black point first, as Camera Raw: lifting before it would flatten the + // deepest shadows' contrast. + v = v.map(|c| m.unblack(c) * g); } for k in 0..3 { - o[k] = lut.encode(out_m[k][0] * p[0] + out_m[k][1] * p[1] + out_m[k][2] * p[2]); + o[k] = lut.encode(v[k]); } } }); @@ -453,7 +480,7 @@ impl GammaLut { /// `curve` at linear `v`: linear interpolation between the points in log₂ of the input, a line /// through 0 below the first point, the last output beyond the last. -fn tone(curve: &[[f32; 2]], v: f32) -> f32 { +pub(crate) fn tone(curve: &[[f32; 2]], v: f32) -> f32 { let (Some(first), Some(last)) = (curve.first(), curve.last()) else { return v }; if v <= first[0] { return if first[0] > 0.0 { v * first[1] / first[0] } else { first[1] }; diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 5ff9fd14d1..e8422b5419 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -271,6 +271,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: if baseline_exposure.is_finite() { baseline_exposure.clamp(-10.0, 10.0) } else { 0.0 }, gain_maps, tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 9a702f2ddb..dc1805d478 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -64,6 +64,7 @@ mod sensor; mod sony; mod tiff; mod tiffep; +mod tone; #[cfg(feature = "testgen")] pub mod testgen; @@ -76,6 +77,7 @@ pub use crate::look::{CameraLook, LookRejected, PROXY_EDGE, Reference, ToneCurve pub use crate::opcodes::GainMap; pub use crate::preview::{Preview, PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_previews}; pub use crate::sensor::{BlackLevels, Cfa, Rect, Sensor}; +pub use crate::tone::{Tone, retone}; use crate::tiff::{Tiff, tag}; diff --git a/crates/raw/src/orf.rs b/crates/raw/src/orf.rs index ba15926725..9271aff93d 100644 --- a/crates/raw/src/orf.rs +++ b/crates/raw/src/orf.rs @@ -150,6 +150,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/raf.rs b/crates/raw/src/raf.rs index dee720b6b4..cb794889d4 100644 --- a/crates/raw/src/raf.rs +++ b/crates/raw/src/raf.rs @@ -302,6 +302,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { baseline_exposure, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/rw2.rs b/crates/raw/src/rw2.rs index fe1e0b2ab9..231cd0971b 100644 --- a/crates/raw/src/rw2.rs +++ b/crates/raw/src/rw2.rs @@ -170,6 +170,7 @@ pub(crate) fn decode(t: &Tiff, limits: &Limits) -> Result { baseline_exposure: 0.0, gain_maps: Vec::new(), tone_curve: Vec::new(), + black_point: 0.0, warnings, }) } diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index a775586dec..e8c65ebf59 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -157,6 +157,10 @@ pub struct Sensor { /// output]` pairs in linear ProPhoto, applied per channel after the colour conversion and /// exposure. Below the first point the curve is a line through 0; empty: no curve. pub tone_curve: Vec<[f32; 2]>, + /// Camera Raw's adaptive black point, for profiles that copy its default rendering: this + /// factor times the 0.1th percentile of the image's linear luminance is subtracted before the + /// tone curve (see `tone.rs`). 0: no black point. + pub black_point: f32, pub warnings: Vec, } diff --git a/crates/raw/src/tiffep.rs b/crates/raw/src/tiffep.rs index b940d6bc3a..4a75491f5f 100644 --- a/crates/raw/src/tiffep.rs +++ b/crates/raw/src/tiffep.rs @@ -223,6 +223,7 @@ pub(crate) fn decode(t: &Tiff, format: RawFormat, limits: &Limits) -> Result bool { + self.gain_neutral() && self.dehaze == 0.0 + } + + /// No luminance gain: Shadows, Highlights, Clarity and Texture at 0. + fn gain_neutral(&self) -> bool { + self.work_neutral() && self.texture == 0.0 + } + + /// Nothing to compute on the work copy: Shadows, Highlights and Clarity at 0. + fn work_neutral(&self) -> bool { + self.shadows == 0.0 && self.highlights == 0.0 && self.clarity == 0.0 + } +} + +/// Long side of the copy the base is computed on, and the guided filter's radius and ε there. +const WORK_SIDE: usize = 1232; +const BASE_RADIUS: f32 = 32.0; +const BASE_EPS: f32 = 1.0; +/// ε (stops²) of the guided upsampling that carries the change to full size. +const UPSAMPLE_EPS: f32 = 1.0; +/// The curves' first knot (stops from the anchor) and knot spacing. +const LO: f32 = -12.0; +const STEP: f32 = 0.25; + +// log₂ gain at +100 / −100 against B − anchor, knots LO, LO + STEP, … (measured; flat or +// extended linearly beyond the measured range, never steeper than 0.8 stop per stop so tones +// keep their order). +const SHADOWS_UP: [f32; 73] = [ + 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, 2.142, + 2.142, 2.142, 2.142, 2.142, 2.138, 2.117, 2.074, 2.010, 1.918, 1.797, 1.668, 1.546, 1.429, 1.315, 1.204, 1.093, 0.979, 0.860, 0.734, 0.604, 0.482, 0.378, + 0.288, 0.211, 0.157, 0.127, 0.104, 0.078, 0.056, 0.046, 0.044, 0.044, 0.044, 0.044, 0.044, 0.041, 0.030, 0.014, 0.003, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, +]; +const SHADOWS_DOWN: [f32; 73] = [ + -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, -2.365, + -2.365, -2.365, -2.365, -2.365, -2.346, -2.270, -2.155, -2.078, -2.054, -2.031, -1.973, -1.872, -1.739, -1.604, -1.483, -1.372, -1.266, -1.161, -1.053, + -0.940, -0.821, -0.696, -0.570, -0.455, -0.356, -0.270, -0.197, -0.146, -0.117, -0.098, -0.076, -0.057, -0.048, -0.046, -0.046, -0.046, -0.046, -0.046, + -0.043, -0.032, -0.014, -0.003, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, +]; +const HIGHLIGHTS_UP: [f32; 73] = [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.001, 0.002, 0.003, 0.004, 0.008, 0.017, 0.030, 0.043, 0.055, 0.070, 0.085, 0.097, 0.106, + 0.114, 0.120, 0.127, 0.142, 0.167, 0.199, 0.239, 0.301, 0.381, 0.470, 0.569, 0.677, 0.796, 0.925, 1.056, 1.176, 1.268, 1.319, 1.339, 1.347, 1.351, 1.357, + 1.364, 1.372, 1.380, 1.388, 1.396, 1.404, 1.412, +]; +#[allow(clippy::approx_constant)] // measured values, one of them happens to be close to e +const HIGHLIGHTS_DOWN: [f32; 73] = [ + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, + 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, 0.000, -0.004, -0.020, -0.046, -0.068, -0.086, -0.104, -0.116, -0.118, -0.118, -0.118, -0.118, -0.118, + -0.119, -0.120, -0.123, -0.127, -0.131, -0.144, -0.169, -0.204, -0.245, -0.301, -0.375, -0.452, -0.537, -0.639, -0.753, -0.872, -0.997, -1.132, -1.294, + -1.475, -1.637, -1.751, -1.836, -1.936, -2.059, -2.191, -2.323, -2.454, -2.586, -2.718, -2.850, +]; + +/// Clarity: the work-copy radii of the bases between its bands, and their guided-filter ε. +const CLARITY_RADII: [f32; 4] = [2.0, 8.0, 32.0, 128.0]; +const CLARITY_EPS: f32 = 1.0; +/// Texture: long side of its copy, the Gaussians' σ there, and the guided upsampling's ε (small: +/// the change must follow fine detail into the full-size pixels). +const TEXTURE_SIDE: usize = 2464; +const TEXTURE_SIGMAS: [f32; 6] = [0.5, 1.0, 2.0, 4.0, 8.0, 16.0]; +const TEXTURE_UPSAMPLE_EPS: f32 = 1e-4; +/// Dehaze: dark-channel window radius and guided smoothing radius (work copy), its ε, the +/// smallest transmission, and the haze weight at +100 and −100. +const HAZE_RADIUS: f32 = 8.0; +const HAZE_SMOOTH: f32 = 64.0; +const HAZE_EPS: f32 = 1e-3; +const HAZE_T0: f32 = 0.1; +const HAZE_UP: f32 = 0.8; +const HAZE_DOWN: f32 = 0.22; +/// Linear ProPhoto → linear Display P3 (both D50): the dark channel was measured in Photoshop's +/// Display P3 output. +const PRO_TO_P3: [[f32; 3]; 3] = [[1.632638, -0.379816, -0.252821], [-0.153846, 1.166859, -0.013013], [0.010511, -0.062886, 1.052375]]; + +/// A change of log₂ luminance computed on a reduced copy, carried to full size along the image's +/// own edges (guided upsampling): a local linear model `change = a · log₂ Y + b` per reduced pixel. +struct Upsampled { + sw: usize, + sh: usize, + a: Vec, + b: Vec, + /// log₂ luminance floor (the reduced copy's 0.1th percentile). + floor: f32, +} + +impl Upsampled { + /// The change at full-size pixel (`x`, `y`) of a `w × h` image, of linear luminance `lum`. + fn change(&self, w: usize, h: usize, x: usize, y: usize, lum: f32) -> f32 { + let l = if lum > 0.0 { lum.log2().max(self.floor) } else { self.floor }; + bilinear(&self.a, self.sw, self.sh, w, h, x, y) * l + bilinear(&self.b, self.sw, self.sh, w, h, x, y) + } +} + +/// `v` (`sw × sh`) at full-size pixel (`x`, `y`) of a `w × h` image, bilinearly. +fn bilinear(v: &[f32], sw: usize, sh: usize, w: usize, h: usize, x: usize, y: usize) -> f32 { + let sx = ((x as f32 + 0.5) * sw as f32 / w as f32 - 0.5).clamp(0.0, (sw - 1) as f32); + let sy = ((y as f32 + 0.5) * sh as f32 / h as f32 - 0.5).clamp(0.0, (sh - 1) as f32); + let (x0, y0) = (sx as usize, sy as usize); + let (x1, y1) = ((x0 + 1).min(sw - 1), (y0 + 1).min(sh - 1)); + let (fx, fy) = (sx - x0 as f32, sy - y0 as f32); + let top = v[y0 * sw + x0] * (1.0 - fx) + v[y0 * sw + x1] * fx; + let bottom = v[y1 * sw + x0] * (1.0 - fx) + v[y1 * sw + x1] * fx; + top * (1.0 - fy) + bottom * fy +} + +/// Box means of `f` over `k × k` cells, `k` the smallest factor that brings the long side to +/// `side` or less: (`sw`, `sh`, the means). +fn reduce(w: usize, h: usize, side: usize, f: impl Fn(usize) -> [f32; N] + Sync) -> (usize, usize, Vec<[f32; N]>) { + let k = w.max(h).div_ceil(side).max(1); + let (sw, sh) = (w.div_ceil(k), h.div_ceil(k)); + let rows = par::map(sh, |sy| { + let (y0, y1) = (sy * k, ((sy + 1) * k).min(h)); + (0..sw) + .map(|sx| { + let (x0, x1) = (sx * k, ((sx + 1) * k).min(w)); + let mut s = [0.0f64; N]; + for y in y0..y1 { + for x in x0..x1 { + let v = f(y * w + x); + for c in 0..N { + s[c] += f64::from(v[c].max(0.0)); + } + } + } + let n = ((x1 - x0) * (y1 - y0)) as f64; + s.map(|v| (v / n) as f32) + }) + .collect::>() + }); + (sw, sh, rows.concat()) +} + +/// The luminance change of one image (Shadows, Highlights, Clarity on the work copy; Texture on +/// a half-size copy) and the black point. +pub(crate) struct ToneMap { + w: usize, + h: usize, + work: Option, + texture: Option, + /// Linear black point to subtract (0: none). + pub(crate) black: f32, +} + +impl ToneMap { + /// `lum(i)`: linear luminance of full-size pixel `i` (row-major, `w × h`). `black_point`: the + /// camera profile's black-point factor (see [`crate::Sensor::black_point`]). `None` when + /// nothing changes. Dehaze is not part of it (see [`Haze`]). + pub(crate) fn new(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, tone: Tone, black_point: f32) -> Option { + if w == 0 || h == 0 || (tone.gain_neutral() && black_point <= 0.0) { + return None; + } + let (sw, sh, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); + let black = if black_point > 0.0 { black_point * p01 } else { 0.0 }; + let work = (!tone.work_neutral()).then(|| { + let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); + let n = l.len() as f64; + let geo = (l.iter().map(|v| f64::from(*v)).sum::() / n) as f32; + let scale = sw.max(sh) as f32 / WORK_SIDE as f32; + let mut change = vec![0.0f32; l.len()]; + if tone.shadows != 0.0 || tone.highlights != 0.0 { + let pm = ((l.iter().map(|v| f64::from(v.exp2()).powf(1.5)).sum::() / n).log2() / 1.5) as f32; + let r = ((BASE_RADIUS * scale).round() as usize).max(1); + let base = guided(&l, &l, sw, sh, r, BASE_EPS); + for (c, b) in change.iter_mut().zip(&base) { + *c += shadows(tone.shadows, b - pm) + highlights(tone.highlights, b - geo); + } + } + if tone.clarity != 0.0 { + clarity(&mut change, &l, sw, sh, geo, scale, tone.clarity); + } + // Fit change ≈ a · l + b locally, averaged; evaluated at full size. The large ε lets the + // change follow strong edges only: inside texture it stays smooth, so fine detail keeps + // its contrast as in Camera Raw (with ε 0.01 shadow detail lost a fifth of it; with ε 4 the change bled ~16 px over 3-stop edges). + let (a, b) = linear_fit(&l, &change, sw, sh, 2, UPSAMPLE_EPS); + Upsampled { sw, sh, a, b, floor: p01.log2() } + }); + let texture = (tone.texture != 0.0).then(|| texture(w, h, &lum, tone.texture)); + Some(ToneMap { w, h, work, texture, black }) + } + + /// The gain at full-size pixel (`x`, `y`) of linear luminance `lum`. + pub(crate) fn gain(&self, x: usize, y: usize, lum: f32) -> f32 { + if self.work.is_none() && self.texture.is_none() { + return 1.0; + } + let c = [&self.work, &self.texture].iter().filter_map(|u| u.as_ref()).map(|u| u.change(self.w, self.h, x, y, lum)).sum::(); + c.clamp(-6.0, 6.0).exp2() + } + + /// Black-point subtraction of one linear value. + #[inline] + pub(crate) fn unblack(&self, v: f32) -> f32 { + if self.black > 0.0 { ((v - self.black) / (1.0 - self.black)).max(0.0) } else { v } + } +} + +/// The black point of an image of luminance `lum` for the profile's factor (0: none): `factor` +/// times the 0.1th percentile of the work copy's linear luminance. +pub(crate) fn black_level(w: usize, h: usize, lum: impl Fn(usize) -> f32 + Sync, factor: f32) -> f32 { + if w == 0 || h == 0 || factor <= 0.0 { + return 0.0; + } + let (_, _, y) = reduce(w, h, WORK_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + factor * quantile(&y, 0.001).max(2f32.powi(-20)) +} + +/// The slider's table for its direction and its weight 0…1. +fn direction(s: f32, up: &'static T, down: &'static T) -> (f32, &'static T) { + let s = s.clamp(-100.0, 100.0) / 100.0; + if s >= 0.0 { (s, up) } else { (-s, down) } +} + +/// A table on evenly spaced knots `lo`, `lo + step`, …, read linearly, flat beyond its ends. +fn even(c: &[f32; N], lo: f32, step: f32, x: f32) -> f32 { + let t = ((x - lo) / step).clamp(0.0, (N - 1) as f32); + let i = (t as usize).min(N - 2); + c[i] + (c[i + 1] - c[i]) * (t - i as f32) +} + +/// Adds Clarity `s` to `change`: bands of `l` between guided bases, each scaled by a gain that +/// follows its base's tone (relative to `geo`), and a tone term of the coarsest base. +fn clarity(change: &mut [f32], l: &[f32], sw: usize, sh: usize, geo: f32, scale: f32, s: f32) { + let (k, t) = direction(s, &tables::CLARITY_UP, &tables::CLARITY_DOWN); + let mut prev = l.to_vec(); + for (band, r) in CLARITY_RADII.iter().enumerate() { + let base = guided(l, l, sw, sh, ((r * scale).round() as usize).max(1), CLARITY_EPS); + for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) { + *c += k * (p - b) * even(&t.bands[band], tables::CLARITY_TONE_LO, tables::CLARITY_TONE_STEP, b - geo); + } + prev = base; + } + for (c, b) in change.iter_mut().zip(&prev) { + *c += k * (even(&t.base, tables::CLARITY_BASE_LO, tables::CLARITY_BASE_STEP, b - geo) + t.offset); + } +} + +/// Texture `s` on a half-size copy: each Gaussian band of log₂ luminance through its measured +/// response (zero for flat areas), carried to full size. +fn texture(w: usize, h: usize, lum: &(impl Fn(usize) -> f32 + Sync), s: f32) -> Upsampled { + let (sw, sh, y) = reduce(w, h, TEXTURE_SIDE, |i| [lum(i)]); + let y: Vec = y.into_iter().map(|[v]| v).collect(); + let p01 = quantile(&y, 0.001).max(2f32.powi(-20)); + let l: Vec = y.iter().map(|v| v.max(p01).log2()).collect(); + let scale = sw.max(sh) as f32 / TEXTURE_SIDE as f32; + let (k, t) = direction(s, &tables::TEXTURE_UP, &tables::TEXTURE_DOWN); + let mut change = vec![0.0f32; l.len()]; + let mut prev = l.clone(); + for (band, sigma) in TEXTURE_SIGMAS.iter().enumerate() { + let base = gauss(&l, sw, sh, sigma * scale); + let zero = knots(&t[band], 0.0); + for ((c, p), b) in change.iter_mut().zip(&prev).zip(&base) { + *c += k * (knots(&t[band], p - b) - zero); + } + prev = base; + } + let (a, b) = linear_fit(&l, &change, sw, sh, 2, TEXTURE_UPSAMPLE_EPS); + Upsampled { sw, sh, a, b, floor: p01.log2() } +} + +/// Texture's band response on the knots [`tables::TEXTURE_KNOTS`], read linearly, flat beyond. +fn knots(c: &[f32; 11], d: f32) -> f32 { + let k = &tables::TEXTURE_KNOTS; + let d = d.clamp(k[0], k[10]); + let i = k.partition_point(|v| *v <= d).clamp(1, 10); + let f = (d - k[i - 1]) / (k[i] - k[i - 1]); + c[i - 1] + (c[i] - c[i - 1]) * f +} + +/// Gaussian blur of `v` (`w × h`), σ `sigma`, radius ⌈3σ⌉, mirrored edges. +fn gauss(v: &[f32], w: usize, h: usize, sigma: f32) -> Vec { + if sigma <= 0.05 { + return v.to_vec(); + } + let r = (3.0 * sigma).ceil() as isize; + let mut k: Vec = (-r..=r).map(|x| (-(x * x) as f32 / (2.0 * sigma * sigma)).exp()).collect(); + let sum: f32 = k.iter().sum(); + k.iter_mut().for_each(|v| *v /= sum); + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + (0..w as isize).map(|x| k.iter().enumerate().map(|(j, kv)| kv * src[mirror(x + j as isize - r, w)]).sum::()).collect::>() + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + (0..h as isize).map(|y| k.iter().enumerate().map(|(j, kv)| kv * tmp[mirror(y + j as isize - r, h) * w + x]).sum::()).collect::>() + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Minimum over a `(2r + 1)²` window, edges clamped. +fn min_filter(v: &[f32], w: usize, h: usize, r: usize) -> Vec { + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + (0..w).map(|x| src[x.saturating_sub(r)..=(x + r).min(w - 1)].iter().fold(f32::MAX, |a, b| a.min(*b))).collect::>() + }); + let tmp = rows.concat(); + let cols = + par::map(w, |x| (0..h).map(|y| (y.saturating_sub(r)..=(y + r).min(h - 1)).map(|yy| tmp[yy * w + x]).fold(f32::MAX, f32::min)).collect::>()); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Camera Raw's Dehaze on linear ProPhoto RGB (see the module docs). +pub(crate) struct Haze { + w: usize, + h: usize, + sw: usize, + sh: usize, + /// Transmission per work-copy pixel (Dehaze > 0); empty for the veil of Dehaze < 0. + t: Vec, + /// Airlight, linear ProPhoto. + air: [f32; 3], + /// Weight of the uniform veil (Dehaze < 0). + veil: f32, +} + +impl Haze { + /// `px(i)`: linear ProPhoto RGB of full-size pixel `i`. `None` at 0. + pub(crate) fn new(w: usize, h: usize, px: impl Fn(usize) -> [f32; 3] + Sync, amount: f32) -> Option { + let amount = amount.clamp(-100.0, 100.0) / 100.0; + if w == 0 || h == 0 || amount == 0.0 { + return None; + } + let (sw, sh, small) = reduce(w, h, WORK_SIDE, px); + let p3 = |v: &[f32; 3]| PRO_TO_P3.map(|row| (row[0] * v[0] + row[1] * v[1] + row[2] * v[2]).max(0.0)); + let small_p3: Vec<[f32; 3]> = small.iter().map(p3).collect(); + // Airlight: mean colour of the 0.1 % of pixels with the brightest dark channel. + let dc: Vec = small_p3.iter().map(|c| c[0].min(c[1]).min(c[2])).collect(); + let top = (dc.len() / 1000).max(1); + let mut idx: Vec = (0..dc.len()).collect(); + idx.select_nth_unstable_by(top - 1, |a, b| dc[*b].total_cmp(&dc[*a])); + let mut air = [0.0f32; 3]; + for i in &idx[..top] { + for c in 0..3 { + air[c] += small[*i][c] / top as f32; + } + } + let air = air.map(|v| v.max(1e-6)); + if amount < 0.0 { + return Some(Haze { w, h, sw, sh, t: Vec::new(), air, veil: HAZE_DOWN * -amount }); + } + let air_p3 = p3(&air).map(|v| v.max(1e-6)); + let scale = sw.max(sh) as f32 / WORK_SIDE as f32; + let dark0: Vec = small_p3.iter().map(|c| (0..3).map(|k| c[k] / air_p3[k]).fold(f32::MAX, f32::min)).collect(); + let dark = min_filter(&dark0, sw, sh, ((HAZE_RADIUS * scale).round() as usize).max(1)); + let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); + let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let air_y = lum(&air).max(1e-6); + let guide: Vec = small.iter().map(|c| lum(c) / air_y).collect(); + let smooth = guided(&guide, &dark, sw, sh, ((HAZE_SMOOTH * scale).round() as usize).max(1), HAZE_EPS); + let wgt = HAZE_UP * amount; + let t = smooth.iter().map(|d| (1.0 - wgt * d).max(HAZE_T0)).collect(); + Some(Haze { w, h, sw, sh, t, air, veil: 0.0 }) + } + + /// Dehaze at full-size pixel (`x`, `y`) of linear ProPhoto `v`. + pub(crate) fn apply(&self, x: usize, y: usize, v: [f32; 3]) -> [f32; 3] { + if self.t.is_empty() { + return [0, 1, 2].map(|c| v[c] * (1.0 - self.veil) + self.air[c] * self.veil); + } + let t = bilinear(&self.t, self.sw, self.sh, self.w, self.h, x, y).max(HAZE_T0); + [0, 1, 2].map(|c| ((v[c] - self.air[c]) / t + self.air[c]).max(0.0)) + } +} + +fn curve(c: &[f32; 73], d: f32) -> f32 { + let t = ((d - LO) / STEP).clamp(0.0, (c.len() - 1) as f32); + let i = (t as usize).min(c.len() - 2); + let f = t - i as f32; + c[i] + (c[i + 1] - c[i]) * f +} + +/// log₂ gain of Shadows `s` (−100…100) at `d` stops from its anchor. +fn shadows(s: f32, d: f32) -> f32 { + let s = s.clamp(-100.0, 100.0) / 100.0; + if s > 0.0 { + s * curve(&SHADOWS_UP, d) + } else if s < 0.0 { + -s * curve(&SHADOWS_DOWN, d) + } else { + 0.0 + } +} + +/// log₂ gain of Highlights `h` (−100…100) at `d` stops from its anchor. +fn highlights(h: f32, d: f32) -> f32 { + let h = h.clamp(-100.0, 100.0) / 100.0; + if h > 0.0 { + h * curve(&HIGHLIGHTS_UP, d) + } else if h < 0.0 { + -h * curve(&HIGHLIGHTS_DOWN, d) + } else { + 0.0 + } +} + +/// The `q` quantile (0…1) of `v`. +fn quantile(v: &[f32], q: f32) -> f32 { + let mut s = v.to_vec(); + let k = ((s.len() - 1) as f32 * q).round() as usize; + let (_, m, _) = s.select_nth_unstable_by(k, |a, b| a.total_cmp(b)); + *m +} + +/// Index `i` mirrored into `0..n` (half-sample symmetric: … 1 0 | 0 1 … n−1 | n−1 n−2 …). +fn mirror(i: isize, n: usize) -> usize { + let n = n as isize; + let p = i.rem_euclid(2 * n); + (if p < n { p } else { 2 * n - 1 - p }) as usize +} + +/// Box mean of radius `r` with mirrored edges. +fn box_mean(v: &[f32], w: usize, h: usize, r: usize) -> Vec { + let k = (2 * r + 1) as f64; + let ri = r as isize; + let rows = par::map(h, |y| { + let src = &v[y * w..(y + 1) * w]; + let mut acc: f64 = (-ri..=ri).map(|i| f64::from(src[mirror(i, w)])).sum(); + let mut out = vec![0.0f32; w]; + for (x, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let x = x as isize; + acc += f64::from(src[mirror(x + ri + 1, w)]) - f64::from(src[mirror(x - ri, w)]); + } + out + }); + let tmp = rows.concat(); + let cols = par::map(w, |x| { + let at = |y: isize| f64::from(tmp[mirror(y, h) * w + x]); + let mut acc: f64 = (-ri..=ri).map(at).sum(); + let mut out = vec![0.0f32; h]; + for (y, o) in out.iter_mut().enumerate() { + *o = (acc / k) as f32; + let y = y as isize; + acc += at(y + ri + 1) - at(y - ri); + } + out + }); + let mut out = vec![0.0f32; w * h]; + for (x, col) in cols.iter().enumerate() { + for (y, v) in col.iter().enumerate() { + out[y * w + x] = *v; + } + } + out +} + +/// Local linear fit `p ≈ a · i + b` over boxes of radius `r` (regularized by `eps`), with `a` +/// and `b` box-averaged (K. He, J. Sun, X. Tang, *Guided Image Filtering*, 2010). +fn linear_fit(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> (Vec, Vec) { + let ip: Vec = i.iter().zip(p).map(|(a, b)| a * b).collect(); + let ii: Vec = i.iter().map(|a| a * a).collect(); + let (mi, mp, mip, mii) = (box_mean(i, w, h, r), box_mean(p, w, h, r), box_mean(&ip, w, h, r), box_mean(&ii, w, h, r)); + let a: Vec = (0..w * h).map(|k| (mip[k] - mi[k] * mp[k]) / ((mii[k] - mi[k] * mi[k]).max(0.0) + eps)).collect(); + let b: Vec = (0..w * h).map(|k| mp[k] - a[k] * mi[k]).collect(); + (box_mean(&a, w, h, r), box_mean(&b, w, h, r)) +} + +/// Guided filter of `p` with guide `i`. +fn guided(i: &[f32], p: &[f32], w: usize, h: usize, r: usize, eps: f32) -> Vec { + let (a, b) = linear_fit(i, p, w, h, r, eps); + a.iter().zip(&b).zip(i).map(|((a, b), i)| a * i + b).collect() +} + +/// The raw-stage sliders ([`Tone`]) on an image [`develop`](crate::develop) already made (gamma-1.8 ProPhoto +/// RGBA, 0…1) with the profile tone `curve`, for a preview of the open-time setting: the curve is +/// undone, the change applied in linear light and the curve redone. The developed pixels already +/// carry the black point, so the change's map is computed from the luminance after it rather +/// than before; that only differs in the deepest shadows. +pub fn retone(px: &mut [[f32; 4]], w: usize, h: usize, curve: &[[f32; 2]], tone: Tone) { + if tone.is_neutral() || w == 0 || h == 0 || px.len() < w * h { + return; + } + let forward = |v: f32| crate::develop::tone(curve, v.clamp(0.0, 1.0)).clamp(0.0, 1.0).powf(1.0 / 1.8); + // The inverse from a table over s = √v (the encoding is steep near 0). + const N: usize = 4096; + let table: Vec = (0..=N).map(|i| forward((i as f32 / N as f32).powi(2))).collect(); + let inverse = |e: f32| { + let i = table.partition_point(|t| *t < e).clamp(1, N); + let (a, b) = (table[i - 1], table[i]); + let f = if b > a { ((e - a) / (b - a)).clamp(0.0, 1.0) } else { 0.0 }; + ((i - 1) as f32 + f) / N as f32 + }; + let mut lin: Vec<[f32; 3]> = px.iter().map(|p| [0, 1, 2].map(|k| inverse(p[k]).powi(2))).collect(); + if let Some(haze) = Haze::new(w, h, |i| lin[i], tone.dehaze) { + for (i, l) in lin.iter_mut().enumerate() { + *l = haze.apply(i % w, i / w, *l); + } + } + let y = crate::color::rgb_to_xyz(crate::color::ROMM, crate::color::D50_XY)[1].map(|v| v as f32); + let lum = |c: &[f32; 3]| y[0] * c[0] + y[1] * c[1] + y[2] * c[2]; + let map = ToneMap::new(w, h, |i| lum(&lin[i]), tone, 0.0); + for (i, (p, l)) in px.iter_mut().zip(&lin).enumerate() { + let g = map.as_ref().map_or(1.0, |m| m.gain(i % w, i / w, lum(l))); + for k in 0..3 { + p[k] = forward(l[k] * g); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHADOWS: Tone = Tone { shadows: 100.0, ..Tone::NEUTRAL }; + const HIGHLIGHTS: Tone = Tone { highlights: -100.0, ..Tone::NEUTRAL }; + + /// Three vertical bands of linear luminance 2⁻⁷ | 2⁻⁴ | 2⁻¹, times `scale`. + fn bands(w: usize, h: usize, scale: f32) -> Vec { + (0..w * h).map(|i| scale * [2f32.powi(-7), 2f32.powi(-4), 0.5][(i % w) * 3 / w]).collect() + } + + fn gains(y: &[f32], w: usize, h: usize, tone: Tone) -> Vec { + let Some(m) = ToneMap::new(w, h, |i| y[i], tone, 0.0) else { return vec![1.0; w * h] }; + (0..w * h).map(|i| m.gain(i % w, i / w, y[i])).collect() + } + + #[test] + fn shadows_lift_the_dark_area_and_keep_the_bright_one() { + let (w, h) = (300, 120); + let g = gains(&bands(w, h, 1.0), w, h, SHADOWS); + let (dark, mid, bright) = (g[60 * w + 50], g[60 * w + 150], g[60 * w + 250]); + // 5 stops below the anchor: about +2 stops, as Camera Raw lifts such areas; 1 stop above it: almost nothing. + assert!(dark.log2() > 1.8 && dark.log2() < 2.3, "{dark}"); + assert!(mid > 1.0 && mid < dark, "{mid}"); + assert!(bright.log2() < 0.06, "{bright}"); + } + + #[test] + fn highlights_lower_the_bright_area_and_keep_the_dark_one() { + let (w, h) = (300, 120); + let g = gains(&bands(w, h, 1.0), w, h, HIGHLIGHTS); + let (dark, bright) = (g[60 * w + 50], g[60 * w + 250]); + // 3 stops above the anchor about −1 stop; 3 below a slight darkening (Camera Raw lowers a + // high-contrast frame as a whole a little too). + assert!(dark.log2() < 0.0 && dark.log2() > -0.15, "{dark}"); + assert!(bright.log2() < -0.8 && bright.log2() > -1.3, "{bright}"); + } + + #[test] + fn exposure_changes_nothing() { + let (w, h) = (300, 120); + for tone in [SHADOWS, HIGHLIGHTS, Tone { shadows: -60.0, highlights: 40.0, clarity: 50.0, texture: -40.0, ..Tone::NEUTRAL }] { + let (a, b) = (gains(&bands(w, h, 1.0), w, h, tone), gains(&bands(w, h, 0.25), w, h, tone)); + assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 1e-3 * a), "{tone:?}"); + } + } + + #[test] + fn areas_move_as_a_whole_without_a_halo() { + // Next to an edge the change stays between the two areas' changes (no over- or undershoot) + // and close to the change inside the area. + let (w, h) = (600, 120); + let g: Vec = gains(&bands(w, h, 1.0), w, h, SHADOWS).iter().map(|g| g.log2()).collect(); + let row = &g[60 * w..61 * w]; + let (dark, mid) = (row[w / 6], row[w / 2]); + for (x, v) in row.iter().enumerate().take(w / 2).skip(w / 6) { + assert!(*v <= dark + 1e-3 && *v >= mid - 1e-3, "{x}: {v} outside {mid}…{dark}"); + } + // Within 0.3 stop: near strong edges the change matches Photoshop as well as elsewhere. + assert!(dark - row[w / 3 - 2] < 0.3 && row[w / 3 + 2] - mid < 0.3, "{} {} | {} {}", dark, row[w / 3 - 2], row[w / 3 + 2], mid); + } + + #[test] + fn the_result_does_not_depend_on_the_image_size() { + let probe = |w: usize, h: usize| { + let g = gains(&bands(w, h, 1.0), w, h, SHADOWS); + [w / 6, w / 2, 5 * w / 6].map(|x| g[(h / 2) * w + x].log2()) + }; + let (a, b) = (probe(4928, 1640), probe(1232, 410)); + assert!(a.iter().zip(&b).all(|(a, b)| (a - b).abs() < 0.02), "{a:?} vs {b:?}"); + } + + #[test] + fn the_black_point_follows_the_darkest_tones() { + let (w, h) = (300, 120); + let y = bands(w, h, 1.0); + let m = ToneMap::new(w, h, |i| y[i], Tone::default(), 0.33).unwrap(); + assert!((m.black - 0.33 * 2f32.powi(-7)).abs() < 1e-7, "{}", m.black); + assert!(m.unblack(m.black * 0.5) == 0.0 && (m.unblack(1.0) - 1.0).abs() < 1e-6); + assert!(ToneMap::new(w, h, |i| y[i], Tone::default(), 0.0).is_none()); + } + + /// log₂ luminance of a `w × h` image: `base` plus a vertical sine of `amp` stops and period `period` px. + fn stripes(w: usize, h: usize, base: f32, amp: f32, period: f32) -> Vec { + (0..w * h).map(|i| (base + amp * ((i % w) as f32 * std::f32::consts::TAU / period).sin()).exp2()).collect() + } + + /// Peak-to-peak log₂ luminance over the middle row's central half, after the gains. + fn swing(y: &[f32], g: &[f32], w: usize, h: usize) -> f32 { + let row = (h / 2) * w; + let v: Vec = (w / 4..3 * w / 4).map(|x| (y[row + x] * g[row + x]).log2()).collect(); + v.iter().cloned().fold(f32::MIN, f32::max) - v.iter().cloned().fold(f32::MAX, f32::min) + } + + #[test] + fn clarity_raises_midtone_detail_and_minus_flattens_it() { + let (w, h) = (1232, 200); + let y = stripes(w, h, -2.5, 0.25, 40.0); + let before = swing(&y, &vec![1.0; w * h], w, h); + let up = swing(&y, &gains(&y, w, h, Tone { clarity: 100.0, ..Tone::NEUTRAL }), w, h); + let down = swing(&y, &gains(&y, w, h, Tone { clarity: -100.0, ..Tone::NEUTRAL }), w, h); + // Measured: +100 roughly doubles midtone detail, −100 flattens it by more than a third. + assert!(up > 1.5 * before && up < 3.5 * before, "{before} → {up}"); + assert!(down < 0.8 * before && down > 0.1 * before, "{before} → {down}"); + } + + #[test] + fn texture_raises_fine_detail_more_than_coarse_and_leaves_flat_areas() { + let (w, h) = (2464, 160); + let fine = stripes(w, h, -2.5, 0.1, 6.0); + let coarse = stripes(w, h, -2.5, 0.1, 600.0); + let gain = |y: &[f32]| swing(y, &gains(y, w, h, Tone { texture: 100.0, ..Tone::NEUTRAL }), w, h) / swing(y, &vec![1.0; w * h], w, h); + let (f, c) = (gain(&fine), gain(&coarse)); + assert!(f > 1.15, "fine detail ×{f}"); + assert!(c < 1.05 && f > c + 0.1, "coarse ×{c}, fine ×{f}"); + let flat = vec![0.2f32; 400 * 300]; + assert!(gains(&flat, 400, 300, Tone { texture: 100.0, ..Tone::NEUTRAL }).iter().all(|g| (g - 1.0).abs() < 1e-4)); + } + + #[test] + fn dehaze_lowers_the_haze_and_minus_adds_a_veil() { + // A pale, hazy sky (top) over a darker, colourful ground. + let (w, h) = (400, 300); + let px: Vec<[f32; 3]> = (0..w * h).map(|i| if i / w < h / 2 { [0.55, 0.6, 0.7] } else { [0.12, 0.08, 0.03] }).collect(); + let up = Haze::new(w, h, |i| px[i], 100.0).unwrap(); + let (sky, ground) = (up.apply(200, 40, px[40 * w + 200]), up.apply(200, 260, px[260 * w + 200])); + // The airlight itself stays; what is darker than it moves away from it. + assert!(sky.iter().zip(&px[40 * w + 200]).all(|(a, b)| (a - b).abs() < 0.02), "{sky:?}"); + assert!(ground[2] < 0.03 && ground[0] <= 0.12, "{ground:?}"); + let down = Haze::new(w, h, |i| px[i], -100.0).unwrap(); + let veiled = down.apply(200, 260, px[260 * w + 200]); + assert!((0..3).all(|c| veiled[c] > px[260 * w + 200][c]), "{veiled:?}"); + assert!(Haze::new(w, h, |i| px[i], 0.0).is_none()); + } + + #[test] + fn retone_changes_nothing_at_zero_and_matches_the_develop_stage() { + let (w, h) = (300, 120); + let y = bands(w, h, 1.0); + let curve = [[0.01, 0.02], [0.1, 0.25], [1.0, 1.0]]; + let enc = |v: f32| crate::develop::tone(&curve, v).powf(1.0 / 1.8); + let px: Vec<[f32; 4]> = y.iter().map(|v| [enc(*v), enc(*v), enc(*v), 1.0]).collect(); + let mut same = px.clone(); + retone(&mut same, w, h, &curve, Tone::default()); + assert_eq!(same, px); + let mut out = px.clone(); + retone(&mut out, w, h, &curve, SHADOWS); + let g = gains(&y, w, h, SHADOWS); + for i in [60 * w + 50, 60 * w + 150, 60 * w + 250] { + assert!((out[i][0] - enc(y[i] * g[i])).abs() < 0.003, "{i}: {} vs {}", out[i][0], enc(y[i] * g[i])); + } + } +} diff --git a/crates/raw/src/tone/tables.rs b/crates/raw/src/tone/tables.rs new file mode 100644 index 0000000000..eb4892360b --- /dev/null +++ b/crates/raw/src/tone/tables.rs @@ -0,0 +1,66 @@ +//! Measured tables for Clarity and Texture (see the parent module's docs). Generated by +//! photoshop-acr-re `tools/export_presence.py` from the fits in `tools/presfinal.py`. + +/// Clarity's tone knots for the band gains (relative to the frame's mean log₂ luminance) +/// and for the tone term. +pub(super) const CLARITY_TONE_LO: f32 = -8.0; +pub(super) const CLARITY_TONE_STEP: f32 = 2.0; +pub(super) const CLARITY_BASE_LO: f32 = -8.0; +pub(super) const CLARITY_BASE_STEP: f32 = 1.0; + +/// One direction of Clarity at full strength: per band (finest first) the gain of the band +/// against its base's tone, the tone term of the coarsest base, and a constant (log₂). +pub(super) struct Clarity { + pub(super) bands: [[f32; 7]; 4], + pub(super) base: [f32; 13], + pub(super) offset: f32, +} + +/// Clarity +100 (cross-validated effect correlation 0.81). +pub(super) const CLARITY_UP: Clarity = Clarity { + bands: [ + [0.9573, 0.9573, 0.9573, 1.5534, 1.3206, 1.0011, 1.1671], + [1.0920, 1.0920, 1.0920, 1.1946, 0.9268, 0.5702, 1.1336], + [0.7149, 0.7149, 0.7149, 0.9739, 0.8363, 0.5026, 0.6025], + [0.9620, 0.9620, 0.9620, 0.5886, 0.4937, 0.2263, 0.7602], + ], + base: [0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.2924, 0.1063, 0.0520, 0.2993, 0.2779, -0.0507, -0.0348, -0.2987], + offset: -0.2342, +}; + +/// Clarity -100 (cross-validated effect correlation 0.92). +pub(super) const CLARITY_DOWN: Clarity = Clarity { + bands: [ + [-0.0449, -0.0449, -0.0449, 0.0021, -0.5728, -1.0000, -0.8334], + [-0.0734, -0.0734, -0.0734, -0.0653, -0.7011, -1.0000, -0.8558], + [-0.0462, -0.0462, -0.0462, -0.0394, -0.4121, -0.6502, -0.5797], + [0.0527, 0.0527, 0.0527, -0.0505, 0.0206, 0.0066, 0.0594], + ], + base: [-0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0179, -0.0079, -0.0138, 0.0165, 0.0167, 0.0509, 0.0485, 0.0390], + offset: 0.0107, +}; + +/// Texture's band-response knots (stops of band detail). +pub(super) const TEXTURE_KNOTS: [f32; 11] = [-2.0000, -1.0000, -0.5000, -0.2500, -0.1000, 0.0000, 0.1000, 0.2500, 0.5000, 1.0000, 2.0000]; + +/// Texture +100: per band (σ 0.5 … 16 on the half-size copy) its change against +/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.89). +pub(super) const TEXTURE_UP: [[f32; 11]; 6] = [ + [-0.1951, -0.1951, -0.1951, -0.1951, -0.0637, -0.0122, 0.0595, 0.2096, 0.2096, 0.2096, 0.2096], + [-0.0598, -0.0598, -0.0598, -0.1444, -0.0747, -0.0089, 0.0477, 0.0790, 0.0494, 0.0494, 0.0494], + [-0.0648, -0.0648, -0.0648, -0.1096, -0.0822, -0.0196, 0.0420, 0.0509, 0.0331, 0.0331, 0.0331], + [-0.0886, -0.0886, -0.0886, -0.0958, -0.0526, 0.0136, 0.0836, 0.0817, 0.0851, 0.0851, 0.0851], + [-0.0728, -0.0728, -0.0728, -0.0525, -0.0203, 0.0305, 0.0752, 0.0759, 0.0762, 0.0408, 0.0408], + [-0.0140, -0.0140, 0.0051, 0.0180, 0.0141, 0.0207, 0.0181, 0.0092, 0.0050, -0.0051, -0.0051], +]; + +/// Texture -100: per band (σ 0.5 … 16 on the half-size copy) its change against +/// the band's detail on [`TEXTURE_KNOTS`] (cross-validated correlation 0.91). +pub(super) const TEXTURE_DOWN: [[f32; 11]; 6] = [ + [0.0194, 0.0194, 0.0194, 0.0194, -0.1470, -0.2059, -0.2565, -0.3623, -0.3623, -0.3623, -0.3623], + [0.1957, 0.1957, 0.1957, 0.2306, 0.1984, 0.1493, 0.0965, 0.0527, 0.0333, 0.0333, 0.0333], + [0.0426, 0.0426, 0.0426, 0.0942, 0.0664, 0.0104, -0.0384, -0.0452, -0.0167, -0.0167, -0.0167], + [0.0725, 0.0725, 0.0725, 0.0795, 0.0659, 0.0050, -0.0538, -0.0603, -0.0689, -0.0689, -0.0689], + [0.0771, 0.0771, 0.0771, 0.0492, 0.0319, -0.0107, -0.0507, -0.0533, -0.0512, -0.0225, -0.0225], + [0.0137, 0.0137, 0.0027, -0.0062, -0.0018, -0.0046, -0.0051, 0.0038, 0.0086, 0.0170, 0.0170], +]; diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index 8b77e7a08e..c939714d9c 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -64,6 +64,9 @@ pub struct CameraRawDialog { pub render_ms: f64, /// Set for the open-time dialog ([`Target::OpenRaw`]). raw_open: Option, + /// The open-time dialog's camera tone curve: Shadows / Highlights act on the raw data there, + /// in linear light before this curve, and the preview undoes it to show the same. + raw_curve: Option>, } impl CameraRawDialog { @@ -129,6 +132,11 @@ impl CameraRawDialog { let mut px = self.proxy.clone(); let mut p = self.params.clone(); p.pixel_scale = (self.pw as f32 / self.full_w.max(1) as f32).min(1.0); + if let Some(curve) = &self.raw_curve { + let tone = raw_stage(&p); + photocraft_io::raw::retone(&mut px, self.pw, self.ph, curve, tone); + (p.shadows, p.highlights, p.clarity, p.texture, p.dehaze) = (0.0, 0.0, 0.0, 0.0, 0.0); + } photocraft_algo::camera_raw::develop(&mut px, self.pw, self.ph, &p, self.float); if let Some(mask) = &self.selection { for ((out, original), coverage) in px.iter_mut().zip(&self.proxy).zip(mask) { @@ -174,9 +182,10 @@ enum Target { /// A raw file opened interactively, shown in Camera Raw before it is kept (as Photoshop opens /// raws in Adobe Camera Raw first). The document is already developed with the defaults; Open -/// re-develops it when Temperature, Tint or Exposure changed (those act on the sensor data, see -/// [`photocraft_io::raw::import_raw_tuned`]) and applies the other settings as one Camera Raw -/// step, then marks the document unmodified. Cancel closes it. +/// re-develops it when Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or +/// Dehaze changed (those act on the sensor data, see [`photocraft_io::raw::import_raw_tuned`]) +/// and applies the other settings as one Camera Raw step, then marks the document unmodified. +/// Cancel closes it. #[derive(Clone, Debug)] pub struct RawOpen { pub document: photocraft_doc::DocId, @@ -216,8 +225,15 @@ fn open_raw(app: &mut PhotocraftApp, ctx: &egui::Context, raw: RawOpen) -> Resul app.session.set_active(index); let (layer, surf, _) = crate::distort_ui::active_pixels(app)?; open_pixels(app, ctx, layer, surf, CameraRaw::default(), Coverage::None, Target::OpenRaw)?; + let curve = match (&raw.bytes, &raw.path) { + (Some(b), _) => Some(photocraft_io::raw::tone_curve(b)), + #[cfg(not(target_arch = "wasm32"))] + (None, Some(p)) => std::fs::read(p).ok().map(|b| photocraft_io::raw::tone_curve(&b)), + _ => None, + }; if let Some(d) = app.camera_raw.as_mut() { d.layer_name = raw.name.clone(); + d.raw_curve = curve; d.raw_open = Some(raw); } Ok(()) @@ -256,6 +272,11 @@ pub struct Redevelop { filter_step: bool, } +/// The sliders a raw open applies while developing, before the tone curve (as Camera Raw does). +fn raw_stage(p: &CameraRaw) -> photocraft_io::raw::Tone { + photocraft_io::raw::Tone { shadows: p.shadows, highlights: p.highlights, clarity: p.clarity, texture: p.texture, dehaze: p.dehaze } +} + /// Open: re-develop for raw-stage settings, apply the rest, keep the document unmodified. /// /// Re-developing reads and decodes the whole raw again, so in the desktop app (background jobs @@ -264,7 +285,16 @@ pub struct Redevelop { /// (tests, the web) it runs inline. fn commit_open_raw(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen) -> Result { let index = document_index(app, raw.document).ok_or("the raw document was closed")?; - let tuning = photocraft_io::raw::RawTuning { temperature: params.temperature, tint: params.tint, exposure: params.exposure }; + let tuning = photocraft_io::raw::RawTuning { + temperature: params.temperature, + tint: params.tint, + exposure: params.exposure, + shadows: params.shadows, + highlights: params.highlights, + clarity: params.clarity, + texture: params.texture, + dehaze: params.dehaze, + }; // Without the file the raw-stage settings stay RGB adjustments, like the filter's. let source = match (&raw.bytes, &raw.path) { (Some(b), _) => Some(photocraft_engine::jobs::OpenSource::Bytes(b.clone())), @@ -346,6 +376,11 @@ fn finish_redevelop(app: &mut PhotocraftApp, params: &CameraRaw, raw: &RawOpen, crate::notices::io_warnings(app, &format!("Opened {}", raw.name), &without_develop_note(&warnings)); let mut rest = params.clone(); rest.exposure = 0.0; + rest.shadows = 0.0; + rest.highlights = 0.0; + rest.clarity = 0.0; + rest.texture = 0.0; + rest.dehaze = 0.0; if v.get("wb").and_then(Value::as_bool) == Some(true) { rest.temperature = 0.0; rest.tint = 0.0; @@ -482,6 +517,10 @@ fn open_pixels( let st = app.session.active().ok_or("no document")?; let canvas = st.doc.bounds(); let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default(); + let mut params = params; + if params.encoding_gamma == 0.0 { + params.encoding_gamma = photocraft_engine::lens_cmds::encoding_gamma(&st.doc); + } // Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw // stages (vignette, grain, local contrast) must agree with the committed result. let area = canvas.union(&surf.content_bounds()); @@ -591,6 +630,7 @@ fn open_pixels( curve_rect: None, render_ms: 0.0, raw_open: None, + raw_curve: None, }; d.render(ctx); app.camera_raw = Some(d); @@ -937,7 +977,8 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { // Never tile a checker across the offscreen extent of a highly zoomed image. widgets::checker(preview.painter(), r.intersect(view), 8.0); preview.painter().image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - let detail_needed = r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32; + let raw_tone = d.raw_curve.is_some() && !raw_stage(&d.params).is_neutral(); + let detail_needed = !raw_tone && (r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32); d.detail.paint(&preview, r, d.preview_revision, d.show_before, &d.params, detail_needed, &d.display); if d.detail.pending() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); @@ -1336,6 +1377,46 @@ mod tests { assert!(mean_red(&app) > before + 0.05, "{before} → {}", mean_red(&app)); } + #[test] + fn shadows_of_a_raw_open_act_on_the_raw_data_and_the_preview_shows_it() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0002.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + assert!(app.camera_raw.as_ref().unwrap().raw_curve.is_some(), "the preview knows how the raw was developed"); + let mean = |px: &[[f32; 4]]| px.iter().map(|p| p[0] + p[1] + p[2]).sum::() / px.len() as f32; + let preview_before = mean(&app.camera_raw.as_ref().unwrap().processed); + let before = mean_red(&app); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"shadows": 100}}})).unwrap().unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert!(mean(&d.processed) > preview_before + 0.01, "{preview_before} → {}", mean(&d.processed)); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert!(r.get("filter").is_none(), "Shadows acts while developing, not as a later filter step: {r}"); + assert!(mean_red(&app) > before + 0.01, "{before} → {}", mean_red(&app)); + } + + #[test] + fn clarity_texture_and_dehaze_of_a_raw_open_act_on_the_raw_data() { + let (mut app, dng) = raw_app(); + let ctx = egui::Context::default(); + app.open_bytes("IMG_0003.dng", &dng).unwrap(); + let raw = app.pending_raw_open.take().unwrap(); + open_raw(&mut app, &ctx, raw).unwrap(); + let before = mean_red(&app); + let preview_before = app.camera_raw.as_ref().unwrap().processed.clone(); + menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"set": {"clarity": 60, "texture": 40, "dehaze": 50}}})).unwrap().unwrap(); + let d = app.camera_raw.as_mut().unwrap(); + d.render(&ctx); + assert_ne!(d.processed, preview_before, "the preview shows the raw-stage change"); + let r = menu(&mut app, &ctx, "filter.cameraRaw", &json!({"ui": {"commit": true}})).unwrap().unwrap(); + assert_eq!(r["redeveloped"], true, "{r}"); + assert!(r.get("filter").is_none(), "they act while developing, not as a later filter step: {r}"); + assert!(mean_red(&app) < before, "Dehaze darkens: {before} → {}", mean_red(&app)); + } + #[test] fn background_redevelop_keeps_the_tab_position_and_view() { let (mut app, dng) = raw_app(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3f0be8ebe9..72d1902cf7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -429,8 +429,9 @@ keeps the dialog open for correction. Nothing else writes document history. command line) of a camera raw developed from its sensor data shows this dialog first, titled "Camera Raw (name)", with **Open** and **Cancel**, as Photoshop opens raws in Adobe Camera Raw. `ui.inspect.cameraRaw.openingRaw` is then `{name, path}`. Commit (Open) re-develops the raw when -Temperature, Tint or Exposure changed (as-shot white-balance gains and develop exposure on the -sensor data; without an as-shot white balance, or without the file's bytes, they stay RGB +Temperature, Tint, Exposure, Shadows, Highlights, Clarity, Texture or Dehaze changed (as-shot +white-balance gains, develop exposure, and the other five in linear light before the camera's +tone curve, as Camera Raw does for raw files; without an as-shot white balance, or without the file's bytes, they stay RGB adjustments), applies the remaining settings as one `filter.cameraRaw` step and leaves the document unmodified; its result is `{document, redeveloped, filter?}`. Cancel closes the document. `app.open` and other automation opens never show the dialog. The preference From cbcdeb2549237785462ebbf5ba4560d0bfda6e3c Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:57 +0200 Subject: [PATCH 124/523] Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803) * fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end The built-in contour presets were hand-drawn guesses and none matched Photoshop. They are now Photoshop 27.11's own curves, measured by running a Satin with each preset against a Linear one (38 levels per curve, thinned to within 1 %, straight segments). The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)| (Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36 at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used by the CPU and GPU satin paths. Probe results vs Photoshop (max level error): Satin with every preset <= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2, Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 % range 27 -> 12. Corpus oracles unchanged (their stored curves are symmetric). Gloss Contour is still off: that is the bevel's gloss model, not the presets. Co-Authored-By: Claude Opus 5.5 * Layer style UI test: the Contour Editor on the measured Cone The measured Cone preset has 18 points, all inner ones corners, so the editor test moves point 1 between its neighbours and checks the corner flags by position instead of the old three-point shape. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/effects.rs | 12 +- crates/engine/src/layer_style.rs | 286 +++++++++++++++++++++++++++--- crates/gpu/src/fx.rs | 2 +- crates/ui-egui/src/layer_style.rs | 12 +- 4 files changed, 278 insertions(+), 34 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index c594e5bd87..05bd1f10da 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -882,6 +882,16 @@ fn contour_lut(c: &Contour) -> Option> { } } +/// Satin's coverage transfer: the contour read from its far end, `|c(1 − v) − c(1)|`. Measured in +/// Photoshop 27.11: Cone and Cone − Inverted Satins are identical, and a Gaussian Satin runs under +/// the diagonal (0.36 at 0.5) while the same Gaussian on a glow or gloss runs over it (0.65). +/// `None` = identity. +pub fn satin_lut(c: &Contour) -> Option> { + let lut = contour_lut(c)?; + let end = lut.last().copied().unwrap_or(1.0); + Some(lut.iter().rev().map(|v| (v - end).abs()).collect()) +} + /// Interpolated lookup in a 0..=1 table (as the GPU's `lut()`). fn lut_at(l: &[f32], v: f32) -> f32 { let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32; @@ -1706,7 +1716,7 @@ fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map { if s.invert { m = m.map(|v| 1.0 - v); } - let mut m = apply_contour(m, &s.contour); + let mut m = apply_lut(m, satin_lut(&s.contour)); for (v, a) in m.v.iter_mut().zip(&shape.v) { *v *= a; } diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index bf2d55a5a5..878bcaca0a 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -98,39 +98,250 @@ pub fn effect_defaults(kind: &str) -> Value { /// A contour preset point: input, output (both `0..=1`) and whether it is a corner. pub type ContourPoint = (f32, f32, bool); -/// Built-in contour presets, in the order of Photoshop's default contour picker. The shapes are -/// our own control points, drawn to match each preset's visible curve; imported PSD contours keep -/// their own points. Points are sorted by input; a corner point bends the curve sharply. +/// Built-in contour presets, in the order of Photoshop's default contour picker. Each is Photoshop's +/// own curve as measured in Photoshop 27.11 (a Satin of each preset against a Linear one, 38 +/// levels per curve, read back through Satin's mirrored lookup — see +/// `photocraft_compose::effects::satin_lut` — and thinned to within 1 %), joined by straight +/// segments (every inner point a corner). Imported PSD contours keep their own points. pub const CONTOURS: &[(&str, &[ContourPoint])] = &[ ("Linear", &[(0.0, 0.0, false), (1.0, 1.0, false)]), - ("Cone", &[(0.0, 0.0, false), (0.5, 1.0, true), (1.0, 0.0, false)]), - ("Cone - Inverted", &[(0.0, 1.0, false), (0.5, 0.0, true), (1.0, 1.0, false)]), - ("Cove - Deep", &[(0.0, 0.0, false), (0.3, 0.02, false), (0.6, 0.12, false), (0.85, 0.4, false), (1.0, 1.0, false)]), - ("Cove - Shallow", &[(0.0, 0.0, false), (0.5, 0.22, false), (1.0, 1.0, false)]), - ("Gaussian", &[(0.0, 0.0, false), (0.25, 0.08, false), (0.5, 0.5, false), (0.75, 0.92, false), (1.0, 1.0, false)]), - ("Half Round", &[(0.0, 0.0, false), (0.04, 0.28, false), (0.15, 0.53, false), (0.35, 0.8, false), (0.65, 0.95, false), (1.0, 1.0, false)]), - ("Ring", &[(0.0, 0.0, false), (0.25, 0.7, false), (0.5, 1.0, false), (0.75, 0.7, false), (1.0, 0.0, false)]), - ("Ring - Double", &[(0.0, 0.0, false), (0.25, 1.0, false), (0.5, 0.0, true), (0.75, 1.0, false), (1.0, 0.0, false)]), - ("Rolling Slope - Descending", &[(0.0, 1.0, false), (0.25, 0.62, false), (0.5, 0.58, false), (0.75, 0.28, false), (1.0, 0.0, false)]), ( - "Rounded Steps", + "Cone", &[ (0.0, 0.0, false), - (0.08, 0.01, false), - (0.165, 0.167, false), - (0.25, 0.323, false), - (0.333, 0.333, true), - (0.413, 0.343, false), - (0.5, 0.5, false), - (0.587, 0.657, false), - (0.667, 0.667, true), - (0.747, 0.677, false), - (0.833, 0.833, false), - (0.92, 0.99, false), + (0.172, 0.477, true), + (0.195, 0.555, true), + (0.266, 0.711, true), + (0.383, 0.922, true), + (0.43, 0.969, true), + (0.477, 0.992, true), + (0.523, 0.992, true), + (0.57, 0.969, true), + (0.664, 0.859, true), + (0.703, 0.789, true), + (0.742, 0.711, true), + (0.836, 0.477, true), + (0.859, 0.398, true), + (0.914, 0.266, true), + (0.945, 0.156, true), + (0.977, 0.078, true), + (1.0, 0.0, false), + ], + ), + ( + "Cone - Inverted", + &[ + (0.0, 1.0, false), + (0.031, 0.922, true), + (0.07, 0.789, true), + (0.172, 0.523, true), + (0.195, 0.445, true), + (0.266, 0.289, true), + (0.383, 0.078, true), + (0.43, 0.031, true), + (0.477, 0.008, true), + (0.523, 0.008, true), + (0.57, 0.031, true), + (0.664, 0.141, true), + (0.703, 0.211, true), + (0.742, 0.289, true), + (0.836, 0.523, true), + (0.859, 0.602, true), + (0.914, 0.734, true), + (0.945, 0.844, true), + (0.977, 0.922, true), (1.0, 1.0, false), ], ), - ("Sawtooth 1", &[(0.0, 0.0, false), (0.3, 1.0, true), (0.34, 0.0, true), (0.64, 1.0, true), (0.68, 0.0, true), (1.0, 1.0, false)]), + ( + "Cove - Deep", + &[ + (0.0, 0.258, false), + (0.344, 0.289, true), + (0.383, 0.297, true), + (0.477, 0.352, true), + (0.57, 0.43, true), + (0.617, 0.484, true), + (0.664, 0.57, true), + (0.703, 0.711, true), + (0.742, 0.906, true), + (0.773, 1.0, true), + (1.0, 1.0, false), + ], + ), + ( + "Cove - Shallow", + &[ + (0.0, 0.0, false), + (0.008, 0.164, true), + (0.016, 0.477, true), + (0.039, 0.508, true), + (0.172, 0.57, true), + (0.305, 0.688, true), + (0.344, 0.734, true), + (0.477, 0.945, true), + (0.523, 1.0, true), + (1.0, 1.0, false), + ], + ), + ( + "Gaussian", + &[ + (0.0, 0.0, false), + (0.117, 0.023, true), + (0.172, 0.055, true), + (0.234, 0.117, true), + (0.305, 0.234, true), + (0.383, 0.414, true), + (0.523, 0.688, true), + (0.617, 0.812, true), + (0.703, 0.891, true), + (0.805, 0.953, true), + (1.0, 1.0, false), + ], + ), + ( + "Half Round", + &[ + (0.0, 0.0, false), + (0.07, 0.18, true), + (0.195, 0.445, true), + (0.266, 0.555, true), + (0.383, 0.703, true), + (0.523, 0.828, true), + (0.617, 0.883, true), + (0.773, 0.945, true), + (1.0, 1.0, false), + ], + ), + ( + "Ring", + &[ + (0.0, 0.0, false), + (0.031, 0.078, true), + (0.039, 0.117, true), + (0.117, 0.383, true), + (0.172, 0.609, true), + (0.195, 0.734, true), + (0.266, 0.953, true), + (0.305, 1.0, true), + (0.344, 0.992, true), + (0.383, 0.922, true), + (0.43, 0.805, true), + (0.477, 0.641, true), + (0.57, 0.281, true), + (0.617, 0.141, true), + (0.664, 0.055, true), + (0.703, 0.016, true), + (0.742, 0.016, true), + (0.773, 0.062, true), + (0.836, 0.234, true), + (0.859, 0.344, true), + (0.891, 0.445, true), + (0.914, 0.547, true), + (0.945, 0.734, true), + (0.977, 0.875, true), + (1.0, 1.0, false), + ], + ), + ( + "Ring - Double", + &[ + (0.0, 0.0, false), + (0.055, 0.086, true), + (0.094, 0.172, true), + (0.117, 0.242, true), + (0.172, 0.477, true), + (0.195, 0.625, true), + (0.266, 0.891, true), + (0.305, 0.945, true), + (0.344, 0.891, true), + (0.383, 0.695, true), + (0.43, 0.367, true), + (0.477, 0.07, true), + (0.523, 0.047, true), + (0.57, 0.32, true), + (0.617, 0.648, true), + (0.664, 0.867, true), + (0.703, 0.93, true), + (0.742, 0.852, true), + (0.773, 0.641, true), + (0.805, 0.375, true), + (0.836, 0.156, true), + (0.859, 0.031, true), + (0.891, 0.023, true), + (0.914, 0.109, true), + (0.977, 0.703, true), + (0.984, 0.82, true), + (1.0, 1.0, false), + ], + ), + ( + "Rolling Slope - Descending", + &[ + (0.0, 1.0, false), + (0.008, 1.0, true), + (0.031, 0.93, true), + (0.039, 0.883, true), + (0.07, 0.758, true), + (0.141, 0.539, true), + (0.195, 0.406, true), + (0.266, 0.352, true), + (0.305, 0.352, true), + (0.523, 0.57, true), + (0.57, 0.602, true), + (0.617, 0.617, true), + (0.703, 0.602, true), + (0.742, 0.562, true), + (0.836, 0.391, true), + (0.914, 0.211, true), + (0.945, 0.109, true), + (0.977, 0.039, true), + (0.984, 0.008, true), + (1.0, 0.0, false), + ], + ), + ( + "Rounded Steps", + &[ + (0.0, 0.234, false), + (0.031, 0.305, true), + (0.039, 0.336, true), + (0.094, 0.43, true), + (0.141, 0.477, true), + (0.234, 0.492, true), + (0.266, 0.516, true), + (0.305, 0.602, true), + (0.344, 0.664, true), + (0.383, 0.711, true), + (0.477, 0.734, true), + (0.523, 0.797, true), + (0.57, 0.898, true), + (0.617, 0.953, true), + (0.664, 0.984, true), + (0.773, 0.984, true), + (0.836, 0.906, true), + (0.891, 0.891, true), + (0.914, 0.898, true), + (1.0, 1.0, false), + ], + ), + ( + "Sawtooth 1", + &[ + (0.0, 0.0, false), + (0.031, 0.047, true), + (0.195, 0.375, true), + (0.266, 0.492, true), + (0.477, 0.047, true), + (0.523, 0.055, true), + (0.742, 0.469, true), + (0.773, 0.445, true), + (0.914, 0.18, true), + (0.945, 0.102, true), + (1.0, 0.0, false), + ], + ), ]; /// Names earlier versions offered, still accepted by `contour` params (scripts, saved defaults). @@ -1118,10 +1329,29 @@ mod tests { assert_eq!(lin.contour, Contour::Linear); } + #[test] + fn contour_presets_match_photoshop() { + // Photoshop 27.11, measured through Satin: Cone and Cone − Inverted Satins are identical, + // and Gaussian reads 0.36 at 0.5 through Satin but 0.64 as the preset curve (glows, gloss). + let lut = |name: &str| { + let Some(Contour::Custom { points, corners, .. }) = builtin_contour(name) else { panic!("{name}") }; + photocraft_compose::adjust::contour_curve_lut(&points, &corners) + }; + let satin = |name: &str| photocraft_compose::effects::satin_lut(&builtin_contour(name).unwrap()).unwrap(); + let at = |l: &[f32], x: f32| l[(x * (l.len() - 1) as f32).round() as usize]; + let (cone, inverted) = (satin("Cone"), satin("Cone - Inverted")); + for x in [0.1, 0.25, 0.5, 0.8] { + assert!((at(&cone, x) - at(&inverted, x)).abs() < 0.03, "{x}: {} vs {}", at(&cone, x), at(&inverted, x)); + } + assert!((at(&satin("Gaussian"), 0.5) - 0.36).abs() < 0.04, "{}", at(&satin("Gaussian"), 0.5)); + assert!((at(&lut("Gaussian"), 0.5) - 0.64).abs() < 0.03, "{}", at(&lut("Gaussian"), 0.5)); + assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero"); + } + #[test] fn contour_presets_and_custom_curves_apply_in_one_undo_step() { let mut s = session(); - // Picking Cone: the preset's own curve, its peak a corner, in one undo step. + // Picking Cone: the preset's own curve, its points corners, in one undo step. s.execute("layer.layerStyle.dropShadow", json!({"contour": "Cone", "antiAlias": true})).unwrap(); let Effect::DropShadow(sh) = &effects(&s)[0] else { panic!() }; assert_eq!(sh.contour, builtin_contour("Cone").unwrap()); @@ -1129,7 +1359,9 @@ mod tests { let Contour::Custom { points, corners, .. } = &sh.contour else { panic!() }; let lut = photocraft_compose::adjust::contour_curve_lut(points, corners); let at = |x: f32| lut[(x * (lut.len() - 1) as f32).round() as usize]; - assert!((at(0.5) - 1.0).abs() < 1e-3 && (at(0.25) - 0.5).abs() < 0.01, "a sharp Λ, not a rounded hump"); + // Photoshop's measured Cone: 0 at both ends, a 0.99 peak at 0.5, symmetric. + assert!(at(0.0).abs() < 1e-3 && at(1.0).abs() < 1e-3 && at(0.5) > 0.98, "a Λ: {} {} {}", at(0.0), at(0.5), at(1.0)); + assert!((at(0.25) - at(0.75)).abs() < 0.03, "symmetric: {} vs {}", at(0.25), at(0.75)); assert_eq!(contour_param(&sh.contour), json!("Cone")); s.execute("edit.undo", json!({})).unwrap(); assert!(effects(&s).is_empty(), "one undo removes it"); diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 10f9272620..9eba5888b8 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -358,7 +358,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, let a = b.conv(a, photocraft_compose::effects::tent_kernel(s.size)); let c = b.shift(In::Shape, -dx, -dy, 0.0, false); let c = b.conv(c, photocraft_compose::effects::tent_kernel(s.size)); - b.finish(a, Some(c), s.invert, &s.contour, true, 0); + b.finish_lut(a, Some(c), s.invert, photocraft_compose::effects::satin_lut(&s.contour), true, 0); 1 } Effect::BevelEmboss(bv) => { diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 93f22c479b..b350cef521 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -1693,17 +1693,19 @@ mod tests { apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); assert_eq!(shadow(&s), photocraft_engine::layer_style::builtin_contour("Cone").unwrap()); - // The Contour Editor: drag the peak to 40 % / 80 %, make the first point a corner. + // The Contour Editor: drag the second point to 10 % / 30 % (between its neighbours on + // the measured Cone), make the first point a corner. contour::open_editor(&mut f, "fx1", "contour"); - assert!(contour::set_point(&mut f, 1, 40.0, 80.0)); + assert!(contour::set_point(&mut f, 1, 10.0, 30.0)); assert!(contour::set_corner(&mut f, 0, true)); - assert!(!contour::set_point(&mut f, 9, 10.0, 10.0), "no such point"); + assert!(!contour::set_point(&mut f, 99, 10.0, 10.0), "no such point"); contour::close_editor(&mut f, true); apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); let photocraft_doc::Contour::Custom { name, points, corners } = shadow(&s) else { panic!() }; assert_eq!(name, "Custom", "an edited preset is no longer called Cone"); - assert!((points[1].input - 102.0 / 255.0).abs() < 1e-6 && (points[1].output - 204.0 / 255.0).abs() < 1e-6, "{points:?}"); - assert_eq!(corners, vec![true, true, false]); + assert!((points[1].input - 0.1).abs() < 3e-3 && (points[1].output - 0.3).abs() < 3e-3, "{points:?}"); + assert_eq!(corners.len(), points.len()); + assert!(corners[0] && corners[1] && !corners[corners.len() - 1], "{corners:?}"); // Cancel restores the curve the editor opened with. let before = f.clone(); From 81f974503358e57a34f920f7aa4924ad84f85a39 Mon Sep 17 00:00:00 2001 From: Gotix-eg Date: Mon, 12 Oct 2026 16:03:40 +0300 Subject: [PATCH 125/523] fix(smart-object): keep pixels when rasterizing smart object layer (#3873) Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/extra_cmds.rs | 82 +++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 251acfb3f4..1399784b1d 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -696,10 +696,34 @@ fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str "shape" => { s.execute("layer.rasterize.shape", json!({"layer": id.0, "coalesce": key}))?; } - _ => { - let label = if kind == "fill" { "Rasterize Fill Content" } else { "Rasterize Smart Object" }; + "smart" => { s.coalesce_request = Some(key.to_string()); - let r = s.edit(label, |doc, _| { + let r = s.edit("Rasterize Smart Object", |doc, _| { + let snapshot = doc.clone(); + let mut snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; + if matches!(snap.content, LayerContent::Smart(ref sm) if sm.cache.is_none()) { + let _ = crate::smart_cmds::refresh_layer(&snapshot, &mut snap); + } + let fmt = snapshot.pixel_format(); + let surface = match &mut snap.content { + LayerContent::Smart(sm) => match sm.cache.take() { + Some(c) => c, + None => content_pixels(&snapshot, &snap), + }, + _ => return Err(EngineError::Other(format!("layer {} is not a smart object", id.0))), + }; + let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) }; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.content = LayerContent::Raster(surface); + l.psd_blocks.retain(|(k, _)| k != b"SoLd" && k != b"SoLE" && k != b"PlLd"); + Ok(()) + }); + s.coalesce_request = None; + r?; + } + _ => { + s.coalesce_request = Some(key.to_string()); + let r = s.edit("Rasterize Fill Content", |doc, _| { let snap = doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?; let px = content_pixels(doc, &snap); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -1655,4 +1679,56 @@ mod tests { assert_eq!(s.active().unwrap().history.past_len(), steps + 1); assert!(doc(&s).walk().iter().all(|(_, _, l)| !matches!(l.content, LayerContent::Fill(_)))); } + + #[test] + fn rasterize_smart_object_layer() { + let mut s = session(8); + s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap(); + let px_before = pixel(&s, 5, 5); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + assert!(matches!(active(&s).content, LayerContent::Smart(_))); + s.execute("layer.rasterize.smartObject", json!({})).unwrap(); + assert!(matches!(active(&s).content, LayerContent::Raster(_))); + let px_after = pixel(&s, 5, 5); + assert_eq!(px_after, px_before); + + // Case 2: New layer above background + let mut s2 = session(8); + s2.execute("layer.new.layer", json!({})).unwrap(); + s2.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s2.execute("edit.fill", json!({"contents": "color", "color": "#00ff00"})).unwrap(); + s2.execute("select.deselect", json!({})).unwrap(); + let px_mid = pixel(&s2, 20, 20); + assert_eq!(px_mid[1], 1.0); // green + s2.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + assert!(matches!(active(&s2).content, LayerContent::Smart(_))); + s2.execute("layer.rasterize.smartObject", json!({})).unwrap(); + assert!(matches!(active(&s2).content, LayerContent::Raster(_))); + let px_after_rast = pixel(&s2, 20, 20); + assert_eq!(px_after_rast, px_mid); + + // Case 3: Shape layer converted to smart object then rasterized + let mut s3 = session(8); + s3.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 40], "fill": "#0000ff"})).unwrap(); + let px_shape = pixel(&s3, 20, 20); + s3.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + assert!(matches!(active(&s3).content, LayerContent::Smart(_))); + s3.execute("layer.rasterize.layer", json!({})).unwrap(); + assert!(matches!(active(&s3).content, LayerContent::Raster(_))); + let px_after_shape = pixel(&s3, 20, 20); + assert_eq!(px_after_shape, px_shape); + + // Case 4: Transformed smart object + let mut s4 = session(8); + s4.execute("layer.new.layer", json!({})).unwrap(); + s4.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + s4.execute("edit.fill", json!({"contents": "color", "color": "#ffff00"})).unwrap(); + s4.execute("select.deselect", json!({})).unwrap(); + s4.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + // Move smart object by 10, 10 + s4.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 10, 10]})).unwrap(); + s4.execute("layer.rasterize.smartObject", json!({})).unwrap(); + assert!(matches!(active(&s4).content, LayerContent::Raster(_))); + assert_eq!(pixel(&s4, 30, 30), vec![1.0, 1.0, 0.0, 1.0]); + } } From 3f72158f0af959674651bfb8086ee000bf8f5157 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:05:22 +0200 Subject: [PATCH 126/523] fix(psd): write kept 8B64 blocks as 8BIM in a PSD (#3743) A PSD re-saved from a PSB kept the source's global FMsk and cinf blocks with their 8B64 signature, and Photoshop 27.11 refuses such a file as "not compatible with this version". 8B64 exists only in PSB, so a kept block is written as 8BIM when the output is a PSD; a PSB keeps 8B64. Measured by bisecting the 3 refused corpus re-exports in Photoshop: each opened once the 8B64 blocks were gone. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/io/src/psd_export.rs | 27 ++++++++++++++++++++++++++- crates/io/tests/roundtrip.rs | 5 ++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4c7463a3c2..36f5941f78 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1356,6 +1356,13 @@ pub(crate) fn estimate_psd_size(doc: &Document) -> Option { estimate_encoded_size(raw_bytes, rows, structural_bytes) } +/// The signature a kept tagged block is written with. `8B64` exists only in PSB files: a PSD +/// with an `8B64` block (kept from a PSB source, e.g. its global `FMsk` or `cinf`) is refused by +/// Photoshop as "not compatible with this version" (measured in Photoshop 27.11). +fn block_signature(sig: [u8; 4], version: Version) -> [u8; 4] { + if version == Version::Psd && sig == *b"8B64" { *b"8BIM" } else { sig } +} + pub(crate) fn psd_version(doc: &Document, force_psb: bool, size_exceeds_limit: bool) -> Version { let exceeds_dimensions = doc.size.width > 30_000 || doc.size.height > 30_000; if force_psb || exceeds_dimensions || size_exceeds_limit { Version::Psb } else { Version::Psd } @@ -1578,7 +1585,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") { continue; } - global_blocks.push(global_block(sig, key, &data)); + global_blocks.push(global_block(block_signature(sig, version), key, &data)); } if let Some(txt2) = txt2 { let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone()); @@ -1784,6 +1791,24 @@ mod tests { assert_eq!(psd_version(&small, false, small_size_exceeds_limit), Version::Psd); assert_eq!(psd_version(&small, true, small_size_exceeds_limit), Version::Psb); } + #[test] + fn kept_psb_blocks_are_written_as_8bim_in_a_psd() { + // Photoshop 27.11 refuses a PSD holding `8B64` blocks ("not compatible with this + // version"); they came from re-saving a PSB as PSD with its global FMsk and cinf kept. + let mut doc = document(4, 4); + doc.metadata.psd_global_blocks = vec![ + (*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0, 0, 50])), + (*b"8BIM", *b"Patt", std::sync::Arc::new(Vec::new())), + ]; + let signatures = |force_psb| { + let opts = PsdExportOptions { force_psb, ..PsdExportOptions::default() }; + let bytes = document_to_psd_with(&doc, &opts).0.to_bytes().unwrap(); + let file = PsdFile::from_bytes(&bytes).unwrap(); + file.global_blocks.iter().map(|b| (b.signature, b.key)).collect::>() + }; + assert_eq!(signatures(false), vec![(*b"8BIM", *b"FMsk"), (*b"8BIM", *b"Patt")]); + assert_eq!(signatures(true), vec![(*b"8B64", *b"FMsk"), (*b"8BIM", *b"Patt")], "a PSB keeps 8B64"); + } /// A layered document with transparency in the merged image and an alpha channel: the name /// and DisplayInfo resources hold one entry per extra channel, the merged transparency first, diff --git a/crates/io/tests/roundtrip.rs b/crates/io/tests/roundtrip.rs index 3e2403f2e4..651d74d53c 100644 --- a/crates/io/tests/roundtrip.rs +++ b/crates/io/tests/roundtrip.rs @@ -305,7 +305,10 @@ fn psd_raw_resources_and_global_blocks_preserved() { d.metadata.psd_global_blocks.push((*b"8BIM", *b"Patt", std::sync::Arc::new(vec![7; 6]))); d.metadata.psd_global_blocks.push((*b"8B64", *b"lnk2", std::sync::Arc::new(vec![1, 2]))); let back = roundtrip(&d); - assert_eq!(back.metadata, d.metadata); + // A PSD holds no `8B64` blocks (Photoshop refuses the file): a kept PSB block is written as `8BIM`. + let mut expected = d.metadata.clone(); + expected.psd_global_blocks[1].0 = *b"8BIM"; + assert_eq!(back.metadata, expected); } #[test] From aa4d4859b4912a4efc960fbe6ca703d227477a09 Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Mon, 12 Oct 2026 15:13:38 +0200 Subject: [PATCH 127/523] fix(io): preserve indexed BMP color tables (#3802) (#3869) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/README.md | 3 ++ crates/codecs/src/codecs/via_image.rs | 5 ++++ crates/codecs/src/lib.rs | 5 ++++ crates/io/src/flat.rs | 8 +++++ crates/io/tests/flat.rs | 43 +++++++++++++++++++++++++++ docs/file-format-parity.md | 5 ++-- 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/codecs/README.md b/crates/codecs/README.md index ee639bcb17..e44890a0d9 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -61,6 +61,9 @@ the encode plan, and `fidelity_warnings` reports the conversion when it loses in `Image::convert` applies the same ICC rule: it keeps the profile only when the colour model stays the same. +Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded table so +`photocraft-io` can open them in Indexed Color mode. + ## Documented asymmetries and limitations * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` diff --git a/crates/codecs/src/codecs/via_image.rs b/crates/codecs/src/codecs/via_image.rs index eca2fbb748..c91be5baf7 100644 --- a/crates/codecs/src/codecs/via_image.rs +++ b/crates/codecs/src/codecs/via_image.rs @@ -32,6 +32,11 @@ fn map_err(f: Format, e: image::ImageError) -> CodecError { } } +pub(crate) fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { + let decoder = image::codecs::bmp::BmpDecoder::new(Cursor::new(bytes)).map_err(|e| map_err(Format::Bmp, e))?; + Ok(decoder.get_palette().map(|palette| palette.to_vec())) +} + pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result { let fmt = image_format(f).ok_or_else(|| CodecError::unsupported(f, "not handled by image backend"))?; let mut reader = image::ImageReader::with_format(Cursor::new(bytes), fmt); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 8278ac0d6b..16025a5a46 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -98,6 +98,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res finish_decode(img, o, opts) } +/// Returns the embedded colour table for an indexed BMP, if it has one. +pub fn bmp_palette(bytes: &[u8]) -> Result>, CodecError> { + via_image::bmp_palette(bytes) +} + /// Lists the pages (image directories) of a TIFF or BigTIFF file without decoding pixels: the /// main IFD chain and its SubIFDs, each with its size, kind (page, reduced-resolution copy or /// mask) and storage. [`TiffInfo::default_page`] is the page [`decode`] opens. diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 450969e827..129282e61b 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -20,7 +20,15 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result { return import_tiff_page(name, bytes, None); } let img = codecs::decode(bytes)?; + // A palette BMP opens in Indexed Color mode with its colour table, as in Photoshop (#3802). + let bmp_palette = if codecs::detect(bytes) == Some(Format::Bmp) { codecs::bmp_palette(bytes).ok().flatten() } else { None }; let mut r = image_to_document(name, &img)?; + if let Some(colors) = bmp_palette.filter(|c| !c.is_empty()) + && r.document.mode == ColorMode::Rgb + { + r.document.mode = ColorMode::Indexed; + r.document.color_table = Some(photocraft_doc::ColorTable { colors, transparent: None }); + } r.document.file_encoding = file_encoding(bytes); if codecs::detect(bytes) == Some(Format::Tga) && img.layout() == ChannelLayout::Rgba { tga_alpha_to_channel(&mut r.document)?; diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 728f65d1f2..4b184ad7fa 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -54,6 +54,49 @@ codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0); codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); +fn indexed_bmp_8() -> Vec { + const FILE_HEADER_SIZE: usize = 14; + const INFO_HEADER_SIZE: usize = 40; + const PALETTE_ENTRY_SIZE: usize = 4; + const PALETTE: [[u8; 3]; 2] = [[10, 20, 30], [200, 100, 50]]; + const PIXEL_ROW: [u8; 4] = [0, 1, 0, 0]; + let pixel_offset = FILE_HEADER_SIZE + INFO_HEADER_SIZE + PALETTE.len() * PALETTE_ENTRY_SIZE; + let file_size = pixel_offset + PIXEL_ROW.len(); + let mut bytes = Vec::with_capacity(file_size); + bytes.extend_from_slice(b"BM"); + bytes.extend_from_slice(&(file_size as u32).to_le_bytes()); + bytes.extend_from_slice(&[0; 4]); + bytes.extend_from_slice(&(pixel_offset as u32).to_le_bytes()); + bytes.extend_from_slice(&(INFO_HEADER_SIZE as u32).to_le_bytes()); + bytes.extend_from_slice(&2i32.to_le_bytes()); + bytes.extend_from_slice(&1i32.to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&8u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&(PIXEL_ROW.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&0i32.to_le_bytes()); + bytes.extend_from_slice(&0i32.to_le_bytes()); + bytes.extend_from_slice(&(PALETTE.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + for [r, g, b] in PALETTE { + bytes.extend_from_slice(&[b, g, r, 0]); + } + bytes.extend_from_slice(&PIXEL_ROW); + bytes +} + +#[test] +fn indexed_bmp_opens_with_its_color_table() { + let doc = import("indexed.bmp", &indexed_bmp_8()).unwrap().document; + assert_eq!(doc.mode, ColorMode::Indexed); + assert_eq!(doc.depth, SampleType::U8); + assert_eq!(doc.color_table.as_ref().unwrap().colors.get(..2), Some(&[[10, 20, 30], [200, 100, 50]][..])); + let px = doc.layers[0].surface().unwrap().rgba(1, 0); + assert!((px[0] - 200.0 / 255.0).abs() < 1e-6); + assert!((px[1] - 100.0 / 255.0).abs() < 1e-6); + assert!((px[2] - 50.0 / 255.0).abs() < 1e-6); +} + /// FITS header cards travel as the document's text metadata, so an astro frame keeps its /// OBJECT, EXPTIME and the rest through an edit. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index deb414961d..024d596f1e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Save keeps a JPEG's quality and a WebP's lossy/lossless encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -28,7 +28,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | JPEG | R/W | yes | yes | 8-bit Gray/RGB/CMYK, ICC/EXIF/XMP; Save keeps the opened file's quality (estimated from its quantisation tables, #2399); no JPEG Options dialog (quality 0–12, baseline/progressive) (FILE-215-19) | | PNG | R/W | yes | yes | 8/16-bit, ICC/text; APNG first frame only; alpha channels on open (#2225) | | GIF | R/W | yes | yes | File › Open keeps the first frame; New Video Layer from File and Video Frames to Layers take every frame, with the timeline rate from the delays (#572). Per-frame delays and keeping the frames in `.pcraft` are not done | -| BMP | R/W | yes | yes | 8-bit | +| BMP | R/W | yes | yes | 8-bit; palette BMPs open in Indexed Color mode | | WebP | R/W | yes | yes | Lossless and our own lossy VP8 encoder; ICC/EXIF/XMP; Save keeps an opened file lossy (at its quality) or lossless (#2399) | | AVIF | R/W | no (#463) | optional feature, off by default | | | JPEG XL | R/W | inside DNG only | no | | @@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | | 2026-10-10 | major | First version: Photoshop 2026 27.11.0's formats from its bundle, against our codecs; Affinity notes moved from `roadmap.md` | From 8d49ecf50d59885fb2dcf38a203d62b314565726 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Mon, 12 Oct 2026 14:17:43 +0100 Subject: [PATCH 128/523] feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725) * feat: Left and Right arrows turn the brush tip With a painting tool, Photoshop's Left and Right arrow keys turn the brush tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush turns the session brush's angle by "degrees", wrapped into -180..180, and move_mods::arrow_keys gives it the plain and Shift arrows only for the painting tools, when no Move-tool, transform or selection nudge applies and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still nudge the layer. Co-Authored-By: Claude Opus 5.5 * feat(ui): Shift-click with the Type tool starts new text over existing type In Photoshop a click on existing type with the Type tool edits it, and a Shift-click there starts a new type layer instead, so text can be placed over other text. pointer_down now leaves a Shift-click unconsumed when no type is being edited, so it starts new text; inside the text being edited, Shift-click still extends the selection. Co-Authored-By: Claude Opus 5.5 * feat(ui): Space+F cycles the canvas colour Photoshop's Space+F steps the pasteboard (canvas) colour through Default, Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F steps back, round and round. While the Hand key is held, F does this instead of Cycle Screen Mode; the choice is saved like picking it in the pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new tool keys. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/brush_key_cmds.rs | 48 ++++++++++++++++++++- crates/ui-egui/src/canvas_tool_menu.rs | 14 ++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/move_mods.rs | 52 ++++++++++++++++++++++- crates/ui-egui/src/shortcuts.rs | 11 +++++ crates/ui-egui/src/tool_shortcut_tests.rs | 39 +++++++++++++++++ crates/ui-egui/src/type_tool.rs | 21 ++++++++- docs/ui-parity.md | 4 +- 8 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/tool_shortcut_tests.rs diff --git a/crates/engine/src/brush_key_cmds.rs b/crates/engine/src/brush_key_cmds.rs index e4887bee39..56663b400a 100644 --- a/crates/engine/src/brush_key_cmds.rs +++ b/crates/engine/src/brush_key_cmds.rs @@ -2,11 +2,15 @@ //! has no menu item for them but lists them in Edit › Keyboard Shortcuts › Tools ("Decrease Brush //! Size"…); here they are ordinary commands with default shortcuts, so they can be rebound, //! recorded and driven by agents. Each one is a `tools.setBrush` on the session brush. +//! +//! `tools.rotateBrush` turns the tip: Photoshop's ← / → while painting (1°, ⇧ 15°). Arrows mean +//! other things with other tools, so the shell gives them to it only for the painting tools +//! (`move_mods::arrow_keys`). use serde_json::{Value, json}; use crate::commands::{CommandSpec, always}; -use crate::{Result, Session}; +use crate::{EngineError, Result, Session}; /// Photoshop's largest brush diameter, in pixels. pub const MAX_SIZE: f32 = 5000.0; @@ -35,6 +39,23 @@ fn hardness(s: &mut Session, up: bool) -> Result { crate::brush_cmds::set_brush(s, &json!({ "brush": { "hardness": hardness } })) } +/// The brush angle turned by `degrees` (positive is anticlockwise, as the angle field counts), +/// wrapped into Photoshop's -180..=180. +fn turned(angle: f32, degrees: f32) -> f32 { + let a = (angle + degrees).rem_euclid(360.0); + if a > 180.0 { a - 360.0 } else { a } +} + +fn rotate(s: &mut Session, p: &Value) -> Result { + let by = p + .get("degrees") + .and_then(Value::as_f64) + .filter(|d| d.is_finite() && d.abs() <= 360.0) + .ok_or_else(|| EngineError::BadParams { cmd: "tools.rotateBrush".into(), msg: "`degrees` must be a number from -360 to 360".into() })?; + let angle = turned(s.tools.brush.angle, by as f32); + crate::brush_cmds::set_brush(s, &json!({ "brush": { "angle": angle } })) +} + macro_rules! key_cmd { ($id:literal, $label:literal, $sc:literal, $run:expr) => { CommandSpec { id: $id, label: $label, menu: &[], shortcut: Some($sc), params: "{}", enabled: always, run: $run, journal: true } @@ -47,6 +68,16 @@ pub fn specs() -> Vec { key_cmd!("tools.increaseBrushSize", "Increase Brush Size", "]", |s, _| size(s, true)), key_cmd!("tools.decreaseBrushHardness", "Decrease Brush Hardness", "Shift+[", |s, _| hardness(s, false)), key_cmd!("tools.increaseBrushHardness", "Increase Brush Hardness", "Shift+]", |s, _| hardness(s, true)), + CommandSpec { + id: "tools.rotateBrush", + label: "Rotate Brush Tip", + menu: &[], + shortcut: None, + params: r##"{"degrees":-360..360 (positive anticlockwise; ← / → while painting send 1, ⇧ 15)}"##, + enabled: always, + run: rotate, + journal: true, + }, ] } @@ -74,6 +105,21 @@ mod tests { assert_eq!(hardness_step(0.0, false), 0.0); } + #[test] + fn rotate_brush_turns_the_tip_and_wraps_at_180() { + assert_eq!(turned(0.0, 1.0), 1.0); + assert_eq!(turned(179.0, 15.0), -166.0); + assert_eq!(turned(-175.0, -15.0), 170.0); + assert_eq!(turned(180.0, 0.0), 180.0); + let mut s = Session::new(); + s.execute("tools.rotateBrush", json!({"degrees": -15})).unwrap(); + assert_eq!(s.tools.brush.angle, -15.0); + for bad in [json!({}), json!({"degrees": "x"}), json!({"degrees": 1e9}), json!(null)] { + assert!(s.execute("tools.rotateBrush", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.tools.brush.angle, -15.0); + } + #[test] fn commands_step_the_session_brush_and_journal() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 13af25d845..b6b86111e1 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -123,6 +123,20 @@ const ENDS_TYPE_EDIT: [&str; 5] = /// The pasteboard menu's Select Custom Color… row. pub const PASTEBOARD_PICK: &str = "pasteboard.selectCustomColor"; +/// The pasteboard colours in the menu's order, which Space+F steps through. +const CANVAS_COLORS: [CanvasColor; 6] = + [CanvasColor::Default, CanvasColor::Black, CanvasColor::DarkGray, CanvasColor::MediumGray, CanvasColor::LightGray, CanvasColor::Custom]; + +/// Photoshop's Space+F (⇧Space+F backwards): the next pasteboard colour, round and round. Saved +/// like a choice in the pasteboard menu (`interface.canvasColor`). +pub fn cycle_canvas_color(app: &mut PhotocraftApp, backward: bool) -> Result<(), String> { + let n = CANVAS_COLORS.len(); + let at = CANVAS_COLORS.iter().position(|c| *c == app.session.prefs().interface.canvas_color).unwrap_or(0); + let next = if backward { (at + n - 1) % n } else { (at + 1) % n }; + let name = CANVAS_COLORS.get(next).map_or("default", |c| c.name()); + app.run("prefs.set", json!({"values": {"interface.canvasColor": name}})).map(|_| ()) +} + /// The Zoom and Hand tools' context menu: the zoom steps and fixed zooms of the View menu, plus /// the Hand tool's Fill Screen. pub const VIEW_MENU: &[Row] = &[ diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1ba0bce83d..d6309afccc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2289,6 +2289,8 @@ mod color_replacement_tests; mod layer_shortcut_tests; #[cfg(test)] mod pencil_tests; +#[cfg(test)] +mod tool_shortcut_tests; #[cfg(test)] mod pixel_aspect_tests; diff --git a/crates/ui-egui/src/move_mods.rs b/crates/ui-egui/src/move_mods.rs index 193a70bb50..b3e7820dd1 100644 --- a/crates/ui-egui/src/move_mods.rs +++ b/crates/ui-egui/src/move_mods.rs @@ -175,7 +175,7 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::{Key, Modifiers}; let cmd = ctx.input(|i| i.modifiers.command) && crate::hold_keys::cmd_nudges(app); if app.ui.tool != Tool::Move && app.ui.transform.is_none() && !cmd { - return selection_arrow_keys(app, ctx); + return selection_arrow_keys(app, ctx) || brush_arrow_keys(app, ctx); } // `consume_key` never ignores an extra ⌘, so it is part of every pattern while it moves. let base = if cmd { Modifiers::COMMAND } else { Modifiers::NONE }; @@ -193,6 +193,30 @@ pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { false } +/// ← / → with a painting tool: turn the brush tip 1° (⇧ 15°), as Photoshop does; → turns it +/// clockwise. Returns true when a key was used. +fn brush_arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { + use egui::{Key, Modifiers}; + // Only plain or ⇧ arrows (`consume_key` ignores an extra ⌥, which kerns type), and never + // while type is being edited. + let m = ctx.input(|i| i.modifiers); + if !app.ui.tool.is_brushlike() || app.ui.text_edit.is_some() || m.alt || m.command || m.ctrl { + return false; + } + for (key, sign) in [(Key::ArrowLeft, 1.0), (Key::ArrowRight, -1.0)] { + // ⇧ first: `consume_key` ignores an extra ⇧. + for (mods, step) in [(Modifiers::SHIFT, 15.0), (Modifiers::NONE, 1.0)] { + if ctx.input_mut(|i| i.consume_key(mods, key)) { + if let Err(e) = app.run("tools.rotateBrush", json!({"degrees": sign * step})) { + app.ui.status = e; + } + return true; + } + } + } + false +} + /// Moves the selected layers (or the Free Transform box, or the selected pixels) by `(dx, dy)` /// pixels, as the Move tool (also when ⌘ makes it the temporary Move tool). pub fn nudge(app: &mut PhotocraftApp, dx: f64, dy: f64, duplicate_first: bool) { @@ -404,6 +428,27 @@ mod tests { arrow_keys(app, &ctx) } + /// Photoshop's ← / → with a painting tool turn the brush tip 1°, ⇧ 15°; → is clockwise. Other + /// tools keep their arrows (the Hand has none, the marquee nudges the selection). + #[test] + fn arrows_turn_the_brush_tip_for_painting_tools_only() { + let mut app = app_with_layer(); + for tool in [Tool::Brush, Tool::Eraser, Tool::CloneStamp] { + app.ui.tool = tool; + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); + assert!(press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE), "{tool:?}"); + assert_eq!(app.session.tools.brush.angle, 1.0, "{tool:?}: ← anticlockwise"); + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::SHIFT)); + assert_eq!(app.session.tools.brush.angle, -14.0, "{tool:?}: ⇧→ 15° clockwise"); + } + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); + // ⌥-arrows are not the brush's (they kern type, and ⌘ moves). + assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::ALT)); + app.ui.tool = Tool::Hand; + assert!(!press(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE)); + assert_eq!(app.session.tools.brush.angle, 0.0); + } + /// Real key events: ⇧ nudges 10 px and ⌥ duplicates first (egui ignores extra ⇧ / ⌥ when /// matching, so the plain arrow must not be tried first). #[test] @@ -433,8 +478,11 @@ mod tests { app.ui.tool = Tool::Brush; let id = app.session.active().unwrap().active_layer.unwrap(); let n0 = app.session.active().unwrap().doc.layers.len(); - assert!(!press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE), "a plain arrow is not the Brush's"); + // A plain arrow turns the Brush's tip (below), never the layer. + assert!(press(&mut app, egui::Key::ArrowRight, egui::Modifiers::NONE)); assert_eq!(bounds(&app, id), photocraft_geom::Rect::new(8, 8, 24, 24)); + assert_eq!(app.session.tools.brush.angle, -1.0); + app.run("tools.setBrush", json!({"brush": {"angle": 0}})).unwrap(); for cmd in [CTRL, MAC_CMD] { assert!(press(&mut app, egui::Key::ArrowRight, cmd)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 511265e1aa..7c0c8145f6 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -456,6 +456,17 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } + // Space+F (⇧Space+F backwards) cycles the pasteboard colour, as in Photoshop: F while the + // Hand key is held, before F's own Cycle Screen Mode. + if !matches!(focus, Focus::Text) && app.ui.text_edit.is_none() && crate::hold_keys::reposition_held(app, ctx) { + let back = ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::F)); + if back || ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::F)) { + if let Err(e) = crate::canvas_tool_menu::cycle_canvas_color(app, back) { + app.ui.status = e; + } + return; + } + } // Inline type editing eats text and navigation keys; ⌘-shortcuts still reach the menus. let editing = crate::type_tool::handle_keys(app, ctx); // Registry, UI and menu-catalogue shortcuts (see [`crate::shortcut_dispatch::bindings`]). diff --git a/crates/ui-egui/src/tool_shortcut_tests.rs b/crates/ui-egui/src/tool_shortcut_tests.rs new file mode 100644 index 0000000000..712e7765a2 --- /dev/null +++ b/crates/ui-egui/src/tool_shortcut_tests.rs @@ -0,0 +1,39 @@ +//! Photoshop's tool keys that aren't plain shortcuts: Space+F cycles the pasteboard colour. + +use egui::{Key, Modifiers}; +use serde_json::json; + +use crate::PhotocraftApp; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app +} + +/// Space+F steps the pasteboard colour forward and ⇧Space+F back, round and round; F alone is +/// still Cycle Screen Mode. +#[test] +fn space_f_cycles_the_canvas_colour() { + use photocraft_engine::prefs::CanvasColor; + let mut app = app(); + let ctx = egui::Context::default(); + let frame = |app: &mut PhotocraftApp, events: Vec| { + ctx.begin_pass(egui::RawInput { events, ..Default::default() }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let color = |app: &PhotocraftApp| app.session.prefs().interface.canvas_color; + frame(&mut app, vec![key(Key::Space, true, Modifiers::NONE)]); + frame(&mut app, vec![key(Key::F, true, Modifiers::NONE), key(Key::F, false, Modifiers::NONE)]); + assert_eq!(color(&app), CanvasColor::Black); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::SHIFT), key(Key::F, true, Modifiers::SHIFT), key(Key::F, false, Modifiers::SHIFT)]); + frame(&mut app, vec![key(Key::F, true, Modifiers::SHIFT), key(Key::F, false, Modifiers::SHIFT)]); + assert_eq!(color(&app), CanvasColor::Custom, "backwards past Default wraps round"); + frame(&mut app, vec![egui::Event::ModifiersChanged(Modifiers::NONE), key(Key::Space, false, Modifiers::NONE)]); + let mode = app.ui.view.screen_mode.clone(); + frame(&mut app, vec![key(Key::F, true, Modifiers::NONE), key(Key::F, false, Modifiers::NONE)]); + assert_eq!(color(&app), CanvasColor::Custom, "F alone leaves the canvas colour"); + assert_ne!(app.ui.view.screen_mode, mode, "and cycles the screen mode"); +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index e1e3573e04..28d4d7c56b 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -251,8 +251,9 @@ pub fn pointer_down(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifie commit_with(app, mods); return true; // Photoshop: a click outside commits without starting new text } - // The Type Mask tools always start new text: existing type layers are just pixels to them. - if app.ui.tool.is_type_mask() { + // The Type Mask tools always start new text: existing type layers are just pixels to them; + // so does a ⇧-click with the Type tool, as in Photoshop, to start new text over old. + if app.ui.tool.is_type_mask() || mods.shift { return false; } if let Some(id) = hit_layer(app, x, y) { @@ -2220,6 +2221,22 @@ mod tests { current_text(app, LayerId(ed.layer)).unwrap() } + /// Photoshop: a click on existing type edits it; a ⇧-click there starts a new type layer. + #[test] + fn shift_click_on_existing_type_starts_new_text() { + let mut app = app(); + pointer_up(&mut app, [50.0, 100.0], [50.0, 100.0]); + insert(&mut app, "Existing words"); + commit(&mut app); + let existing = app.session.active().unwrap().doc.layers.last().unwrap().id; + let (x, y) = (60.0, 95.0); + assert_eq!(hit_layer(&mut app, x, y), Some(existing), "the click lands on the existing text"); + assert!(!pointer_down(&mut app, x, y, egui::Modifiers::SHIFT), "⇧: not consumed, so a new type layer starts"); + assert!(app.ui.text_edit.is_none()); + assert!(pointer_down(&mut app, x, y, egui::Modifiers::NONE), "a plain click edits the existing text"); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(existing.0)); + } + #[test] fn click_creates_placeholder_selected_and_typing_replaces_it() { let mut app = app(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3daee822c7..76b59de719 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -184,6 +184,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Shortcut glyphs per platform | done (UI-217-19) | | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | +| Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | ## Panels and workspace @@ -217,3 +218,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | History snapshots: the camera button takes one, snapshot rows return to it as a new state, rename and delete it (#2966) | | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | +| 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | From 73ed08ddbe4ffbc0bc686e7c4acbb5aecbc81fb4 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:28:31 +0200 Subject: [PATCH 129/523] PSD type: read styles from the run's sheets and place text at its Txt2 frame (#3795, #1317) (#3819) * PSD type: resolve paragraph and character styles from the run's own sheets (#3795) apply_engine_data filled a run's missing attributes only from the Normal sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes its alignment or size from a named sheet (by Parent index or Name), or from its paragraph's DefaultStyleSheet, was read with Normal's values, so a centred paragraph style came back Left and starting an edit re-laid the text half a line to the side. Resolve run -> named sheet -> paragraph's default character style -> Normal, ignoring missing or out-of-range references. On export, runs whose sheet reference is rewritten also get the attributes they inherited, so they keep their look whatever they now point to. * PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317) Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a frame-to-text-space matrix and, for point text, the anchor, with EngineData's PointBase left at the origin and BoxBounds in the frame's space. Import ignored them, so box text landed far off the canvas once an edit started (the text 'disappeared') and point text moved up a line and off its alignment anchor. Apply the frame when its origin lands in the TySh bounds (and not already without it, so a TySh PhotoCraft rewrote is not moved twice). Photoshop also keeps a box line whose baseline is inside the box while its descenders hang below it; layout dropped such lines (and some single-line boxes rendered nothing). --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/psd_import.rs | 1 + crates/io/tests/psd_style_sheets.rs | 225 ++++++++++++++++++++++++++++ crates/text/src/layout.rs | 6 +- crates/text/src/psd.rs | 203 +++++++++++++++++++++---- crates/text/src/psd_styles.rs | 27 +++- crates/text/src/tests.rs | 98 ++++++++++++ 6 files changed, 528 insertions(+), 32 deletions(-) create mode 100644 crates/io/tests/psd_style_sheets.rs diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index ffe2d34eaf..af19a0d76e 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -307,6 +307,7 @@ impl Ctx<'_> { }); if let Some(txt2) = &self.txt2 { photocraft_text::psd::apply_txt2(&mut t, &data, txt2); + photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2); } // Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and // other writers leave type layers without image data (Photoshop re-renders them on diff --git a/crates/io/tests/psd_style_sheets.rs b/crates/io/tests/psd_style_sheets.rs new file mode 100644 index 0000000000..0634dd3f9c --- /dev/null +++ b/crates/io/tests/psd_style_sheets.rs @@ -0,0 +1,225 @@ +//! #3795: a PSD type layer's alignment and character attributes can come from its style sheets +//! rather than its runs. A run's `Properties` / `StyleSheetData` hold only its overrides; the rest +//! comes from the paragraph style its `Name` names in `ParagraphSheetSet`, the character style in +//! `StyleSheetSet` (by name, or the paragraph's `DefaultStyleSheet`), then the Normal sheets. +//! Reading only the Normal sheets left a centred paragraph style read as Left, so starting an +//! edit re-laid the text out half a line to the side. + +use photocraft_doc::text::TextAlign; +use photocraft_geom::Affine; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use photocraft_text::engine_data::Value as E; +use photocraft_text::psd::{TySh, text_layer_from_tysh, write_tysh}; + +fn real(v: f64) -> E { + E::Real(v) +} + +fn d(kv: Vec<(&str, E)>) -> E { + E::Dict(kv.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} + +/// Photoshop-shaped EngineData for one paragraph of point text. `para` is the paragraph run's +/// `ParagraphSheet`, `style` the character run's `StyleSheet`; the sheet sets are the +/// `ResourceDict`'s, with Normal at index 0. +fn engine(text: &str, para: E, style: E, para_set: Vec, style_set: Vec) -> E { + let ed_text = format!("{text}\r"); + let n = ed_text.encode_utf16().count() as i64; + let res = d(vec![ + ( + "FontSet", + E::Array(vec![d(vec![("Name", E::String("Inter-Regular".into())), ("Script", E::Int(0)), ("FontType", E::Int(1)), ("Synthetic", E::Int(0))])]), + ), + ("StyleSheetSet", E::Array(style_set)), + ("ParagraphSheetSet", E::Array(para_set)), + ("TheNormalStyleSheet", E::Int(0)), + ("TheNormalParagraphSheet", E::Int(0)), + ]); + d(vec![ + ( + "EngineDict", + d(vec![ + ("Editor", d(vec![("Text", E::String(ed_text))])), + ("ParagraphRun", d(vec![("RunArray", E::Array(vec![d(vec![("ParagraphSheet", para)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])), + ("StyleRun", d(vec![("RunArray", E::Array(vec![d(vec![("StyleSheet", style)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])), + ( + "Rendered", + d(vec![( + "Shapes", + d(vec![( + "Children", + E::Array(vec![d(vec![ + ("ShapeType", E::Int(0)), + ("Cookie", d(vec![("Photoshop", d(vec![("ShapeType", E::Int(0)), ("PointBase", E::Array(vec![real(0.0), real(0.0)]))]))])), + ])]), + )]), + )]), + ), + ]), + ), + ("ResourceDict", res), + ]) +} + +fn normal_char(size: f64) -> E { + d(vec![ + ("Name", E::String("Normal RGB".into())), + ("StyleSheetData", d(vec![("Font", E::Int(0)), ("FontSize", real(size)), ("AutoLeading", E::Bool(true))])), + ]) +} + +fn para_sheet(name: &str, justification: i64, default_style: i64) -> E { + d(vec![("Name", E::String(name.into())), ("DefaultStyleSheet", E::Int(default_style)), ("Properties", d(vec![("Justification", E::Int(justification))]))]) +} + +fn layer(text: &str, ed: &E) -> photocraft_doc::TextLayer { + let desc = Descriptor::new("TxLr") + .with("Txt ", D::Text(UnicodeString::new_nul(text))) + .with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") }) + .with("TextIndex", D::Integer(0)) + .with("EngineData", D::RawData(photocraft_text::engine_data::write(ed))); + let data = write_tysh(&TySh { transform: Affine::translate(450.0, 300.0), text: desc, warp: None, bounds: [0.0; 4] }); + let mut t = text_layer_from_tysh(&data, 72.0).expect("parses"); + // Import keeps the block, as for a layer read from a file. + t.psd_raw = Some(std::sync::Arc::new(data)); + t +} + +/// The paragraph run names a centred (or right-aligned) paragraph style and overrides nothing. +#[test] +fn alignment_from_the_paragraph_style_the_run_names() { + for (j, want) in [(2, TextAlign::Center), (1, TextAlign::Right)] { + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", j, 0)], + vec![normal_char(48.0)], + ); + let t = layer("LOREM IPSUM", &ed); + assert_eq!(t.paragraphs[0].style.align, want, "Justification {j} from the paragraph style"); + // Laid out around the anchor like Photoshop draws it. + let b = photocraft_text::TextEngine::new().layout(&t, 72.0).bounds().unwrap(); + match want { + TextAlign::Center => assert!((b[0] + b[2]).abs() < 1.0, "centred on the anchor: {b:?}"), + _ => assert!(b[2].abs() < 1.0, "ends at the anchor: {b:?}"), + } + } +} + +/// A run's own value still wins over its paragraph style, and an unknown style name or an +/// out-of-range sheet index falls back to Normal. +#[test] +fn run_overrides_win_and_bad_references_fall_back_to_normal() { + let ed = engine( + "Text", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("Justification", E::Int(1))]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 0)], + vec![normal_char(48.0)], + ); + assert_eq!(layer("Text", &ed).paragraphs[0].style.align, TextAlign::Right); + let ed = engine( + "Text", + d(vec![("Name", E::String("Missing".into())), ("DefaultStyleSheet", E::Int(9)), ("Properties", d(vec![]))]), + d(vec![("Name", E::String("Missing".into())), ("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 2, 0)], + vec![normal_char(30.0)], + ); + let t = layer("Text", &ed); + assert_eq!(t.paragraphs[0].style.align, TextAlign::Center, "Normal's alignment"); + assert!((t.runs[0].style.size_pt - 30.0).abs() < 0.01, "Normal's size: {}", t.runs[0].style.size_pt); +} + +/// Character attributes from the paragraph's default character style (`DefaultStyleSheet`) and +/// from a character style the run names. +#[test] +fn character_attributes_from_the_style_sheets() { + let big = d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(72.0))]))]); + let ed = engine( + "Text", + d(vec![("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0), big.clone()], + ); + assert!((layer("Text", &ed).runs[0].style.size_pt - 72.0).abs() < 0.01, "the paragraph's default character style"); + let ed = engine( + "Text", + d(vec![("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]), + d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0), big], + ); + let t = layer("Text", &ed); + assert!((t.runs[0].style.size_pt - 72.0).abs() < 0.01, "the named character style: {}", t.runs[0].style.size_pt); + // Other attributes still come from Normal. + assert_eq!( + t.runs[0].style.font_family, + layer( + "Text", + &engine( + "Text", + d(vec![("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0)] + ) + ) + .runs[0] + .style + .font_family + ); +} + +/// Export writes every run fully resolved and keeps the sheet sets: a round trip reads the same +/// alignment and size, with one entry per sheet. +#[test] +fn styles_from_sheets_survive_a_round_trip() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)], + vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])], + ); + let mut t = layer("LOREM IPSUM", &ed); + let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 72.0; + photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(t))); + let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("t.psd", &bytes).unwrap().document; + let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") }; + assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center); + assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt); +} + +/// A layer that inherits its alignment and size from sheets it no longer links to (a document +/// without those styles) keeps them through export: runs are written with what they inherited. +#[test] +fn unlinked_runs_keep_their_inherited_style_on_export() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)], + vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])], + ); + let mut t = layer("LOREM IPSUM", &ed); + assert!(t.psd_raw.is_some()); + let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 72.0; + photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(t))); + // No `text_styles_map::import`: the document has no styles and the runs link to none. + let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("t.psd", &bytes).unwrap().document; + let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") }; + assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center); + assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f7494bd992..7fdbb8a01f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -702,7 +702,11 @@ impl Layouter { Some(b) => b + leading + pending_space, }; pending_space = 0.0; - let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline + descent > box_rect.1 + box_rect.3 + 0.5 }; + // Photoshop keeps a horizontal line whose baseline is inside the box and lets + // its descenders hang below the bottom edge (#1317: three 35 pt lines in a + // 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped + // that line. + let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline > box_rect.1 + box_rect.3 + 0.5 }; if is_box && overflow { stop = true; break; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index b1359f31bb..27b1909510 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -12,7 +12,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp}; -use photocraft_geom::Affine; +use photocraft_geom::{Affine, Point}; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; use crate::engine_data::{self as ed, Value as E}; @@ -212,6 +212,83 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { layer.sync_summary(); } +/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it. +fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { + let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?; + let frame = + object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?; + txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0") +} + +/// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved +/// (#1317). Recent Photoshop keeps a type layer's position partly in its `Txt2` frame: a matrix +/// (`/2 /2`) from the frame's space to the `TySh` text space and, for point text, the anchor +/// (`/0`), while EngineData's `PointBase` stays at the origin and `BoxBounds` stays in the +/// frame's space. Ignoring them put the text far from Photoshop's pixels (off the canvas, so +/// it "disappeared" as soon as an edit started), and point text a line too high and off its +/// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as +/// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`. +pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { + let Some(t) = parse_tysh(tysh) else { return }; + let Some(index) = (match t.text.get("TextIndex") { + Some(D::Integer(i)) => usize::try_from(*i).ok(), + _ => None, + }) else { + return; + }; + let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str); + if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) { + return; + } + let Some(frame) = txt2_frame(txt2, index) else { return }; + let m = arr_f(frame.path(&["2", "2"])); + let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; + let at = match layer.shape { + TextShape::Point => { + let p = arr_f(frame.get("0")); + if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } + } + TextShape::Box { .. } => Affine::IDENTITY, + }; + let to_text = matrix.mul(&at); + if to_text == Affine::IDENTITY || to_text.determinant().abs() <= 1e-9 { + return; + } + // The frame's origin (point text: the anchor; box text: the box's top left) must land in + // the text-space `bounds` Photoshop wrote beside it, and not already be there without it. A `TySh` PhotoCraft rewrote (an edited + // or moved layer whose text is unchanged keeps its old `Txt2` object and frame) already has + // the frame folded into its transform and its `bounds` near the origin: applying the frame + // again would move it twice. + let origin = match layer.shape { + TextShape::Point => Point::new(0.0, 0.0), + TextShape::Box { .. } => { + let b = engine_data(&t.text) + .map(|e| { + arr_f( + e.path(&["EngineDict", "Rendered", "Shapes", "Children"]) + .and_then(E::as_array) + .and_then(|c| c.first()) + .and_then(|s| s.path(&["Cookie", "Photoshop", "BoxBounds"])), + ) + }) + .unwrap_or_default(); + let [x, y, ..] = b[..] else { return }; + Point::new(x, y) + } + }; + let Some([l, top, r, bottom]) = descriptor_rect(&t.text, "bounds") else { return }; + let inside = |p: Point| p.x >= l - 1.0 && p.x <= r + 1.0 && p.y >= top - 1.0 && p.y <= bottom + 1.0; + if inside(origin) || !inside(to_text.apply(origin)) { + return; + } + { + // From the file's own transform: the frame's anchor is exact, so it replaces the + // ink-box estimate `legacy_point_origin` made for such point text (#1469), which put + // the baseline at the ink's top and the anchor at the ink's left whatever the alignment. + layer.transform = t.transform.mul(&to_text); + } +} + /// OpenType settings of a `Txt2` style that are on/off flags, by style key, as Photoshop /// writes them (each turned on alone in the Character panel's OpenType settings and read back): /// `/20` Contextual Alternates, `/23` Fractions, `/24` Ordinals, `/25` Swash, `/26` Titling @@ -380,46 +457,97 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .and_then(E::as_array) .map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()) .unwrap_or_default(); - let default_sheet = res.and_then(|r| r.get("TheNormalStyleSheet")).and_then(E::as_i64).unwrap_or(0) as usize; - let base_style = - res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).and_then(|a| a.get(default_sheet).or(a.first())).and_then(|s| s.get("StyleSheetData")); - let default_para_sheet = res.and_then(|r| r.get("TheNormalParagraphSheet")).and_then(E::as_i64).unwrap_or(0) as usize; - let base_para = res - .and_then(|r| r.get("ParagraphSheetSet")) - .and_then(E::as_array) - .and_then(|a| a.get(default_para_sheet).or(a.first())) - .and_then(|s| s.get("Properties")); + // Style resolution (#3795). A run's own `StyleSheetData` / `Properties` hold only what it + // overrides; the rest comes from its sheets, as in Photoshop: + // - a paragraph run: its `Properties` → the `ParagraphSheetSet` entry its `Name` names (a + // paragraph style; or its `Parent` index) → `TheNormalParagraphSheet`; + // - a character run: its `StyleSheetData` → the `StyleSheetSet` entry its `Parent` or `Name` + // names (a character style) → the default character style of its paragraph (the paragraph run's + // `DefaultStyleSheet`, else its paragraph style's) → `TheNormalStyleSheet`. + // Missing or out-of-range indices fall through to the next level, then to the defaults. + let style_set: &[E] = res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).unwrap_or(&[]); + let para_set: &[E] = res.and_then(|r| r.get("ParagraphSheetSet")).and_then(E::as_array).unwrap_or(&[]); + let index = |key: &str| res.and_then(|r| r.get(key)).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()); + let normal_style = index("TheNormalStyleSheet").and_then(|i| style_set.get(i)).or(style_set.first()); + let normal_para = index("TheNormalParagraphSheet").and_then(|i| para_set.get(i)).or(para_set.first()); + // A run names its sheet by a `Parent` index or by `Name` (see `psd_styles::reference`). + let named = |set: &'_ [E], sheet: &E| -> Option { + if let Some(i) = sheet.get("Parent").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < set.len()) { + return Some(i); + } + let name = sheet.get("Name").and_then(E::as_str)?; + set.iter().position(|s| s.get("Name").and_then(E::as_str) == Some(name)) + }; + + // Paragraph runs first: a character run's defaults depend on its paragraph. + let prun = e.path(&["EngineDict", "ParagraphRun"]); + let psheets: Vec<&E> = + prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default(); + let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray"))); + let plens = utf16_to_byte_lengths(&ed_text, &plens16); + let empty = E::Dict(Vec::new()); + // Per paragraph run: its merged base properties and its default character style. + let para_bases: Vec<(E, Option<&E>)> = psheets + .iter() + .map(|sheet| { + let style = named(para_set, sheet).and_then(|i| para_set.get(i)); + let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]); + let char_default = [Some(*sheet), style] + .into_iter() + .flatten() + .find_map(|s| s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| style_set.get(i))); + (base, char_default) + }) + .collect(); + layer.paragraphs = psheets + .iter() + .zip(¶_bases) + .zip(plens) + .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) }) + .collect(); // Character runs. let srun = e.path(&["EngineDict", "StyleRun"]); - let sdata: Vec<&E> = srun - .and_then(|r| r.get("RunArray")) - .and_then(E::as_array) - .map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect()) - .unwrap_or_default(); - let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray")))); + let ssheets: Vec<&E> = + srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default(); + let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray"))); + let slens = utf16_to_byte_lengths(&ed_text, &slens16); + // The paragraph run each character run starts in (UTF-16 offsets). + let para_of = |at: f64| -> Option { + let mut end = 0.0; + for (i, n) in plens16.iter().enumerate() { + end += n; + if at < end { + return Some(i); + } + } + plens16.len().checked_sub(1) + }; + let mut at16 = 0.0; + let bases: Vec = ssheets + .iter() + .enumerate() + .map(|(i, sheet)| { + let own = named(style_set, sheet).and_then(|i| style_set.get(i)); + let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c); + at16 += slens16.get(i).copied().unwrap_or(0.0); + merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))) + }) + .collect(); + let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect(); let incoming: Vec<(usize, bool, f32)> = sdata .iter() + .zip(&bases) .zip(&slens) - .map(|(d, len)| { - let auto = lookup(d, base_style, "AutoKerning").and_then(E::as_bool) != Some(false); - let kern = lookup(d, base_style, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + .map(|((d, base), len)| { + let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false); + let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; (*len, auto, kern) }) .collect(); - layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect(); + layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect(); layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming); - // Paragraph runs. - let prun = e.path(&["EngineDict", "ParagraphRun"]); - let pdata: Vec<&E> = prun - .and_then(|r| r.get("RunArray")) - .and_then(E::as_array) - .map(|a| a.iter().filter_map(|x| x.path(&["ParagraphSheet", "Properties"])).collect()) - .unwrap_or_default(); - let plens = utf16_to_byte_lengths(&ed_text, &arr_f(prun.and_then(|r| r.get("RunLengthArray")))); - layer.paragraphs = pdata.iter().zip(plens).map(|(d, len)| ParagraphRun { len, style: para_style(base_para, d, k) }).collect(); - // Shape. let shape = e.path(&["EngineDict", "Rendered", "Shapes", "Children"]).and_then(E::as_array).and_then(|a| a.first()); if let Some(sh) = shape @@ -432,6 +560,21 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { } } +/// The dictionaries in `levels` as one: a key comes from the first level that has it. +fn merged(levels: &[Option<&E>]) -> E { + let mut out: Vec<(String, E)> = Vec::new(); + for level in levels.iter().flatten() { + if let E::Dict(kv) = level { + for (k, v) in kv { + if !out.iter().any(|(o, _)| o == k) { + out.push((k.clone(), v.clone())); + } + } + } + } + E::Dict(out) +} + fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { run.get(key).or_else(|| base.and_then(|b| b.get(key))) } diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index fb3b297fdc..4caba32115 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -55,6 +55,20 @@ fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option Option { let t = parse_tysh(tysh)?; @@ -170,6 +184,7 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d rd.set("TheNormalParagraphSheet", E::Int(0)); rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect())); } + let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default(); let char_ix = |id: Option| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); let para_ix = |id: Option| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); let runs = layer.char_runs(); @@ -193,9 +208,16 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d if let Some(sheet) = r.get_mut("StyleSheet") { remove_key(sheet, "Parent"); remove_key(sheet, "Name"); - if let Some(ix) = char_ix(run_at(start).and_then(|r| r.style.style_sheet)) { + let run = run_at(start); + if let Some(ix) = char_ix(run.and_then(|r| r.style.style_sheet)) { sheet.set("Parent", E::Int(ix)); } + // The run's reference may be dropped or renumbered: write what it inherited + // too, so it keeps its look whatever sheet it now points to (#3795). + if let Some(run) = run { + let fi = char_fonts.iter().position(|f| *f == postscript_for(&run.style)).unwrap_or(0); + fill_missing(sheet, "StyleSheetData", style_sheet_data(&run.style, fi, k)); + } } } } @@ -208,6 +230,9 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d if let Some(ix) = para_ix(paras.get(i).and_then(|p| p.style.style_sheet)) { sheet.set("Parent", E::Int(ix)); } + if let Some(p) = paras.get(i) { + fill_missing(sheet, "Properties", paragraph_properties(&p.style, k)); + } } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index dc8df9e5e3..ae3092ca46 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1580,6 +1580,104 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::>()); } +/// #1317: Photoshop keeps the last line of a text box whose baseline is inside the box even +/// when its descenders hang below the bottom edge (three 35 pt lines of 25 pt type in a +/// 91.3 pt box). A line whose baseline falls outside the box is still dropped. +#[test] +fn box_lines_whose_descent_hangs_below_the_box_are_kept() { + let mut e = TextEngine::new(); + let mut t = styled("one\ntwo\nthree", CharStyle { size_pt: 25.0, leading_pt: Some(35.0), ..Default::default() }); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 91.27 }; + let l = e.layout(&t, 72.0); + assert_eq!(l.lines.len(), 3, "{:?}", l.lines.iter().map(|l| l.baseline).collect::>()); + assert!(l.lines[2].baseline <= 91.27 + 0.5); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 80.0 }; + assert_eq!(e.layout(&t, 72.0).lines.len(), 2, "the third baseline (about 88) is below an 80 pt box"); +} + +/// `Txt2` with one text object for `text` whose rendered frame is frame 0, and that frame: +/// for point text an anchor `point`, and a frame-to-text matrix `matrix` (recent Photoshop, +/// #1317). +fn txt2_with_frame(text: &str, point: Option<[f64; 2]>, matrix: [f64; 6]) -> Vec { + let mut v = txt2_with_modes(text, &[(text.encode_utf16().count() + 1, 1)]); + // Close the object's model early: `… >> >> ] >>` → `… >> /1 << /2 [ frame ] >> >> ] >>`. + let tail = b"] >> >> >> ] >>"; + assert!(v.ends_with(tail)); + v.truncate(v.len() - tail.len()); + v.extend_from_slice(b"] >> >> /1 << /2 [ << /6 [ << /10 0 >> ] >> ] >> >> ] >>"); + let pt = point.map(|[x, y]| format!("/0 [ {x:.5} {y:.5} ] ")).unwrap_or_default(); + let m = matrix.map(|x| format!("{x:.5}")).join(" "); + let frames = format!("/0 << /8 << /0 [ << /0 << {pt}/2 << /2 [ {m} ] >> >> >> ] >> >>"); + let at = v.windows(9).position(|w| w == b"/0 << >> ").unwrap(); + v.splice(at..at + 9, frames.into_bytes().into_iter().chain(*b" ")); + v +} + +/// #1317: point text whose anchor Photoshop keeps only in its `Txt2` frame (EngineData's +/// `PointBase` at the origin, the transform far away) is placed at that anchor, not at the +/// ink's top left. A rewritten `TySh` (bounds near the origin) is left alone. +#[test] +fn txt2_frame_places_point_text_at_its_anchor() { + let mut t = point("2", 100.0); + t.transform = Affine::translate(-1000.0, -2000.0); + // Photoshop's text space: anchor at (1100, 2050), ink above its baseline. + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("2", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + let o = l.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); + assert!((o.x - 100.0).abs() < 1e-3 && (o.y - 50.0).abs() < 1e-3, "anchor at {o:?}"); + // Already folded in (bounds near the origin): unchanged; another text: unchanged. + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([0.0, -70.0, 60.0, 0.0])), 0); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + assert_eq!(l.transform, before); + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); + let other = crate::psd::parse_txt2(&txt2_with_frame("3", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &tysh, &other); + assert_eq!(l.transform, before); +} + +/// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text). +fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec { + use photocraft_psd::descriptor::{Descriptor, Id, Value}; + let mut t = crate::psd::parse_tysh(tysh).unwrap(); + let mut d = Descriptor::default(); + for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) { + d.items.push((Id::new(k), Value::Double(v))); + } + t.text.items.retain(|(k, _)| !k.is("bounds")); + t.text.items.push((Id::new("bounds"), Value::Descriptor(d))); + crate::psd::write_tysh(&t) +} + +/// #1317: a text box whose `BoxBounds` are in its `Txt2` frame's space is moved by the frame's +/// matrix onto the `bounds` Photoshop drew it at (it used to land off the canvas, so its text +/// "disappeared" when an edit started). +#[test] +fn txt2_frame_moves_box_text_onto_its_bounds() { + let mut t = styled("Casemiro 45", CharStyle { size_pt: 25.0, ..Default::default() }); + t.shape = TextShape::Box { x: 500.0, y: 600.0, width: 300.0, height: 90.0 }; + t.transform = Affine::translate(-700.0, -300.0); + let built = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + let tysh = with_bounds(&built, [960.0, 300.0, 1260.0, 390.0]); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Casemiro 45", None, [1.0, 0.0, 0.0, 1.0, 460.0, -300.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + assert!(matches!(l.shape, TextShape::Box { .. })); + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + let TextShape::Box { x, y, .. } = l.shape else { panic!("not a box") }; + let o = l.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(y))); + assert!((o.x - 260.0).abs() < 1e-3 && (o.y - 0.0).abs() < 1e-3, "box top left at {o:?}"); + // PhotoCraft's own `TySh` (bounds = the box, frame already folded in): unchanged. + let mut l = crate::psd::text_layer_from_tysh(&built, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &built, &txt2); + assert_eq!(l.transform, before); +} + /// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the /// fallback stack, so a face without the glyph shows the missing-glyph box instead of a /// fallback's glyph (#1909). From 76fa06dbd5d5bf1cb40d6cf8f9d8b8c38eab6c5e Mon Sep 17 00:00:00 2001 From: Derek Gliwa Date: Mon, 12 Oct 2026 08:28:37 -0500 Subject: [PATCH 130/523] Merged and stamped layers keep their content bounds in CMYK and Lab (#3886) Merging or stamping converts the straight-RGBA composite into the document's colour model. Transparent black converts to K = 100% in CMYK and neutral a/b in Lab, so those pixels no longer equal the surface's default pixel, prune() keeps them, and the layer's content bounds span everything that was rendered: the whole canvas for Stamp Visible, Stamp Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down whenever an input may draw anywhere (a fill layer, even a masked one). Fully transparent pixels are now written as the default pixel in both conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy Merged) and engine pixels::composite_layers (merges). Only formats with alpha are affected; over a Background everything stays opaque. Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over a transparent document, with a masked fill layer and onto a Background, Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab at 8/16 bit, and a compose unit test for flatten_to_surface. The masked fill layer test fails on main (merged layer = full canvas in CMYK). Co-authored-by: dgliwa <2328690+dgliwa@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/lib.rs | 12 +- crates/compose/src/tests.rs | 28 ++++ .../engine/tests/merge_transparent_bounds.rs | 146 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/merge_transparent_bounds.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 08dcdeaf6a..cb88d002d9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -233,6 +233,11 @@ pub fn band_rows_for(width: u32, requested: i32) -> i32 { /// The document's composite as a pixel surface in `fmt` (straight RGBA converted to its model /// and depth), optionally flattened over an opaque `background`; rendered and written in bands, /// so no full-size float composite is held. Tiles equal to the default pixel are pruned. +/// +/// Fully transparent pixels are written as the default pixel. Converting transparent black +/// leaves colour behind in CMYK (K = 100%) and Lab (neutral a/b = 0.5), and those pixels would +/// otherwise count as content: the result would span the whole canvas. +/// /// A CMYK document of plain Normal layers is composited in its ink values instead /// ([`native::flatten_cmyk`]), so flattening doesn't round-trip its pixels through RGB (#3778). pub fn flatten_to_surface(doc: &Document, fmt: photocraft_color::PixelFormat, background: Option<[f32; 3]>) -> Surface { @@ -249,6 +254,7 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P } let mut s = Surface::new(fmt); let n = fmt.channels(); + let empty = s.default_pixel(); let mut data = Vec::new(); // The composite separates back to CMYK through the document's own profile, the one its // pixels were read through. @@ -262,7 +268,11 @@ pub fn composite_to_surface(doc: &Document, rect: Rect, fmt: photocraft_color::P data.resize(band.px.len() * n, 0.0); photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { for (p, out) in band.px.iter().zip(data.chunks_exact_mut(n)) { - photocraft_raster::from_rgba_into(&fmt, *p, out); + if fmt.alpha && (p[3] <= 0.0 || p[3].is_nan()) && empty.len() == n { + out.copy_from_slice(&empty); + } else { + photocraft_raster::from_rgba_into(&fmt, *p, out); + } } }); s.write_region(band.rect, &data); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 869c316dd7..aca7a10c92 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -604,6 +604,34 @@ fn cmyk_document_renders_via_rgb() { assert!(p[0] < 0.3 && (p[0] - p[1]).abs() < 0.05 && (p[1] - p[2]).abs() < 0.05 && p[3] == 1.0, "{p:?}"); } +/// Transparent pixels convert to colour in CMYK (K = 100%) and Lab (a/b = 0.5). A flattened +/// surface must still treat them as empty, or its content spans the whole canvas. +#[test] +fn flatten_to_surface_leaves_transparent_pixels_empty_in_every_mode() { + let modes = [ + (ColorMode::Rgb, [SampleType::U8, SampleType::U16, SampleType::F32].as_slice()), + (ColorMode::Grayscale, &[SampleType::U8, SampleType::U16, SampleType::F32]), + (ColorMode::Cmyk, &[SampleType::U8, SampleType::U16]), + (ColorMode::Lab, &[SampleType::U8, SampleType::U16]), + ]; + for (mode, samples) in modes { + for &sample in samples { + let mut d = Document::new("t", Size::new(300, 200), mode, sample); + let fmt = PixelFormat::new(mode, sample, true); + let mut l = Layer::raster("dot", fmt); + let opaque: Vec = photocraft_raster::from_rgba(&fmt, [0.9, 0.4, 0.1, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(120, 80, 140, 90), &opaque); + d.layers.push(l); + let s = flatten_to_surface(&d, fmt, None); + assert_eq!(s.content_bounds(), Rect::new(120, 80, 140, 90), "{mode:?} {sample:?}"); + // Over a background everything is content, as before. + let fmt_opaque = PixelFormat::new(mode, sample, false); + let s = flatten_to_surface(&d, fmt_opaque, Some([1.0, 1.0, 1.0])); + assert_eq!(s.rgba(5, 5)[3], 1.0, "{mode:?} {sample:?}: background stays opaque"); + } + } +} + #[test] fn sixteen_bit_and_float_layers_composite() { for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F] { diff --git a/crates/engine/tests/merge_transparent_bounds.rs b/crates/engine/tests/merge_transparent_bounds.rs new file mode 100644 index 0000000000..c796007233 --- /dev/null +++ b/crates/engine/tests/merge_transparent_bounds.rs @@ -0,0 +1,146 @@ +//! Merging small layers gives a layer as big as their content in every colour mode. In CMYK and +//! Lab, transparent pixels used to keep the colour of converted transparent black, so Merge +//! Layers, Merge Visible, Stamp Visible and Stamp Down made layers that spanned the whole canvas: +//! Free Transform, the Move tool and alignment then worked on the canvas instead of the content. + +use photocraft_doc::{Layer, LayerId}; +use photocraft_engine::Session; +use photocraft_engine::transform_cmds::transform_bounds; +use photocraft_geom::Rect; +use serde_json::{Value, json}; + +const W: u32 = 300; +const H: u32 = 400; +/// Two small shapes far from the canvas edges, and the box around both. +const A: [i32; 4] = [40, 300, 20, 30]; +const B: [i32; 4] = [90, 320, 25, 20]; +const BOTH: Rect = Rect { x0: 40, y0: 300, x1: 115, y1: 340 }; + +/// Every mode at the depths it supports (Photoshop has no 32-bit CMYK or Lab). +const CASES: &[(&str, u32)] = + &[("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("gray", 16), ("gray", 32), ("cmyk", 8), ("cmyk", 16), ("lab", 8), ("lab", 16)]; + +fn id_of(r: &Value) -> LayerId { + LayerId(r["layer"].as_u64().unwrap()) +} + +/// A transparent document with the two shapes on their own layers. +fn scene(mode: &str, depth: u32) -> (Session, LayerId, LayerId) { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "transparent"})).unwrap(); + let a = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap()); + let b = id_of(&s.execute("shape.create", json!({"kind": "rect", "rect": B, "fill": "#3060c0"})).unwrap()); + (s, a, b) +} + +fn select(s: &mut Session, ids: &[LayerId]) { + s.execute("layer.select", json!({"layer": ids[0].0})).unwrap(); + for id in &ids[1..] { + s.execute("layer.select", json!({"layer": id.0, "mode": "add"})).unwrap(); + } +} + +fn active(s: &Session) -> &Layer { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap() +} + +/// The merged layer hugs the shapes (anti-aliased edges may add a pixel) and still shows them. +fn assert_tight(s: &Session, what: &str) { + let doc = &s.active().unwrap().doc; + let layer = active(s); + let b = transform_bounds(doc, layer); + let grown = Rect::new(BOTH.x0 - 1, BOTH.y0 - 1, BOTH.x1 + 1, BOTH.y1 + 1); + assert!(grown.contains_rect(&b) && b.contains_rect(&Rect::new(BOTH.x0 + 1, BOTH.y0 + 1, BOTH.x1 - 1, BOTH.y1 - 1)), "{what}: bounds {b:?}, want ~{BOTH:?}"); + let surf = layer.surface().unwrap(); + // Inside each shape: opaque. Between them and far away: transparent. + assert!(surf.rgba(A[0] + 5, A[1] + 5)[3] > 0.99, "{what}: first shape kept"); + assert!(surf.rgba(B[0] + 5, B[1] + 5)[3] > 0.99, "{what}: second shape kept"); + assert!(surf.rgba(70, 310)[3] < 0.01, "{what}: gap stays transparent"); + assert!(surf.rgba(5, 5)[3] < 0.01, "{what}: corner stays transparent"); +} + +#[test] +fn merge_layers_keeps_content_bounds_in_every_mode() { + for &(mode, depth) in CASES { + let (mut s, a, b) = scene(mode, depth); + select(&mut s, &[a, b]); + s.execute("layer.mergeLayers", json!({})).unwrap(); + assert_tight(&s, &format!("Merge Layers {mode} {depth}")); + } +} + +#[test] +fn merge_visible_keeps_content_bounds_in_every_mode() { + for &(mode, depth) in CASES { + let (mut s, _, _) = scene(mode, depth); + s.execute("layer.mergeVisible", json!({})).unwrap(); + assert_tight(&s, &format!("Merge Visible {mode} {depth}")); + } +} + +#[test] +fn stamps_keep_content_bounds_in_every_mode() { + for &(mode, depth) in CASES { + let (mut s, _, _) = scene(mode, depth); + s.execute("layer.stampVisible", json!({})).unwrap(); + assert_tight(&s, &format!("Stamp Visible {mode} {depth}")); + + let (mut s, a, b) = scene(mode, depth); + select(&mut s, &[a, b]); + s.execute("layer.stampDown", json!({})).unwrap(); + assert_tight(&s, &format!("Stamp Down {mode} {depth}")); + } +} + +/// A fill layer may draw anywhere, so merging one renders the whole canvas; its mask still +/// limits the merged layer to the shapes. +#[test] +fn merging_a_masked_fill_layer_keeps_content_bounds_in_every_mode() { + let masked_scene = |mode: &str, depth: u32| { + 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 { + let (mut s, a, fill) = masked_scene(mode, depth); + select(&mut s, &[a, fill]); + s.execute("layer.mergeLayers", json!({})).unwrap(); + assert_tight(&s, &format!("Merge Layers with a fill layer {mode} {depth}")); + + let (mut s, _, _) = masked_scene(mode, depth); + s.execute("layer.mergeVisible", json!({})).unwrap(); + assert_tight(&s, &format!("Merge Visible with a fill layer {mode} {depth}")); + } +} + +/// Over an opaque Background, Merge Visible still makes an opaque, canvas-sized Background. +#[test] +fn merge_visible_onto_a_background_stays_opaque() { + for &(mode, depth) in CASES { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth, "background": "white"})).unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": A, "fill": "#e07020"})).unwrap(); + s.execute("layer.mergeVisible", json!({})).unwrap(); + let what = format!("{mode} {depth}"); + let doc = &s.active().unwrap().doc; + let layer = active(&s); + assert_eq!(layer.name, "Background", "{what}"); + assert_eq!(transform_bounds(doc, layer), Rect::new(0, 0, W as i32, H as i32), "{what}"); + let white = layer.surface().unwrap().rgba(5, 5); + assert!(white.iter().all(|c| *c > 0.98), "{what}: Background stays white, got {white:?}"); + } +} + +/// Undo restores both layers after the fixed merge. +#[test] +fn merge_layers_in_cmyk_is_one_undo_step() { + let (mut s, a, b) = scene("cmyk", 8); + select(&mut s, &[a, b]); + s.execute("layer.mergeLayers", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(doc.layer(a).is_some() && doc.layer(b).is_some()); +} From 4010630c98084b9e296dbc845c477a0bfeb662ef Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 21:32:37 +0800 Subject: [PATCH 131/523] Preserve surviving channel targets and visibility on deletion (#3887) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/engine/src/channel_cmds.rs | 4 ++ crates/engine/src/channel_cmds/tests.rs | 50 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 39ce9c8250..63f8f5b626 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -1126,11 +1126,15 @@ fn delete_channel(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(cmd, "missing `channel`")), }, }; + // `edit` validates the view against the shorter channel list before we can remap it. + // Keep the original indices and eyes so a surviving final channel is not discarded. + let view = s.active().ok_or(EngineError::NoDocument)?.channel_view.clone(); s.edit("Delete Channel", |doc, _| { doc.channels.remove(i); Ok(()) })?; if let Some(st) = s.active_mut() { + st.channel_view = view; let v = &mut st.channel_view; if i < v.alpha_visible.len() { v.alpha_visible.remove(i); diff --git a/crates/engine/src/channel_cmds/tests.rs b/crates/engine/src/channel_cmds/tests.rs index 17727dc8ad..d39dda70b7 100644 --- a/crates/engine/src/channel_cmds/tests.rs +++ b/crates/engine/src/channel_cmds/tests.rs @@ -168,6 +168,56 @@ fn channel_management_and_view_state() { assert_eq!(doc(&s).channels.len(), 3); } +#[test] +fn deleting_an_earlier_channel_keeps_the_last_channel_targeted() { + for depth in [8, 16, 32] { + let mut s = session_depth(depth); + s.execute("channel.new", json!({"name": "Remove", "fill": "black"})).unwrap(); + s.execute("channel.new", json!({"name": "Keep", "fill": "white"})).unwrap(); + let layer_before = px(&s, 5, 5); + s.execute("channel.delete", json!({"channel": "Remove"})).unwrap(); + assert_eq!(doc(&s).channels[0].name, "Keep"); + let view = &s.active().unwrap().channel_view; + assert_eq!(view.target, ChannelTarget::Alpha(0), "the surviving target follows its new index"); + assert!(view.alpha_shown(0)); + assert_eq!(view.visible_colors(3), 0, "keep the channel-only view"); + + s.execute("image.adjustments.invert", json!({})).unwrap(); + assert_eq!(chan(&s, 0, 5, 5), 0.0, "implicit pixel edits still target Keep"); + assert_eq!(px(&s, 5, 5), layer_before, "the layer must not be edited instead"); + assert!(s.undo()); + assert_eq!(chan(&s, 0, 5, 5), 1.0); + assert_eq!(px(&s, 5, 5), layer_before); + assert!(s.redo()); + assert_eq!(chan(&s, 0, 5, 5), 0.0); + assert_eq!(px(&s, 5, 5), layer_before); + } +} + +#[test] +fn deleting_a_channel_remaps_all_surviving_eyes_and_targets() { + for target_index in 0..3 { + let mut s = session(); + for name in ["First", "Middle", "Last"] { + s.execute("channel.new", json!({"name": name})).unwrap(); + } + s.execute("channel.target", json!({"channel": target_index})).unwrap(); + for (channel, visible) in [false, false, true].into_iter().enumerate() { + s.execute("channel.setVisible", json!({"channel": channel, "visible": visible})).unwrap(); + } + s.execute("channel.delete", json!({"channel": "Middle"})).unwrap(); + let view = &s.active().unwrap().channel_view; + let expected = match target_index { + 0 => ChannelTarget::Alpha(0), + 1 => ChannelTarget::Composite, + _ => ChannelTarget::Alpha(1), + }; + assert_eq!(view.target, expected, "old target {target_index}"); + assert!(!view.alpha_shown(0)); + assert!(view.alpha_shown(1), "Last keeps its eye when Middle is removed"); + } +} + #[test] fn every_colour_channel_can_hide_while_alpha_stays() { let mut s = session(); From 7958dc63fb8143bdb208d96d7d641f392d65e321 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:42:58 +0200 Subject: [PATCH 132/523] Layers: Merge Group for a selected group (#3832) (#3888) * Layers: Merge Group for a selected group (#3832) With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the Layers panel's right-click menu offers Merge Group instead of Merge Down, as in Photoshop. The new layer.mergeGroup command flattens the group's visible contents into one pixel layer in its place, named after the group, keeping its opacity, blend mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran Merge Down and merged the whole group into the layer below it. * i18n: Vietnamese row for Merge Group Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/commands.rs | 38 +++++++++++++++++++++++++++ crates/engine/src/layer_multi_cmds.rs | 6 +++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/layer_menu_ui.rs | 28 ++++++++++++++++++++ crates/ui-egui/src/menus.rs | 8 ++++++ 22 files changed, 135 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index a47bde9fff..5524388c71 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -782,6 +782,13 @@ fn build() -> Vec { })?; Ok(Value::Null) }), + cmd!("layer.mergeGroup", "Merge Group", ["Layer"], None, r##"{"layer":id?}"##, has_layer, |s, p| { + let id = layer_param(s, p)?; + if !s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) { + return Err(EngineError::Other("Merge Group needs a group".into())); + } + merge_group(s, id) + }), cmd!("layer.flattenImage", "Flatten Image", ["Layer"], None, "{}", has_doc, |s, _| { s.edit("Flatten Image", |doc, active| { let fmt = doc.pixel_format(); @@ -1427,6 +1434,37 @@ pub(crate) fn layer_copy(doc: &Document, id: LayerId) -> Result { Ok(dup) } +/// Layer › Merge Group (⌘E with a group selected, #3832): the group's visible contents, as they +/// show, become one pixel layer in its place, named after it. Like Photoshop, the layer keeps the +/// group's opacity, blend mode (Pass Through becomes Normal), clipping and locks; the group's mask +/// and style are applied to the pixels. +fn merge_group(s: &mut Session, id: LayerId) -> Result { + s.edit("Merge Group", |doc, active| { + let group = doc.layer(id).ok_or(EngineError::NoLayer(id))?.clone(); + if !group.visible { + return Err(EngineError::Other("the group is hidden".into())); + } + let fmt = doc.pixel_format(); + let fmt = photocraft_color::PixelFormat::new(fmt.mode, fmt.sample, true); + let mut solo = group.clone(); + solo.opacity = 1.0; + solo.blend = photocraft_doc::BlendMode::Normal; + solo.clipped = false; + let mut tmp = doc.clone(); + tmp.layers = vec![solo]; + let mut merged = Layer::new(group.name.clone(), LayerContent::Raster(pixels::composite_layers(&tmp, fmt, None))); + merged.id = group.id; + merged.opacity = group.opacity; + merged.blend = if group.blend == photocraft_doc::BlendMode::PassThrough { photocraft_doc::BlendMode::Normal } else { group.blend }; + merged.clipped = group.clipped; + merged.locks = group.locks; + *doc.layer_mut(id).ok_or(EngineError::NoLayer(id))? = merged; + *active = Some(id); + Ok(()) + })?; + Ok(json!({ "layer": id.0 })) +} + #[cfg(test)] mod registry_tests { use super::*; diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 2d51dfdf0c..db96c3c1b4 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -1217,6 +1217,12 @@ pub fn group_layers(s: &mut Session, p: &Value) -> Result { fn merge_layers(s: &mut Session) -> Result { let sel = selected(s); if sel.len() < 2 { + // ⌘E on a lone group is Photoshop's Merge Group (#3832); on a layer, Merge Down. + if let Some(&id) = sel.first() + && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Group(_))) + { + return s.execute("layer.mergeGroup", json!({ "layer": id.0 })); + } return s.execute("layer.mergeDown", json!({})); } let mid = s.edit("Merge Layers", |doc, active| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a35a19bb43..08e17bd39c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1469,3 +1469,41 @@ fn delete_layer_preserves_an_unaffected_active_layer_and_last_layer_guard() { assert_eq!(s.active().unwrap().active_layer, Some(other)); assert_eq!(s.active().unwrap().history.past_len(), before); } + +#[test] +fn merge_down_on_a_group_merges_the_group() { + // #3832: with a group selected, Merge Down is Photoshop's Merge Group (⌘E): the group's + // contents become one layer in its place, named after it, and the layer below is untouched. + let mut s = session_with_doc(); + let below = s.execute("layer.new.layer", json!({"name": "Below"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("edit.fill", json!({"color": "#00ff00"})).unwrap(); + let a = s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let b = s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 0, "width": 10, "height": 48})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.select", json!({"layer": b})).unwrap(); + s.execute("layer.select", json!({"layer": a, "mode": "add"})).unwrap(); + let group = s.execute("layer.groupLayers", json!({"name": "Badge"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": group, "opacity": 0.5})).ok(); + let before = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)]; + let layers = s.active().unwrap().doc.layer_count(); + s.execute("layer.select", json!({"layer": group})).unwrap(); + s.execute("layer.mergeLayers", json!({})).unwrap(); + let st = s.active().unwrap(); + let merged = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + assert_eq!(merged.name, "Badge"); + assert!(matches!(merged.content, photocraft_doc::LayerContent::Raster(_)), "one pixel layer"); + assert!(st.doc.layer(photocraft_doc::LayerId(below)).is_some(), "the layer below stays"); + assert_eq!(st.doc.layer_count(), layers - 2, "group and its two layers became one"); + let after = [px(&mut s, 5, 5), px(&mut s, 25, 5), px(&mut s, 50, 5)]; + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.0 / 255.0), "{before:?} -> {after:?}"); + } + // Undo brings the group back. + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active().unwrap().doc.layer(photocraft_doc::LayerId(group)).is_some()); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0b74504d51..e7de3409af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -398,6 +398,7 @@ Select Linked Layers Vybrat propojené vrstvy Merge Layers Sloučit vrstvy Merge Down Sloučit dolů + Merge Group Sloučit skupinu Merge Visible Sloučit viditelné Stamp Visible Otisknout viditelné Select Layer Above Vybrat vrstvu nad diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 604dcb43f0..ac313154db 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -394,6 +394,7 @@ Select Linked Layers Verknüpfte Ebenen auswählen Merge Layers Ebenen zusammenfügen Merge Down Nach unten zusammenfügen + Merge Group Gruppe zusammenfügen Merge Visible Sichtbare zusammenfügen Stamp Visible Sichtbare Ebenen stempeln Select Layer Above Ebene darüber auswählen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17677731dd..e5be105597 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1838,6 +1838,7 @@ New Guide Layout… Νέα διάταξη οδηγών… New Guides From Shape Νέοι οδηγοί από σχήμα Merge Down Συγχώνευση με το υποκείμενο επίπεδο + Merge Group Συγχώνευση ομάδας Stamp Visible Συνένωση ορατών σε νέο επίπεδο Select Layer Above Επιλογή επάνω επιπέδου Select Layer Below Επιλογή κάτω επιπέδου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7ad6d980e8..b986956544 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -391,6 +391,7 @@ Select Linked Layers Seleccionar capas vinculadas Merge Layers Combinar capas Merge Down Combinar hacia abajo + Merge Group Combinar grupo Merge Visible Combinar visibles Stamp Visible Sellar visibles Select Layer Above Seleccionar la capa de arriba diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7863936bfc..f426b28c56 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -387,6 +387,7 @@ Select Linked Layers Sélectionner les calques liés Merge Layers Fusionner les calques Merge Down Fusionner avec le calque inférieur + Merge Group Fusionner le groupe Merge Visible Fusionner les calques visibles Stamp Visible Tamponner les calques visibles Stamp Down Tamponner sur le calque inférieur diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 74bd4462f5..420600cf3f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -380,6 +380,7 @@ Select Linked Layers Pilih Layer Tertaut Merge Layers Gabungkan Layer Merge Down Gabung ke Bawah + Merge Group Gabungkan Grup Merge Visible Gabung yang Terlihat Stamp Visible Stamp yang Terlihat Stamp Down Stamp ke Bawah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3d2a0efb34..410ab8f609 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -390,6 +390,7 @@ Select Linked Layers Seleziona livelli collegati Merge Layers Unisci livelli Merge Down Unisci sotto + Merge Group Unisci gruppo Merge Visible Unisci visibili Stamp Visible Unisci visibili in nuovo Select Layer Above Seleziona livello sopra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2196441a31..7e5e65a639 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -391,6 +391,7 @@ Select Linked Layers リンクしたレイヤーを選択 Merge Layers レイヤーを結合 Merge Down 下のレイヤーと結合 + Merge Group グループを結合 Merge Visible 表示レイヤーを結合 Stamp Visible 表示レイヤーをスタンプ Select Layer Above 上のレイヤーを選択 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 9476f0dab4..f48633c2cb 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -375,6 +375,7 @@ Select Linked Layers 연결된 레이어 선택 Merge Layers 레이어 병합 Merge Down 아래 레이어와 병합 + Merge Group 그룹 병합 Merge Visible 보이는 레이어 병합 Stamp Visible 보이는 레이어의 병합 사본 만들기 Select Layer Above 위 레이어 선택 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fda481dc97..3778a8a1de 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -383,6 +383,7 @@ Select Linked Layers Gekoppelde lagen selecteren Merge Layers Lagen samenvoegen Merge Down Omlaag samenvoegen + Merge Group Groep samenvoegen Merge Visible Zichtbare samenvoegen Stamp Visible Zichtbare stempelen Select Layer Above Laag erboven selecteren diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ed63d43905..ce9387b623 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -389,6 +389,7 @@ Select Linked Layers Zaznacz połączone warstwy Merge Layers Scal warstwy Merge Down Scal w dół + Merge Group Scal grupę Merge Visible Scal widoczne Stamp Visible Odbij widoczne Select Layer Above Zaznacz warstwę powyżej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a4242dc8c8..92e9c238b1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -383,6 +383,7 @@ Select Linked Layers Selecionar camadas vinculadas Merge Layers Mesclar camadas Merge Down Mesclar para baixo + Merge Group Mesclar grupo Merge Visible Mesclar visíveis Stamp Visible Carimbar visíveis Stamp Down Carimbar para baixo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ead84742f9..7e1d12bbb1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1897,6 +1897,7 @@ Lock Layers… Заблокировать слои… Mean Среднее Merge Down Выполнить сведение + Merge Group Объединить группу Merge Layers Объединить слои Merge Shape Components Объединить компоненты фигуры Merge Visible Свести видимые diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7f59884e80..11a4caa9cf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1876,6 +1876,7 @@ Lock Layers… Заблокувати шари… Mean Середнє значення Merge Down Об'єднати з нижнім + Merge Group Об'єднати групу Merge Layers Об'єднати шари Merge Shape Components Об'єднати компоненти фігури Merge Visible Об'єднати видимі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c05376c1ee..ca091c14d8 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -936,6 +936,7 @@ Disable Layer Mask Tắt mặt nạ lớp Release Clipping Mask Nhả mặt nạ cắt Merge Down Gộp xuống + Merge Group Gộp nhóm Quick Export As PNG Xuất nhanh dạng PNG Rename Layer… Đổi tên lớp… Frame from Layers… Frame từ các lớp… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 08f00a0fa5..d0309166ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -393,6 +393,7 @@ Select Linked Layers 选择链接的图层 Merge Layers 合并图层 Merge Down 向下合并 + Merge Group 合并组 Merge Visible 合并可见图层 Stamp Visible 盖印可见图层 Select Layer Above 选择上方图层 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index f2bcab8538..8698dfc078 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -398,6 +398,7 @@ Select Linked Layers 選取連結的圖層 Merge Layers 合併圖層 Merge Down 向下合併圖層 + Merge Group 合併群組 Merge Visible 合併可見圖層 Stamp Visible 蓋印可見圖層 Select Layer Above 選取上方圖層 diff --git a/crates/ui-egui/src/layer_menu_ui.rs b/crates/ui-egui/src/layer_menu_ui.rs index 6c4c0ec99e..5be01c750a 100644 --- a/crates/ui-egui/src/layer_menu_ui.rs +++ b/crates/ui-egui/src/layer_menu_ui.rs @@ -111,6 +111,9 @@ pub fn entries(l: &Layer, multi: bool, has_selection: bool) -> Vec { v.push(None); if multi { v.push(Some((tl!("Merge Layers"), "layer.mergeLayers"))); + } else if matches!(l.content, LayerContent::Group(_)) { + // Photoshop's Merge Group: a group merges itself, not into the layer below (#3832). + v.push(Some((tl!("Merge Group"), "layer.mergeGroup"))); } else { v.push(Some((tl!("Merge Down"), "layer.mergeDown"))); } @@ -654,6 +657,31 @@ mod tests { assert!(ids.contains(&"layer.mergeLayers")); } + /// #3832: a group's right-click menu and the Layer menu offer Photoshop's Merge Group, which + /// merges the group itself (not into the layer below), and it is enabled. + #[test] + fn a_group_offers_merge_group() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 10, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.groupLayers", json!({"name": "G"})).unwrap(); + let st = s.active().unwrap(); + let g = st.doc.layer(st.active_layer.unwrap()).unwrap().clone(); + let e: Vec<(&str, &str)> = entries(&g, false, false).into_iter().flatten().collect(); + assert!(e.contains(&("Merge Group", "layer.mergeGroup")), "{e:?}"); + assert!(!e.iter().any(|x| x.0 == "Merge Down")); + let mut app = crate::PhotocraftApp::new(s, Default::default()); + app.sync_views(); + let items = crate::menus::menu_items(&app); + let item = items.iter().find(|i| i.id == "layer.mergeLayers").unwrap(); + assert_eq!(item.label, "Merge Group"); + assert!(item.enabled, "Merge Group runs on a group"); + // A plain layer keeps Merge Layers. + app.run("layer.new.layer", json!({})).unwrap(); + let items = crate::menus::menu_items(&app); + assert_eq!(items.iter().find(|i| i.id == "layer.mergeLayers").unwrap().label, "Merge Layers"); + } + #[test] fn add_mask_uses_the_selection() { assert_eq!(add_mask_command(false, false), "layer.layerMask.revealAll"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4e6c9ba188..18f1d6afb3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -966,6 +966,14 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec { { item.label = crate::layer_menu_ui::mask_toggle_label(mask.enabled).into(); } + // Layer › Merge Layers reads Merge Group with just a group selected, as in Photoshop (#3832). + if let Some(st) = app.session.active() + && st.selected_layers().len() <= 1 + && st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Group(_))) + && let Some(item) = items.iter_mut().find(|i| i.id == "layer.mergeLayers") + { + item.label = "Merge Group".into(); + } // The File quick-export command uses the format selected in Export Preferences. // On the web it intentionally downloads PNG until the quick-export service supports // the other formats; the Layer quick-export command also always produces PNG. From 094c4f30e4067bd3eb8564bed53843ac77881e7e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:43:34 +0200 Subject: [PATCH 133/523] CMS tests: validate profile-specific CMYK black endpoints (#3890) Retain the relative paper-white requirement and round-trip checks while validating finite, bounded endpoints and darker full-K ink. Add an encoded and reparsed AToB1 regression with an independently calculated 100/87/74/61/48 lightness ramp, plus invalid endpoint cases. Signed-off-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/cms/tests/cms.rs | 69 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/cms/tests/cms.rs b/crates/cms/tests/cms.rs index ba3f008fb4..f390a390da 100644 --- a/crates/cms/tests/cms.rs +++ b/crates/cms/tests/cms.rs @@ -450,6 +450,73 @@ fn malformed_profiles_are_rejected_without_panicking() { } } +// A press's full-K lightness depends on its ink and paper (newsprint can exceed L* 40). +// Keep the relative paper-white check and require finite endpoints with darker, bounded ink. +fn cmyk_endpoints_valid(paper: [f64; 3], black: [f64; 3]) -> bool { + // Allow only float rounding at the physical L* limits, not a different printing condition. + let lightness = -1e-4..=100.0 + 1e-4; + paper.iter().chain(&black).all(|v| v.is_finite()) + && lightness.contains(&paper[0]) + && lightness.contains(&black[0]) + && paper[0] > 90.0 + && black[0] < paper[0] +} + +#[test] +fn a_light_black_cmyk_lut_has_the_known_relative_ramp() { + use photocraft_cms::Curve; + use photocraft_cms::pipeline::Stage; + use photocraft_cms::profile::{Lut, LutKind}; + + let mut p = cmyk().clone(); + // Independent linear test model: L* = 100 - 52 K, neutral a*/b*. Exercise A2B1 only; + // the inherited coated profile's inverse is not an inverse of this generated model. + p.a2b = [None, None, None]; + p.b2a = [None, None, None]; + p.a2b[1] = Some(Lut { + kind: LutKind::Ab, + inputs: 4, + outputs: 3, + stages: vec![ + Stage::Curves(vec![Curve::Identity; 4]), + Stage::Clut(Clut::sample(vec![2; 4], 3, |ink, out| { + out.copy_from_slice(&enc([100.0 - 52.0 * f64::from(ink[3]), 0.0, 0.0])); + })), + Stage::Curves(vec![Curve::Identity; 3]), + ], + }); + let p = Profile::parse(&p.with_encoded_bytes().to_bytes()).unwrap(); + let paper = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric); + let black = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric); + assert!(cmyk_endpoints_valid(paper, black), "{paper:?} {black:?}"); + // Explicit expected values, not the validation predicate: also catch wrong channels, + // intent selection, constant output and incorrect LUT encoding/interpolation. + for (k, expected) in [(0.0, 100.0), (0.25, 87.0), (0.5, 74.0), (0.75, 61.0), (1.0, 48.0)] { + let actual = to_lab(&p, &[0.0, 0.0, 0.0, k], Intent::RelativeColorimetric); + // The writer quantizes the CLUT to 16 bits: at most 0.000763 L* error. + assert!((actual[0] - expected).abs() < 0.001 && actual[1].abs() < 1e-4 && actual[2].abs() < 1e-4, "K={k}: {actual:?}"); + } +} + +#[test] +fn cmyk_endpoints_reject_invalid_values() { + let paper = [100.0, 0.0, 0.0]; + let black = [48.0, 0.0, 0.0]; + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for channel in 0..3 { + let mut p = paper; + let mut k = black; + p[channel] = bad; + k[channel] = bad; + assert!(!cmyk_endpoints_valid(p, black)); + assert!(!cmyk_endpoints_valid(paper, k)); + } + } + for (p, k) in [(90.0, 48.0), (100.01, 48.0), (100.0, -0.01), (100.0, 100.01), (100.0, 100.0), (99.0, 100.0)] { + assert!(!cmyk_endpoints_valid([p, 0.0, 0.0], [k, 0.0, 0.0]), "L*: paper {p}, black {k}"); + } +} + /// Parses every profile the OS ships (macOS ColorSync; read-only, nothing is copied) and /// converts through each. #[test] @@ -500,7 +567,7 @@ fn system_profiles() { // Real CMYK profile: paper lighter than 100 % K. let w = to_lab(&p, &[0.0; 4], Intent::RelativeColorimetric); let k = to_lab(&p, &[0.0, 0.0, 0.0, 1.0], Intent::RelativeColorimetric); - assert!(w[0] > 90.0 && k[0] < 40.0, "{}: {w:?} {k:?}", path.display()); + assert!(cmyk_endpoints_valid(w, k), "{}: {w:?} {k:?}", path.display()); // And it round-trips through its own BToA. if p.b2a[1].is_some() { let to = Transform::new(lab(), &p, Intent::RelativeColorimetric, false).unwrap(); From 8072636e0f5a74d9e1bd4fca5101b31a23db2607 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 21:46:59 +0800 Subject: [PATCH 134/523] Count video footage in history memory budgets (#3894) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/ops/src/lib.rs | 83 +++++++++++++++++++++++++++++++++++++++++ crates/ops/src/tiles.rs | 7 ++++ 2 files changed, 90 insertions(+) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 0f29cf8e98..70cbebe0f7 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -419,6 +419,89 @@ mod tests { assert_eq!(counter.bytes_with(&empty), retained as u128); } + fn video_document(depth: SampleType, frames: usize) -> Document { + let mut doc = Document::new("video", Size::new(64, 64), ColorMode::Rgb, depth); + let mut layer = Layer::raster("Video", doc.pixel_format()); + let footage: Vec<_> = (0..frames) + .map(|i| { + let mut surface = photocraft_doc::Surface::new(doc.pixel_format()); + surface.write_pixel(1, 1, &[i as f32 / frames as f32, 0.0, 0.0, 1.0]); + surface + }) + .collect(); + layer.content = photocraft_doc::LayerContent::Raster(footage[0].clone()); + layer.video = Some(photocraft_doc::VideoData::new(footage, 24.0)); + doc.layers.push(layer); + doc + } + + #[test] + fn video_memory_counts_originals_and_deduplicates_displayed_frames() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = video_document(depth, 3); + let tile = doc.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len(); + let video = doc.layers[0].video.as_mut().unwrap(); + video.original_frames = Some(video.frames.clone()); + // Original footage and the displayed frame alias the stack, except this painted frame. + video.frames[2].write_pixel(1, 1, &[0.0, 1.0, 0.0, 1.0]); + let mut cur = Arc::new(doc); + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + for name in ["A", "B"] { + edit(&mut h, &mut cur, "Rename", |doc| doc.name = name.into()); + h.trim(&cur); + } + assert_counter(&h, &cur); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert_eq!(h.unique_bytes(&cur), 0); + // Rasterizing keeps the displayed frame; the rest of the clip survives only in undo. + edit(&mut h, &mut cur, "Rasterize", |doc| doc.layers[0].video = None); + assert_eq!(h.unique_bytes(&cur), 3 * tile); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert_counter(&h, &cur); + cur = h.undo(cur).unwrap().0; + assert_eq!(h.unique_bytes(&cur), 0); + assert_counter(&h, &cur); + cur = h.redo(cur).unwrap().0; + assert_eq!(h.unique_bytes(&cur), 3 * tile); + assert_counter(&h, &cur); + h.clear(); + assert_eq!(h.pixel_bytes(&cur), tile); + } + } + + #[test] + fn video_memory_budget_evicts_deleted_off_playhead_frames() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut cur = Arc::new(video_document(depth, 6)); + let tile = cur.layers[0].surface().unwrap().tiles().next().unwrap().1.bytes().len(); + let mut h = History::default(); + for _ in 0..4 { + edit(&mut h, &mut cur, "Delete Frame", |doc| { + doc.layers[0].video.as_mut().unwrap().frames.remove(1); + }); + } + assert_eq!(h.pixel_bytes(&cur), 6 * tile); + assert_eq!(h.unique_bytes(&cur), 4 * tile); + h.max_bytes = 3 * tile; + assert_eq!(h.trim(&cur), 3); + assert_eq!(h.past_len(), 1); + assert_eq!(h.pixel_bytes(&cur), 3 * tile); + assert_eq!(h.unique_bytes(&cur), tile); + assert_counter(&h, &cur); + cur = h.undo(cur).unwrap().0; + assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 3); + assert_counter(&h, &cur); + cur = h.redo(cur).unwrap().0; + assert_eq!(cur.layers[0].video.as_ref().unwrap().frames.len(), 2); + assert_counter(&h, &cur); + // A budget below the remaining clip still keeps the latest step undoable. + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 0); + assert!(h.can_undo()); + } + } + #[test] fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index 59d73cb50e..c4178b5b31 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -19,6 +19,13 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { if let Some(mask) = &layer.mask { visit(&mask.surface); } + if let Some(video) = &layer.video { + // Undo retains the whole clip, not only the displayed frame. Visit aliases too: + // Tiles tracks every owner and counts their shared allocations only once. + for frame in video.frames.iter().chain(video.original_frames.iter().flatten()) { + visit(frame); + } + } } for channel in doc.channels.iter().chain(&doc.quick_mask) { visit(&channel.surface); From a58b6f3d9ef68bc5e993aa0958f73528783fde80 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 12 Oct 2026 21:52:59 +0800 Subject: [PATCH 135/523] Mix filtered selection edges with premultiplied alpha (#3895) * Mix filtered selection edges with premultiplied alpha Co-authored-by: Codex * Update stylize kernel comparison for alpha-aware selection mixing Co-authored-by: Codex --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/algo/src/blur.rs | 5 +- crates/algo/src/blur/motion_apply.rs | 2 +- crates/algo/src/blur/motion_fft_tests.rs | 4 +- crates/algo/src/blur/motion_tests.rs | 2 +- crates/algo/src/lib.rs | 33 +++++++- crates/algo/src/pixelate/tests.rs | 2 +- crates/algo/src/shape_blur_tests.rs | 2 +- crates/algo/src/stylize2/tests.rs | 12 +-- crates/algo/src/tests_iris.rs | 2 +- crates/algo/src/tests_selection_mix.rs | 98 ++++++++++++++++++++++++ crates/algo/src/tests_tilt_shift.rs | 2 +- 11 files changed, 139 insertions(+), 25 deletions(-) create mode 100644 crates/algo/src/tests_selection_mix.rs diff --git a/crates/algo/src/blur.rs b/crates/algo/src/blur.rs index 606ee7dbc5..ae8b12f40a 100644 --- a/crates/algo/src/blur.rs +++ b/crates/algo/src/blur.rs @@ -992,8 +992,9 @@ mod tests { let mixed = crate::apply_in(&s, &p, canvas, canvas, Some(&half), canvas); for (x, y) in [(0, 0), (18, 11), (36, 22)] { let (o, f, m) = (s.pixel(x, y), full.pixel(x, y), mixed.pixel(x, y)); - for c in 0..4 { - assert!((m[c] - (o[c] + f[c]) / 2.0).abs() < 1e-5, "({x},{y}) channel {c}"); + assert!((m[3] - (o[3] + f[3]) / 2.0).abs() < 1e-5, "({x},{y}) alpha"); + for c in 0..3 { + assert!((m[c] * m[3] - (o[c] * o[3] + f[c] * f[3]) / 2.0).abs() < 1e-5, "({x},{y}) premultiplied channel {c}"); } } let cancel = || true; diff --git a/crates/algo/src/blur/motion_apply.rs b/crates/algo/src/blur/motion_apply.rs index cc2f3e84ee..166a1da5a2 100644 --- a/crates/algo/src/blur/motion_apply.rs +++ b/crates/algo/src/blur/motion_apply.rs @@ -169,7 +169,7 @@ fn apply_inner( return None; } if let Some(selection) = selection { - crate::mix_selection(&mut data, output, selection, &src); + crate::mix_selection(&mut data, output, selection, &src, fmt.alpha); } if ctl.cancelled() { return None; diff --git a/crates/algo/src/blur/motion_fft_tests.rs b/crates/algo/src/blur/motion_fft_tests.rs index 12271d7bc2..fd27434bc4 100644 --- a/crates/algo/src/blur/motion_fft_tests.rs +++ b/crates/algo/src/blur/motion_fft_tests.rs @@ -280,7 +280,7 @@ fn motion_fft_pipeline_supports_depths_models_edges_selections_and_tile_seams() for mask in [None, Some(&selection)] { let mut expected_data = filtered.clone(); if let Some(mask) = mask { - crate::mix_selection(&mut expected_data, rect, mask, &src); + crate::mix_selection(&mut expected_data, rect, mask, &src, alpha); } let mut expected = source.clone(); expected.write_region(rect, &expected_data); @@ -362,7 +362,7 @@ fn motion_fft_production_crosses_real_tile_seams_and_leaves_other_pixels_untouch let mut expected = source.clone(); let mut data = filtered.clone(); if let Some(mask) = mask { - crate::mix_selection(&mut data, area, mask, &src); + crate::mix_selection(&mut data, area, mask, &src, true); } expected.write_region(area, &data); let expected = Image::read(&expected, check); diff --git a/crates/algo/src/blur/motion_tests.rs b/crates/algo/src/blur/motion_tests.rs index 822cbb755e..1141dec559 100644 --- a/crates/algo/src/blur/motion_tests.rs +++ b/crates/algo/src/blur/motion_tests.rs @@ -69,7 +69,7 @@ fn row_surface(surface: &Surface, params: &FilterParams, area: Rect, bounds: Rec let ctx = Ctx { bounds, mode: format.mode, alpha: format.alpha }; let mut data = motion_rows(&src, area, &ctx, *angle, *distance); if let Some(selection) = selection { - crate::mix_selection(&mut data, area, selection, &src); + crate::mix_selection(&mut data, area, selection, &src, format.alpha); } let mut result = surface.clone(); result.write_region(area, &data); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index ded4ad31a1..fdbe6dd72b 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -1095,7 +1095,7 @@ fn apply_tiled_with_impl( return (*t, Vec::new()); }; if let Some(sel) = selection { - mix_selection(&mut data, *t, sel, src); + mix_selection(&mut data, *t, sel, src, fmt.alpha); } tick(); (*t, data) @@ -1179,7 +1179,7 @@ fn apply_box_blur( let write = |(s, v): &mut (Surface, Vec)| { for (t, data) in v.iter_mut() { if let Some(sel) = selection { - mix_selection(data, *t, sel, &Image::read(surface, *t)); + mix_selection(data, *t, sel, &Image::read(surface, *t), fmt.alpha); } s.write_region(*t, data); } @@ -1202,8 +1202,9 @@ fn apply_box_blur( } /// Mixes filtered `data` for `t` back toward the original pixels in `orig` by the selection's -/// coverage (channel 0 of `sel`). -fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) { +/// coverage (channel 0 of `sel`). Changing alpha requires premultiplied colour interpolation; +/// otherwise hidden colours leak into feathered edges and alpha-removing filters darken them. +fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image, alpha: bool) { let w = t.width() as usize; for (i, px) in data.chunks_exact_mut(orig.ch.max(1)).enumerate() { let (x, y) = (t.x0 + (i % w) as i32, t.y0 + (i / w) as i32); @@ -1211,6 +1212,27 @@ fn mix_selection(data: &mut [f32], t: Rect, sel: &Surface, orig: &Image) { if k >= 1.0 { continue; } + if k <= 0.0 { + for (c, v) in px.iter_mut().enumerate() { + *v = orig.get(x, y, c); + } + continue; + } + if alpha { + let a = px.len() - 1; + let original_alpha = orig.get(x, y, a); + let filtered_alpha = px[a]; + if original_alpha != filtered_alpha { + let mixed_alpha = original_alpha + (filtered_alpha - original_alpha) * k; + let original_weight = original_alpha * (1.0 - k); + let filtered_weight = filtered_alpha * k; + for (c, v) in px[..a].iter_mut().enumerate() { + *v = if mixed_alpha > 0.0 { (orig.get(x, y, c) * original_weight + *v * filtered_weight) / mixed_alpha } else { 0.0 }; + } + px[a] = mixed_alpha; + continue; + } + } for (c, v) in px.iter_mut().enumerate() { let o = orig.get(x, y, c); *v = o + (*v - o) * k; @@ -1229,6 +1251,9 @@ mod tests_ext; #[cfg(test)] mod tests_mosaic; +#[cfg(test)] +mod tests_selection_mix; + /// The current upstream sampler through the identical source/selection/tile pipeline. #[cfg(test)] #[allow(clippy::too_many_arguments)] diff --git a/crates/algo/src/pixelate/tests.rs b/crates/algo/src/pixelate/tests.rs index 83d89bf303..c82d747326 100644 --- a/crates/algo/src/pixelate/tests.rs +++ b/crates/algo/src/pixelate/tests.rs @@ -153,7 +153,7 @@ fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() { FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles), _ => Vec::new(), }; - crate::mix_selection(&mut expected_data, bounds, &selection, &image); + crate::mix_selection(&mut expected_data, bounds, &selection, &image, ctx.alpha); let mut expected = surface.clone(); expected.write_region(bounds, &expected_data); for tile in [1, 7, 17, 256] { diff --git a/crates/algo/src/shape_blur_tests.rs b/crates/algo/src/shape_blur_tests.rs index 96f8f4c1e8..02bdb0b3ca 100644 --- a/crates/algo/src/shape_blur_tests.rs +++ b/crates/algo/src/shape_blur_tests.rs @@ -129,7 +129,7 @@ fn surfaces_exact_depth_models_alpha_selection_and_tile_boundaries() { }; let spans = Spans::new(radius, shape_inside(shape)); let mut expected = conv_spans(&src, area, &ctx, &spans); - crate::mix_selection(&mut expected, area, &selection, &src); + crate::mix_selection(&mut expected, area, &selection, &src, alpha); let mut reference = surface.clone(); reference.write_region(area, &expected); for tile in [17, 256] { diff --git a/crates/algo/src/stylize2/tests.rs b/crates/algo/src/stylize2/tests.rs index cc44558ea6..3d7f6b5235 100644 --- a/crates/algo/src/stylize2/tests.rs +++ b/crates/algo/src/stylize2/tests.rs @@ -167,17 +167,7 @@ fn storage_selection_and_tile_sizes_match_original_kernels() { FilterParams::Wind { method, from_right, seed } => wind_direct(&src, area, &ctx, method, from_right, seed), _ => continue, }; - let original = s.read_region(area); - for (i, px) in raw.chunks_exact_mut(fmt.channels()).enumerate() { - let (x, y) = (area.x0 + (i % area.width() as usize) as i32, area.y0 + (i / area.width() as usize) as i32); - let k = selection.sample_channel(x, y, 0); - if k >= 1.0 { - continue; - } - for (c, v) in px.iter_mut().enumerate() { - *v = original[i * fmt.channels() + c] + (*v - original[i * fmt.channels() + c]) * k; - } - } + crate::mix_selection(&mut raw, area, &selection, &src, fmt.alpha); let mut want = s.clone(); want.write_region(area, &raw); for tile in [1, 7, 17, 256] { diff --git a/crates/algo/src/tests_iris.rs b/crates/algo/src/tests_iris.rs index 1f930c172d..2d54c8f134 100644 --- a/crates/algo/src/tests_iris.rs +++ b/crates/algo/src/tests_iris.rs @@ -170,7 +170,7 @@ fn iris_depths_models_selection_tiles_and_cancellation() { let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1)); let src = Image::read_clamped(&surface, t.inflate(radius), bounds); let mut data = reference::iris(&src, t, &ctx, &pins); - crate::mix_selection(&mut data, t, &selection, &src); + crate::mix_selection(&mut data, t, &selection, &src, alpha); want.write_region(t, &data); } } diff --git a/crates/algo/src/tests_selection_mix.rs b/crates/algo/src/tests_selection_mix.rs new file mode 100644 index 0000000000..f715a8bbc2 --- /dev/null +++ b/crates/algo/src/tests_selection_mix.rs @@ -0,0 +1,98 @@ +use super::*; +use photocraft_color::{PixelFormat, SampleType}; + +fn selection(coverage: f32) -> Surface { + Surface::with_default(PixelFormat::new(ColorMode::Grayscale, SampleType::F32, false), &[coverage]) +} + +#[test] +fn feathered_color_to_alpha_preserves_appearance_over_the_key() { + let r = Rect::new(0, 0, 1, 1); + let filter = FilterParams::ColorToAlpha { color: [1.0; 4], transparency_threshold: 0.0, opacity_threshold: 1.0 }; + for sample in SampleType::ALL { + for mode in [ColorMode::Rgb, ColorMode::Grayscale] { + for alpha in [0.5, 1.0] { + let format = PixelFormat::new(mode, sample, true); + let mut s = Surface::new(format); + let mut p = vec![0.5; format.channels()]; + *p.last_mut().unwrap() = alpha; + s.write_pixel(0, 0, &p); + let original = s.pixel(0, 0); + let a = format.channels() - 1; + for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] { + let out = apply(&s, &filter, r, r, Some(&selection(coverage))); + let actual = out.pixel(0, 0); + let tolerance = if sample == SampleType::U8 { 2.0 / 255.0 } else { 1e-4 }; + for c in 0..a { + let before = original[c] * original[a] + 1.0 - original[a]; + let after = actual[c] * actual[a] + 1.0 - actual[a]; + assert!( + (before - after).abs() < tolerance, + "{sample:?}/{mode:?}, alpha={alpha}, coverage={coverage}: {original:?} -> {actual:?}, over white {before} -> {after}" + ); + } + } + } + } + } +} + +#[test] +fn feathered_offset_ignores_hidden_colour_of_the_original_pixel() { + let r = Rect::new(0, 0, 2, 1); + let filter = FilterParams::Offset { horizontal: 1, vertical: 0, undefined: UndefinedAreas::Wrap }; + for sample in SampleType::ALL { + let mut s = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true)); + s.write_pixel(0, 0, &[1.0, 0.0, 0.0, 0.0]); + s.write_pixel(1, 0, &[0.0, 0.0, 1.0, 1.0]); + let out = apply(&s, &filter, r, r, Some(&selection(0.5))); + let p = out.pixel(0, 0); + assert_eq!(&p[..3], &[0.0, 0.0, 1.0], "{sample:?}: hidden red contaminated the selected blue: {p:?}"); + assert!((p[3] - 0.5).abs() < 1.0 / 255.0); + } +} + +#[test] +fn feathered_blurs_do_not_reintroduce_hidden_colour() { + let r = Rect::new(0, 0, 3, 3); + let mut s = Surface::new(PixelFormat::RGBA32F); + s.fill_rect(r, &[1.0, 0.0, 0.0, 0.0]); + s.write_pixel(1, 1, &[0.0, 0.0, 1.0, 1.0]); + for filter in [FilterParams::BoxBlur { radius: 1.0 }, FilterParams::MotionBlur { angle: 0.0, distance: 3.0 }] { + let full = apply(&s, &filter, r, r, None); + let out = apply(&s, &filter, r, r, Some(&selection(0.5))); + let p = out.pixel(0, 1); + assert!(p[3] > 0.0, "{filter:?}: expected blur to spread coverage"); + assert!((p[3] - full.pixel(0, 1)[3] * 0.5).abs() < 1e-6, "{filter:?}: selection should halve the new coverage"); + assert!(p[0].abs() < 1e-6 && (p[2] - 1.0).abs() < 1e-6, "{filter:?}: hidden red reappeared: {p:?}"); + } +} + +#[test] +fn selection_mixing_keeps_hdr_range_and_exact_endpoints() { + let r = Rect::new(0, 0, 1, 1); + let original = Image { rect: r, ch: 4, data: vec![-2.0, 4.0, 0.5, 0.25] }; + let filtered = vec![8.0, -4.0, 0.75, 1.0]; + let mut data = filtered.clone(); + mix_selection(&mut data, r, &selection(0.5), &original, true); + for (&actual, expected) in data.iter().zip([6.0, -2.4, 0.7, 0.625]) { + assert!((actual - expected).abs() < 1e-6, "{data:?}"); + } + for coverage in [0.0, 1.0] { + let mut data = filtered.clone(); + mix_selection(&mut data, r, &selection(coverage), &original, true); + assert_eq!(data, if coverage == 0.0 { &original.data } else { &filtered }.clone()); + } +} + +#[test] +fn selection_mixing_without_alpha_keeps_every_channel_independent() { + let r = Rect::new(0, 0, 1, 1); + // One channel is a mask; three/four channels can be RGB or CMYK without alpha. + for ch in [1, 3, 4] { + let original = Image { rect: r, ch, data: vec![0.25; ch] }; + let mut data = vec![0.75; ch]; + mix_selection(&mut data, r, &selection(0.25), &original, false); + assert_eq!(data, vec![0.375; ch]); + } +} diff --git a/crates/algo/src/tests_tilt_shift.rs b/crates/algo/src/tests_tilt_shift.rs index 49a6e79bbf..88066ca9d6 100644 --- a/crates/algo/src/tests_tilt_shift.rs +++ b/crates/algo/src/tests_tilt_shift.rs @@ -177,7 +177,7 @@ fn tilt_shift_depths_models_selection_and_tile_edges_match_reference() { None => Image::read(&surface, t.inflate(radius)), }; let mut data = reference::tilt_shift(&src, t, &ctx, 17.0, (0.3, 0.7), 29.0, 0.07, 0.3); - crate::mix_selection(&mut data, t, &selection, &src); + crate::mix_selection(&mut data, t, &selection, &src, alpha); want.write_region(t, &data); } } From bb17dd555c161dbc6ca20c4cf856d1bf713456e5 Mon Sep 17 00:00:00 2001 From: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:57:11 +0900 Subject: [PATCH 136/523] Color: convert input-only embedded profiles to the working space on open (#3876) (#3900) An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class profile with only AToB tables. Preserve kept it as the document's space, so every composite into the document failed with "RGB profile without matrix/TRC or BToA tags". Such a profile can't be a destination, so open converts from it to the working space whatever the policy (Off still discards it) and the report says inputOnly. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/cms/src/profile.rs | 7 ++++ crates/engine/src/color_cmds.rs | 62 +++++++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/cms/src/profile.rs b/crates/cms/src/profile.rs index 6d646cc1e1..99f5710e5d 100644 --- a/crates/cms/src/profile.rs +++ b/crates/cms/src/profile.rs @@ -296,6 +296,13 @@ impl Profile { self.a2b.iter().all(Option::is_none) && (self.matrix.is_some() && self.trc.is_some() || self.gray_trc.is_some()) } + /// Whether colours can be converted into this profile for every intent. Input profiles with + /// only AToB tables (Apple's Wide Color Sharing Profile on iPhone photos) can't: they + /// describe a source but have no PCS → device path. + pub fn can_be_destination(&self) -> bool { + [Intent::Perceptual, Intent::RelativeColorimetric, Intent::Saturation].into_iter().all(|i| self.pcs_to_device(i).is_ok()) + } + fn lut_for(luts: &[Option; 3], intent: Intent) -> Option<&Lut> { let i = match intent { Intent::Perceptual => 0, diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index f6d4b1b18c..a68188bd3f 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -276,18 +276,22 @@ impl ColorState { if !mismatch || linear_hdr { return merge(base, json!({"action": "kept"})); } + // An input-only profile can't be the document's space: nothing could be + // composited into it (#3876). Preserve converts it to the working space instead. + let input_only = !emb.can_be_destination(); let action = match policy { - Policy::Preserve => "kept", + Policy::Preserve if !input_only => "kept", Policy::Off => { doc.icc_profile = None; "discarded" } - Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) { + Policy::Preserve | Policy::Convert => match convert_document(doc, &working, self.settings.intent(), self.settings.bpc) { Ok(()) => "converted", Err(_) => "kept", }, }; - merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch})) + let report = merge(base, json!({"action": action, "ask": self.settings.ask_on_mismatch})); + if input_only { merge(report, json!({"inputOnly": true})) } else { report } } None => { // Untagged files are assumed to be in the working space; tag them when it is not @@ -1397,6 +1401,58 @@ mod settings_tests { assert_eq!(desc(&s.active().unwrap().doc).as_deref(), Some("Display P3")); } + /// Display P3 as an input profile with only an AToB table, like Apple's Wide Color Sharing + /// Profile embedded in iPhone photos. + fn input_only_p3() -> Profile { + let p3 = Builtin::DisplayP3.profile(); + let t = Transform::new(p3, Builtin::LabD50.profile(), Intent::RelativeColorimetric, false).unwrap(); + let clut = photocraft_cms::Clut::sample(vec![9; 3], 3, |inp, out| t.eval(inp, out)); + let ident = || photocraft_cms::pipeline::Stage::Curves(vec![photocraft_cms::Curve::Identity; 3]); + let lut = photocraft_cms::profile::Lut { + kind: photocraft_cms::profile::LutKind::Ab, + inputs: 3, + outputs: 3, + stages: vec![ident(), photocraft_cms::pipeline::Stage::Clut(clut), ident()], + }; + let mut p = p3.clone(); + p.class = photocraft_cms::ProfileClass::Input; + p.pcs = photocraft_cms::Pcs::Lab; + p.description = "Input-only P3".into(); + (p.matrix, p.trc) = (None, None); + p.a2b = [Some(lut), None, None]; + p.b2a = [None, None, None]; + p.with_encoded_bytes() + } + + #[test] + fn input_only_profiles_are_converted_on_open() { + // #3876: an iPhone photo's input-only profile was kept as the document's space, so + // compositing anything into it failed with "RGB profile without matrix/TRC or BToA tags". + let input = input_only_p3(); + assert!(Builtin::DisplayP3.profile().can_be_destination()); + assert!(!input.can_be_destination()); + let mut s = Session::new(); + let mut d = tagged("srgb"); + d.icc_profile = Some(input.to_bytes()); + let (photo, r) = s.open_document(d, None); + assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool(), r["ask"].as_bool()), (Some("converted"), Some(true), Some(true))); + let doc = &s.active().unwrap().doc; + assert!(desc(doc).unwrap().starts_with("sRGB")); + // Converted from P3, not reinterpreted as sRGB. + assert!((doc.layers[0].surface().unwrap().pixel(1, 1)[0] - 0.2).abs() > 0.01); + let (other, _) = s.open_document(tagged("srgb"), None); + s.execute("layer.copyToDocument", json!({"document": photo, "source": other})).unwrap(); + // Off still discards the profile, and profiles that can be a destination are still kept. + s.execute("edit.colorSettings", json!({"policyRgb": "off"})).unwrap(); + let mut d = tagged("srgb"); + d.icc_profile = Some(input.to_bytes()); + let (_, r) = s.open_document(d, None); + assert_eq!((r["action"].as_str(), r["inputOnly"].as_bool()), (Some("discarded"), Some(true))); + s.execute("edit.colorSettings", json!({"policyRgb": "preserve"})).unwrap(); + let (_, r) = s.open_document(tagged("display-p3"), None); + assert_eq!((r["action"].as_str(), r.get("inputOnly")), (Some("kept"), None)); + } + /// A little-endian EXIF block whose Exif IFD holds ColorSpace = `space`. fn exif_color_space(space: u16) -> Vec { let mut b = b"Exif\0\0II\x2a\0\x08\0\0\0".to_vec(); From 5a0e2e26582c6aac610eb077e28e55a5fe3a20a6 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:26:29 -0400 Subject: [PATCH 137/523] Web: wasm back under the 24 MiB gate with ~1 MiB headroom (25.38 -> 24.17 MB) The "web build and wasm size gate" job failed on main and every PR: the release `_bg.wasm` measured 25,379,677 bytes at 8d49ecf5, over the 25,165,824-byte limit in packaging/web/package.sh. - egui's default fonts (Ubuntu Light, Hack, Noto Emoji, emoji-icon-font) were embedded uncompressed (1.4 MB) by egui's `default_fonts` feature. The app now builds egui without it; ui-egui's build.rs deflates the same files from the `epaint_default_fonts` crate (a build-dependency, so no font files are committed) and `egui_fonts.rs` rebuilds egui's exact default stack from them on first use. Test builds keep the feature as the oracle: the rebuilt definitions equal egui's byte for byte. - Translation catalogs: every catalog repeated the same English sources. build.rs now stores the `contextsource` keys once and each catalog as references plus translations, losslessly (`text()` still returns each .tsv byte for byte; malformed lines are kept as they are). - wasm-release profile: opt-level "z" for PhotoCraft's own crates. Dependencies stay at "s" (at "z" the wasm grew 0.4 MB, measured), the file-decoding crates (codecs, psd, raw, heif, vector) stay at "s" and the pixel crates at 3, so decoding and filters keep their speed. Measured with `trunk build --release` (trunk 0.21.14, wasm-opt v123 -Oz): upstream main 8d49ecf5 25,379,677 bytes this change 24,167,186 bytes (-1,212,491) gate 25,165,824 bytes (998,638 headroom) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 40 +++++----- apps/photocraft-web/Cargo.toml | 2 +- apps/photocraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 9 ++- crates/ui-egui/build.rs | 129 ++++++++++++++++++++++++++----- crates/ui-egui/src/egui_fonts.rs | 91 ++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 70 +++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/theme.rs | 3 +- packaging/web/README.md | 17 ++-- xtask/src/i18n_coverage.rs | 2 +- 12 files changed, 310 insertions(+), 57 deletions(-) create mode 100644 crates/ui-egui/src/egui_fonts.rs diff --git a/Cargo.lock b/Cargo.lock index 160403c482..404c51f354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4574,6 +4574,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "epaint_default_fonts", "flate2", "log", "photocraft-algo", diff --git a/Cargo.toml b/Cargo.toml index 87e0a378ce..5c003659b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,9 @@ zune-core = "0.5" zune-jpeg = "0.5" proptest = "1" eframe = { version = "0.36", default-features = false } -egui = "0.36" +# Without `default_fonts`, which embeds egui's fonts uncompressed: ui-egui ships them deflated +# (egui_fonts.rs; the web build's size gate). +egui = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" log = "0.4" @@ -81,13 +83,14 @@ codegen-units = 1 # The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the # size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every -# visit, so this profile optimizes for size: fat LTO, opt-level "z" for the UI, command, and I/O -# shells, and "s" for other crates except pixel processing, which keeps opt-level 3. panic -# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for -# the native app shell). packaging/web/package.sh fails the build above 24 MiB. +# visit, so this profile optimizes for size: fat LTO, opt-level "z" for PhotoCraft's own crates, +# "s" for dependencies and for the crates that decode files, and 3 for pixel processing (filters +# and compositing). Dependencies stay at "s": at "z" the whole wasm grows (by 0.4 MB, measured). +# panic stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` +# is for the native app shell). packaging/web/package.sh fails the build above 24 MiB. [profile.wasm-release] inherits = "release" -opt-level = "s" +opt-level = "z" lto = "fat" codegen-units = 1 strip = "debuginfo" @@ -95,20 +98,19 @@ strip = "debuginfo" [profile.wasm-release.package."*"] opt-level = "s" -# Keep the browser shell compact as shared panel controls grow. Pixel-processing crates below -# retain their speed-oriented optimization level. -[profile.wasm-release.package.photocraft-ui-egui] -opt-level = "z" - -# Keep browser command dispatch, import/export glue, and the entry point compact. Native -# builds and the speed-oriented pixel-processing overrides below retain their settings. -[profile.wasm-release.package.photocraft-engine] -opt-level = "z" -[profile.wasm-release.package.photocraft-io] -opt-level = "z" -[profile.wasm-release.package.photocraft-web] -opt-level = "z" +# Decoding and rasterizing keep their speed. +[profile.wasm-release.package.photocraft-codecs] +opt-level = "s" +[profile.wasm-release.package.photocraft-psd] +opt-level = "s" +[profile.wasm-release.package.photocraft-raw] +opt-level = "s" +[profile.wasm-release.package.photocraft-heif] +opt-level = "s" +[profile.wasm-release.package.photocraft-vector] +opt-level = "s" +# Pixel processing keeps its speed-oriented optimization level. [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml index 6b4c62797a..a4ded64181 100644 --- a/apps/photocraft-web/Cargo.toml +++ b/apps/photocraft-web/Cargo.toml @@ -29,7 +29,7 @@ log = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit"] } rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 740a75b3f4..57b537931c 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -29,7 +29,7 @@ photocraft-automation = { workspace = true } photocraft-ui-egui = { workspace = true } # Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate). photocraft-tablet = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] } +eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 568727163b..d6a49c0038 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -30,7 +30,7 @@ photocraft-format = { workspace = true } photocraft-codecs = { workspace = true } egui = { workspace = true } egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["wgpu", "accesskit"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -49,6 +49,9 @@ sys-locale = "=0.3.2" winsafe = { version = "=0.0.29", default-features = false, features = ["kernel"] } [dev-dependencies] +# egui's own default fonts in tests: the oracle for egui_fonts.rs, and contexts made with +# `Context::default()` (no install_fonts) can lay out text. +egui = { workspace = true, features = ["default_fonts"] } # Synthetic raw files for the open-time Camera Raw dialog tests. photocraft-raw = { workspace = true, features = ["testgen"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot", "eframe"] } @@ -58,7 +61,9 @@ photocraft-testkit = { workspace = true } photocraft-psd = { workspace = true } [build-dependencies] -# Deflates the translation catalogs (build.rs); already linked at run time to inflate them. +# Deflates the translation catalogs and egui's default fonts (build.rs); already linked at run time to inflate them. flate2 = { workspace = true } +# egui's default fonts, deflated by build.rs for egui_fonts.rs (the same version egui uses). +epaint_default_fonts = "0.36" serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index fa0e19cc1f..900370eb69 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -8,10 +8,10 @@ //! //! A missing or malformed file gives empty tables (or no names) and a warning. //! -//! Also deflates the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/.tsv.deflate` -//! for `i18n::LANGUAGES`: stored compressed they take ~0.65 MB of the binary instead of ~1.9 MB, -//! which keeps the web build under its 24 MiB size gate (packaging/web/package.sh). A catalog is -//! inflated the first time its language is used. +//! Also packs the translation catalogs (`src/i18n/*.tsv`) into `OUT_DIR/i18n/` for +//! `i18n::LANGUAGES` (see [`pack_catalogs`]): stored that way they take ~0.5 MB of the binary +//! instead of ~2.2 MB, which keeps the web build under its 24 MiB size gate +//! (packaging/web/package.sh). A catalog is unpacked the first time its language is used. use std::fmt::Write as _; @@ -27,30 +27,123 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } - deflate_catalogs(); + pack_catalogs(); + deflate_egui_fonts(); } -/// Deflate every `src/i18n/*.tsv` into `OUT_DIR/i18n/`. A catalog that can't be written is a -/// build error (`include_bytes!` would fail on it anyway, with a less helpful message). -fn deflate_catalogs() { +/// Deflate egui's default fonts (the `epaint_default_fonts` crate) into `OUT_DIR/egui_fonts/` for +/// `egui_fonts.rs`. The app builds egui without `default_fonts`, which would embed the same 1.4 MB +/// uncompressed; deflated they take 0.78 MB (the web build's 24 MiB size gate). +fn deflate_egui_fonts() { use std::io::Write as _; - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); - let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n"); - println!("cargo::rerun-if-changed={}", src.display()); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("egui_fonts"); + let fonts = [ + ("Hack-Regular", epaint_default_fonts::HACK_REGULAR), + ("NotoEmoji-Regular", epaint_default_fonts::NOTO_EMOJI_REGULAR), + ("Ubuntu-Light", epaint_default_fonts::UBUNTU_LIGHT), + ("emoji-icon-font", epaint_default_fonts::EMOJI_ICON), + ]; let result = (|| -> std::io::Result<()> { std::fs::create_dir_all(&out)?; - for entry in std::fs::read_dir(&src)? { - let path = entry?.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue }; - println!("cargo::rerun-if-changed={}", path.display()); + for (name, bytes) in fonts { let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); - z.write_all(&std::fs::read(&path)?)?; - std::fs::write(out.join(format!("{name}.deflate")), z.finish()?)?; + z.write_all(bytes)?; + std::fs::write(out.join(format!("{name}.ttf.deflate")), z.finish()?)?; } Ok(()) })(); if let Err(e) = result { - println!("cargo::error=deflating the translation catalogs in {}: {e}", src.display()); + println!("cargo::error=deflating egui's default fonts into {}: {e}", out.display()); + } +} + +/// Whether a catalog line is an entry (`contextsourcetranslation`, source and +/// translation non-empty, as `i18n::catalog::parse_entries` reads it), and its key and translation. +fn catalog_entry(line: &str) -> Option<(&str, &str)> { + if line.starts_with('#') { + return None; + } + let mut cols = line.splitn(3, '\t'); + let (ctx, src, tr) = (cols.next()?, cols.next()?, cols.next()?); + let key = line.get(..ctx.len() + 1 + src.len())?; + (!src.is_empty() && !tr.is_empty() && !tr.contains('\t')).then_some((key, tr)) +} + +/// Pack every `src/i18n/*.tsv` into `OUT_DIR/i18n/`, losslessly (`i18n::unpack` restores each file +/// byte for byte). Every catalog repeats the same English sources, so they are stored once: +/// +/// - `keys.deflate`: every entry's `contextsource`, one per line, in order of first +/// appearance (the largest catalog first). +/// - `.tsv.deflate`: the catalog, each entry line replaced by `\x01` when its key +/// follows the previous entry's in `keys`, else `\x02` (the offset from +/// there). Other lines are kept; one that starts with \x01, \x02 or \x03 gets a \x03 prefix. +/// +/// Both are deflated. A catalog that can't be written is a build error (`include_bytes!` would +/// fail on it anyway, with a less helpful message). +fn pack_catalogs() { + use std::io::Write as _; + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("i18n"); + println!("cargo::rerun-if-changed={}", src.display()); + let deflate = |bytes: &[u8]| -> std::io::Result> { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::best()); + z.write_all(bytes)?; + z.finish() + }; + let result = (|| -> std::io::Result<()> { + std::fs::create_dir_all(&out)?; + let mut catalogs = Vec::new(); + for entry in std::fs::read_dir(&src)? { + let path = entry?.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).filter(|n| n.ends_with(".tsv")) else { continue }; + println!("cargo::rerun-if-changed={}", path.display()); + catalogs.push((name.to_owned(), std::fs::read_to_string(&path)?)); + } + // Largest first, then by name: the key order (and so the output) is reproducible. + catalogs.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0))); + let mut keys: Vec<&str> = Vec::new(); + let mut index = std::collections::HashMap::new(); + for line in catalogs.iter().flat_map(|(_, text)| text.split('\n')) { + if let Some((key, _)) = catalog_entry(line) { + index.entry(key).or_insert_with(|| { + keys.push(key); + keys.len() - 1 + }); + } + } + std::fs::write(out.join("keys.deflate"), deflate(keys.join("\n").as_bytes())?)?; + for (name, text) in &catalogs { + let mut packed = String::with_capacity(text.len()); + let mut next = 0usize; + for (n, line) in text.split('\n').enumerate() { + if n > 0 { + packed.push('\n'); + } + match catalog_entry(line) { + Some((key, tr)) => { + let i = index.get(key).copied().unwrap_or_default(); + if i == next { + packed.push('\x01'); + } else { + let _ = write!(packed, "\x02{}\t", i as i64 - next as i64); + } + packed.push_str(tr); + next = i + 1; + } + None => { + if line.starts_with(['\x01', '\x02', '\x03']) { + packed.push('\x03'); + } + packed.push_str(line); + } + } + } + std::fs::write(out.join(format!("{name}.deflate")), deflate(packed.as_bytes())?)?; + } + Ok(()) + })(); + if let Err(e) = result { + println!("cargo::error=packing the translation catalogs in {}: {e}", src.display()); } } diff --git a/crates/ui-egui/src/egui_fonts.rs b/crates/ui-egui/src/egui_fonts.rs new file mode 100644 index 0000000000..4c71705e64 --- /dev/null +++ b/crates/ui-egui/src/egui_fonts.rs @@ -0,0 +1,91 @@ +//! egui's default fonts (Ubuntu Light, Hack, Noto Emoji and emoji-icon-font: the fallback stack +//! behind Inter and JetBrains Mono, for symbols and emoji), stored deflated. +//! +//! egui's `default_fonts` feature embeds them uncompressed (1.4 MB), which the web build's 24 MiB +//! size gate (packaging/web/package.sh) can't afford. The app builds egui without it; build.rs +//! deflates the same files from the `epaint_default_fonts` crate (0.78 MB) and they are inflated +//! once, the first time the fonts are installed. Test builds keep the feature (dev-dependencies), +//! so egui's own definitions are the oracle for [`definitions`]. + +use std::sync::{Arc, LazyLock}; + +use egui::{FontData, FontDefinitions, FontFamily, FontTweak}; + +/// A font deflated by build.rs. +macro_rules! deflated { + ($name:literal) => { + include_bytes!(concat!(env!("OUT_DIR"), "/egui_fonts/", $name, ".ttf.deflate")) + }; +} + +static HACK: LazyLock> = LazyLock::new(|| inflate(deflated!("Hack-Regular"))); +static NOTO_EMOJI: LazyLock> = LazyLock::new(|| inflate(deflated!("NotoEmoji-Regular"))); +static UBUNTU_LIGHT: LazyLock> = LazyLock::new(|| inflate(deflated!("Ubuntu-Light"))); +static EMOJI_ICON: LazyLock> = LazyLock::new(|| inflate(deflated!("emoji-icon-font"))); + +/// Inflated fonts are bounded (the largest is 0.42 MB), so corrupt data can't exhaust memory. +const MAX_FONT_BYTES: u64 = 16 << 20; + +/// Inflate a font. Data that doesn't inflate gives empty bytes (the font is left out), never a panic. +fn inflate(deflated: &[u8]) -> Vec { + use std::io::Read as _; + let mut out = Vec::new(); + match flate2::read::DeflateDecoder::new(deflated).take(MAX_FONT_BYTES + 1).read_to_end(&mut out) { + Ok(_) if out.len() as u64 <= MAX_FONT_BYTES => out, + _ => Vec::new(), + } +} + +/// `FontDefinitions::default()` with egui's default fonts, whether or not egui was built with +/// `default_fonts`: the same faces, tweaks and family order. +pub fn definitions() -> FontDefinitions { + let fonts = FontDefinitions::default(); + if fonts.font_data.is_empty() { from_deflated() } else { fonts } +} + +/// egui's default definitions, built from the deflated copies. +fn from_deflated() -> FontDefinitions { + let mut fonts = FontDefinitions::empty(); + let faces: [(&str, &'static [u8], f32); 4] = [ + ("Hack", HACK.as_slice(), 1.0), + // egui draws its emoji fonts a little smaller. + ("NotoEmoji-Regular", NOTO_EMOJI.as_slice(), 0.81), + ("Ubuntu-Light", UBUNTU_LIGHT.as_slice(), 1.0), + ("emoji-icon-font", EMOJI_ICON.as_slice(), 0.90), + ]; + for (name, bytes, scale) in faces { + if bytes.is_empty() { + log::error!("egui default font {name} is unavailable"); + continue; + } + let data = FontData::from_static(bytes).tweak(FontTweak { scale, ..Default::default() }); + fonts.font_data.insert(name.to_owned(), Arc::new(data)); + } + let stack = |names: &[&str]| -> Vec { names.iter().filter(|n| fonts.font_data.contains_key(**n)).map(|n| (*n).to_owned()).collect() }; + let mono = stack(&["Hack", "Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]); + let proportional = stack(&["Ubuntu-Light", "NotoEmoji-Regular", "emoji-icon-font"]); + fonts.families.insert(FontFamily::Monospace, mono); + fonts.families.insert(FontFamily::Proportional, proportional); + fonts +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The definitions built from the deflated copies are egui's own: the same bytes, tweaks and + /// family order (test builds enable `default_fonts`, so `FontDefinitions::default()` is egui's). + #[test] + fn deflated_fonts_match_egui_defaults() { + let egui = FontDefinitions::default(); + assert!(!egui.font_data.is_empty(), "test builds enable egui's default_fonts (dev-dependencies)"); + assert_eq!(from_deflated(), egui); + assert_eq!(definitions(), egui); + } + + #[test] + fn corrupt_font_data_is_left_out_not_a_panic() { + assert!(inflate(b"not deflate data").is_empty()); + assert!(inflate(&[]).is_empty()); + } +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ecf21f46d0..e7347ca5af 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -115,8 +115,8 @@ fn plural_polish(n: u64) -> usize { } } -/// A catalog file as deflated by build.rs (the binary would otherwise carry ~1.9 MB of -/// translations, which the web build's size gate can't afford). +/// A catalog file as packed by build.rs (`pack_catalogs`; the binary would otherwise carry ~2.2 MB +/// of translations, which the web build's size gate can't afford). See [`unpack`]. macro_rules! catalog { ($code:literal) => { include_bytes!(concat!(env!("OUT_DIR"), "/i18n/", $code, ".tsv.deflate")) @@ -140,6 +140,52 @@ fn inflate(source: &[u8]) -> Result { Ok(text) } +/// Every catalog entry's `contextsource`, stored once for all catalogs (build.rs). +static KEYS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/i18n/keys.deflate")); + +/// Restore a catalog file from its packed form (build.rs `pack_catalogs`): entry lines refer to +/// [`KEYS`] instead of repeating the English source. Bad data gives an error (and the language +/// falls back to English), never a panic. +fn unpack(source: &[u8]) -> Result { + static KEY_TEXT: OnceLock> = OnceLock::new(); + if source.is_empty() { + return Ok(String::new()); + } + let keys: Vec<&str> = KEY_TEXT.get_or_init(|| inflate(KEYS)).as_ref().map_err(|e| format!("catalog keys: {e}"))?.split('\n').collect(); + expand(&inflate(source)?, &keys) +} + +/// [`unpack`]'s second step: replace each packed entry line with its key and translation. +fn expand(packed: &str, keys: &[&str]) -> Result { + let mut text = String::with_capacity(packed.len().saturating_mul(2)); + let mut next = 0usize; + for (n, line) in packed.split('\n').enumerate() { + if n > 0 { + text.push('\n'); + } + let bad = || format!("packed line {}: bad key reference", n + 1); + let (index, tr) = if let Some(tr) = line.strip_prefix('\x01') { + (next, tr) + } else if let Some(rest) = line.strip_prefix('\x02') { + let (offset, tr) = rest.split_once('\t').ok_or_else(bad)?; + let offset: i64 = offset.parse().map_err(|_| bad())?; + let index = i64::try_from(next).ok().and_then(|n| n.checked_add(offset)).and_then(|i| usize::try_from(i).ok()).ok_or_else(bad)?; + (index, tr) + } else { + text.push_str(line.strip_prefix('\x03').unwrap_or(line)); + continue; + }; + text.push_str(keys.get(index).ok_or_else(bad)?); + text.push('\t'); + text.push_str(tr); + next = index.saturating_add(1); + if text.len() as u64 > MAX_CATALOG_BYTES { + return Err(format!("over {MAX_CATALOG_BYTES} bytes")); + } + } + Ok(text) +} + /// The registry. English first: it is the fallback and the source language. pub static LANGUAGES: [LangInfo; 18] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, @@ -171,7 +217,7 @@ pub static LANGUAGES: [LangInfo; 18] = [ impl LangInfo { /// The catalog file's text (empty for English, or if the bundled data can't be inflated). pub fn text(&self) -> String { - inflate(self.source).unwrap_or_else(|e| { + unpack(self.source).unwrap_or_else(|e| { log::error!("translation catalog {}: {e}; showing English", self.code); String::new() }) @@ -677,13 +723,13 @@ mod tests { assert_eq!(tr(CS(), "All"), "Vše"); } - /// The deflated catalogs in the binary inflate to exactly the `.tsv` files. + /// The packed catalogs in the binary unpack to exactly the `.tsv` files. #[test] - fn deflated_catalogs_match_their_files() { + fn packed_catalogs_match_their_files() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/i18n"); for l in LANGUAGES.iter().skip(1) { let file = std::fs::read_to_string(dir.join(format!("{}.tsv", l.code))).unwrap(); - assert_eq!(inflate(l.source).unwrap(), file, "{}", l.code); + assert_eq!(unpack(l.source).unwrap(), file, "{}", l.code); assert!(l.source.len() < file.len() / 2, "{}: stored deflated", l.code); } assert_eq!(LANGUAGES[0].text(), "", "English has no catalog"); @@ -707,6 +753,18 @@ mod tests { assert_eq!(tr(Lang(info), "Layer"), "Layer", "falls back to English"); } + /// Packed lines unpack to their keys; references to keys that don't exist are an error, not a + /// panic. + #[test] + fn packed_lines_unpack_and_bad_references_are_errors() { + let keys = ["\tOpen", "@id\tfile.open", "\tClose"]; + let packed = "# c\n\x01Ouvrir\n\x02-1\tOuvrir\n\x02+1\tFermer\n\x03\x01x\n"; + assert_eq!(expand(packed, &keys).unwrap(), "# c\n\tOpen\tOuvrir\n\tOpen\tOuvrir\n\tClose\tFermer\n\x01x\n"); + for bad in ["\x02-1\tx", "\x02 3\tx", "\x023", "\x029223372036854775807\tx", "\x02-9223372036854775808\tx", "\x01a\n\x01b\n\x01c\n\x01d"] { + assert!(expand(bad, &keys).is_err(), "{bad:?}"); + } + } + /// Every bundled catalog is well-formed and consistent with its sources. #[test] fn bundled_catalogs_are_consistent() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d6309afccc..d9d67542ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,7 @@ pub mod distort_ui; pub mod doc_props_ui; pub mod dock; pub mod duplicate_layer_ui; +pub mod egui_fonts; pub mod enable_rules; pub mod eraser_ui; pub mod export_dialog; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0719c5cec1..bee0104b89 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -726,7 +726,8 @@ pub fn install_fonts(ctx: &egui::Context) { /// [`install_fonts`] with the CJK and other-script fallback fonts taken from `cjk` (tests swap the sources). pub fn install_fonts_with(ctx: &egui::Context, cjk: crate::cjk_fonts::Sources) { - let mut fonts = FontDefinitions::default(); + // egui's default fonts stay last in the stack, for symbols and emoji Inter lacks. + let mut fonts = crate::egui_fonts::definitions(); // The bundled fonts are inflated on first use (photocraft_text::fonts). Should one ever fail // to inflate (empty bytes), it is left out and egui's default fonts draw that text. let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| -> Option { diff --git a/packaging/web/README.md b/packaging/web/README.md index 4079c838f6..5ca375f6cc 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -7,7 +7,7 @@ static site in `photocraft-web-/`: |---|---| | `index.html` | The page. It loads everything through relative URLs. | | `photocraft-web-.js` | wasm-bindgen glue (generated, ES module) | -| `photocraft-web-_bg.wasm` | The app: about 19 MiB raw, 8 MiB with gzip, 5.6 MiB with Brotli (see [Sizes](#sizes)) | +| `photocraft-web-_bg.wasm` | The app: about 23 MiB raw, 10.5 MiB with gzip (see [Sizes](#sizes)) | | `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | | `fonts/` (optional) | Fonts loaded on demand, listed in `fonts/manifest.txt` (see [Fonts](#fonts)) | @@ -74,19 +74,20 @@ To verify the browser deployment, open the page, open an image and save/export i ## Sizes -Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11): +Measured on 2026-10-12 (0.8.0, `packaging/web/package.sh`; gzip `-9`): -| File | Raw | gzip | Brotli | -|---|---|---|---| -| `photocraft-web-_bg.wasm` | 19,680,726 bytes (18.8 MiB) | 8,177,260 (7.8 MiB) | 5,868,731 (5.6 MiB) | -| `photocraft-web-.js` | about 160 KB | about 23 KB | about 20 KB | +| File | Raw | gzip | +|---|---|---| +| `photocraft-web-_bg.wasm` | 24,167,186 bytes (23.0 MiB) | 10,998,053 (10.5 MiB) | +| `photocraft-web-.js` | about 170 KB | about 26 KB | Per-file upload limits: Cloudflare Pages and Workers static assets reject any file over **25 MiB** (26,214,400 bytes). The wasm fits, and `packaging/web/package.sh` fails the build if it ever grows past 24 MiB, so a release can't ship a file a common host refuses. (Releases up to 0.2.0 shipped a 25.8 MiB wasm, which Cloudflare rejected; issue #198.) The size comes from the -`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel crates kept at full -speed) plus `wasm-opt -Oz`. +`wasm-release` Cargo profile (fat LTO, size-optimized code with the pixel and file-decoding +crates kept at speed) plus `wasm-opt -Oz`, and from storing the bundled fonts (PhotoCraft's and +egui's) and the translation catalogs compressed, unpacked on first use. ## Any path works diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 105f927435..86a49151d4 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -94,7 +94,7 @@ fn registered_languages(source: &str) -> Result, String> { let catalog = if code == "en" { None } else { - // `catalog!("xx")` embeds `xx.tsv` (deflated by ui-egui's build.rs). + // `catalog!("xx")` embeds `xx.tsv` (packed by ui-egui's build.rs). let marker = "catalog!(\""; let start = block.find(marker).ok_or_else(|| format!("language `{code}` has no catalog! entry"))? + marker.len(); let rest = block.get(start..).ok_or_else(|| format!("language `{code}` has a malformed catalog path"))?; From fd21e734111ef0af3f26b77e3e59d3d651c50f91 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 16:06:16 +0200 Subject: [PATCH 138/523] Format: check pattern sizes on load (#3478) (#3484) * Format: check pattern sizes on load (#3478) A .pcraft pattern's width/height came straight from the manifest, so a 1 KB file could declare a 65535x65535 pattern without tiles; painting it allocated width*height*16 bytes (abort) and the engine's u32 size math overflowed. - format: reject patterns 0 px on a side (Corrupt) or above the PSD reader's 30 000 px edge (LimitExceeded). - doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26, a 1 GiB f32 RGBA working copy). - compose/gpu: patterns too large to paint paint nothing instead of allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan). - engine: brush.texturePattern refuses them; average_color uses u64 math and a bounded corner. Co-Authored-By: Claude Opus 5.5 * gpu: skip unpaintable patterns in the Bevel texture too The Bevel & Emboss texture element uploaded the pattern through pattern_rgba, which now returns no pixels for a pattern too large to paint; filter it like the other GPU pattern lookups. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/pattern.rs | 24 +++++++++++++++--- crates/doc/src/pattern.rs | 17 +++++++++++++ crates/engine/src/fill_cmds.rs | 2 +- crates/engine/src/paint_cmds.rs | 2 +- crates/engine/src/pattern_cmds.rs | 23 ++++++++++++++--- .../engine/src/retouch_cmds/pattern_stamp.rs | 2 +- crates/format/src/convert.rs | 9 +++++++ crates/format/tests/corruption.rs | 25 +++++++++++++++++++ crates/gpu/src/fx.rs | 2 +- crates/gpu/src/plan.rs | 6 ++--- 10 files changed, 98 insertions(+), 14 deletions(-) diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index a55c2c8abc..a2c5c52fb9 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -45,7 +45,7 @@ impl<'a> PreparedPatterns<'a> { pub(crate) fn get(&self, id: &str, name: &str) -> Option> { let p = photocraft_doc::pattern::find(self.patterns, id, name)?; - if p.is_empty() { + if !p.is_paintable() { return None; } let bytes = usize::try_from(p.width) @@ -87,9 +87,13 @@ impl<'a> PreparedPatterns<'a> { /// colour mode `mode`. A pattern keeps its own colours (an RGB pattern stays RGB, in the /// library and in the document), but in a Grayscale or CMYK document it paints that mode's /// colours: what Edit › Fill writes into the document's pixels, and what Photoshop shows (the -/// pattern in colour in its picker, filled in grey). +/// 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 mut px = vec![[0.0f32; 4]; (p.width as usize).saturating_mul(p.height as usize)]; + 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 { @@ -117,8 +121,9 @@ impl Tile { } /// The tile of `p` painted into a document of colour mode `mode` (see [`pattern_rgba`]). + /// `None` when the pattern isn't [paintable](Pattern::is_paintable). pub fn new_in(p: &Pattern, mode: ColorMode) -> Option { - if p.is_empty() { + if !p.is_paintable() { return None; } let (w, h) = (p.width as usize, p.height as usize); @@ -255,6 +260,17 @@ mod tests { assert_eq!(px[8], [0.0, 0.0, 1.0, 1.0]); // x = 4 } + #[test] + fn patterns_too_large_to_paint_are_skipped_without_allocating() { + // Only the size is declared (no tiles): a hostile file can claim any size. + let p = Pattern { id: "big".into(), name: "Big".into(), width: 9000, height: 8000, surface: Surface::new(PixelFormat::RGBA8) }; + assert!(!p.is_paintable()); + assert!(Tile::new(&p).is_none()); + assert!(pattern_rgba(&p, ColorMode::Rgb).is_empty()); + let list = [p]; + assert!(PreparedPatterns::new(&list, 0).get("big", "").is_none()); + } + #[test] fn link_phase_scale_and_angle_move_the_origin() { let t = Tile::new(&checker()).unwrap(); diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 47279e2c86..9a43c0a467 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -5,6 +5,13 @@ 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). +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. +pub const MAX_PAINT_PIXELS: u64 = 1 << 26; + /// 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)] @@ -57,6 +64,16 @@ impl Pattern { pub fn is_empty(&self) -> bool { self.width == 0 || self.height == 0 } + + /// Pixels in the tile (`width × height`, never overflowing). + pub fn pixel_count(&self) -> u64 { + u64::from(self.width) * u64::from(self.height) + } + + /// Whether the tile has pixels and is small enough to paint (see [`MAX_PAINT_PIXELS`]). + pub fn is_paintable(&self) -> bool { + !self.is_empty() && self.pixel_count() <= MAX_PAINT_PIXELS + } } /// Display form of a stored pattern name (see [`Pattern::display_name`]). diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index a7350dafc1..130977ce28 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -106,7 +106,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { "pattern" => { let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?; let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); - let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?; + let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty or too large to paint"))?; // Photoshop's Fill uses the canvas origin. let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle); out["pattern"] = json!(pat.id); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 7366758e93..ad16cd85d9 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -55,7 +55,7 @@ fn bucket(s: &mut Session, p: &Value) -> Result { let pattern = if source == "pattern" { let pat = crate::pattern_cmds::resolve_param(s, "paint.bucket", p)?; let tile = photocraft_compose::pattern::Tile::new(&pat) - .ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty".into() })?; + .ok_or_else(|| EngineError::BadParams { cmd: "paint.bucket".into(), msg: "the pattern is empty or too large to paint".into() })?; let (scale, angle, _, phase) = crate::pattern_cmds::placement(p); Some((tile, scale, angle, phase)) } else { diff --git a/crates/engine/src/pattern_cmds.rs b/crates/engine/src/pattern_cmds.rs index 21bee1283c..d58fd9e827 100644 --- a/crates/engine/src/pattern_cmds.rs +++ b/crates/engine/src/pattern_cmds.rs @@ -326,8 +326,12 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result { fn brush_texture(s: &mut Session, p: &Value) -> Result { let pat = resolve_param(s, "brush.texturePattern", p)?; + let n = pat.is_paintable().then(|| usize::try_from(pat.pixel_count()).ok()).flatten(); + let Some(n) = n else { + 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]; (pat.width * pat.height) as usize]; + let mut px = vec![[0.0f32; 4]; n]; pat.surface.read_rgba_into(pat.rect(), &mut px); let lum: Vec = px .iter() @@ -348,8 +352,11 @@ fn brush_texture(s: &mut Session, p: &Value) -> Result { /// A swatch colour for UIs (average of the tile), straight RGB. pub fn average_color(p: &Pattern) -> Color { - let mut px = vec![[0.0f32; 4]; (p.width * p.height) as usize]; - p.surface.read_rgba_into(p.rect(), &mut px); + // 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]]); Color::rgb(s[0] / n, s[1] / n, s[2] / n) @@ -560,4 +567,14 @@ mod tests { assert!(s.tools.brush.texture.enabled); assert!(matches!(s.tools.brush.texture.pattern, photocraft_paint::Pattern::Tile(_))); } + + #[test] + fn a_pattern_too_large_to_paint_is_refused_not_allocated() { + // width × height overflows u32: this panicked (debug) or wrapped (release). + let mut s = session(8); + 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}"); + } } diff --git a/crates/engine/src/retouch_cmds/pattern_stamp.rs b/crates/engine/src/retouch_cmds/pattern_stamp.rs index cb9e0adfe9..0dc9f102ac 100644 --- a/crates/engine/src/retouch_cmds/pattern_stamp.rs +++ b/crates/engine/src/retouch_cmds/pattern_stamp.rs @@ -45,7 +45,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { return Err(bad(CMD, "`points` is empty")); }; let phase = stamp_phase(p, CMD, aligned, first)?; - let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty"))?; + let tile = Tile::new(&pat).ok_or_else(|| bad(CMD, "the pattern is empty or too large to paint"))?; reject_huge_stroke(CMD, &stroke)?; let seed = p.get("seed").and_then(Value::as_u64).unwrap_or_else(|| { diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 4dbbb6aa38..47ea5383d7 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -592,6 +592,15 @@ impl Loader<'_> { } let mut patterns = Vec::with_capacity(m.patterns.len()); for p in &m.patterns { + // Sizes come from the file: a pattern with no tiles can claim any size, and renderers + // allocate per pixel. + let max = photocraft_doc::pattern::MAX_EDGE; + if p.width == 0 || p.height == 0 { + return Err(FormatError::corrupt(format!("pattern `{}` is {}×{} px", p.name, p.width, p.height))); + } + if p.width > max || p.height > max { + return Err(FormatError::LimitExceeded(format!("pattern `{}` is {}×{} px (max {max} per side)", p.name, p.width, p.height))); + } patterns.push(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface: self.surface(&p.surface)? }); } let layer_comps = m.layer_comps.iter().map(|c| self.comp(c)).collect::>>()?; diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index 2f47203b3f..2ef07bcd45 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -419,6 +419,31 @@ fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() { } } +/// Rewrites the first pattern's declared size (its tiles stay as saved). +fn with_pattern_size(w: u64, h: u64) -> Vec { + rebuild(sample_cached(), |n, d| { + if n != "manifest.json" { + return Some(d); + } + let mut v: serde_json::Value = serde_json::from_slice(&d).unwrap(); + let p = &mut v["document"]["patterns"][0]; + assert!(p.is_object(), "the sample has a pattern"); + p["width"] = w.into(); + p["height"] = h.into(); + Some(serde_json::to_vec(&v).unwrap()) + }) +} + +#[test] +fn pattern_sizes_from_the_file_are_checked() { + // A pattern can claim any size without tiles; painting it allocated per pixel (abort). + let huge = load_from_bytes(&with_pattern_size(65_535, 65_535)); + assert!(matches!(huge, Err(FormatError::LimitExceeded(_))), "{huge:?}"); + let empty = load_from_bytes(&with_pattern_size(0, 24)); + assert!(matches!(empty, Err(FormatError::Corrupt(_))), "{empty:?}"); + assert!(load_from_bytes(&with_pattern_size(32, 24)).is_ok()); +} + /// A canvas is a few bytes of manifest JSON; one past the budget is refused before any work (#2935). /// The default budget is the largest canvas the app creates (300000 × 300000, as `file.new`, /// Canvas Size and PSB import allow), so everything a user can save reopens. diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index 9eba5888b8..cc396d8ba0 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -389,7 +389,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, h = b.push(st); } if let Some(t) = &bv.texture - && let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| !p.is_empty()) + && let Some(pat) = photocraft_doc::pattern::find(patterns, &t.id, &t.name).filter(|p| p.is_paintable()) { // Texture element (`effects::bevel_height`): luminance × depth / unit added. let unit = if bv.depth.abs() > 1e-6 { (g.depth / bv.depth).abs().max(1e-3) } else { g.width.max(1.0) }; diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index f5a3b573e4..fb135dc135 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -803,7 +803,7 @@ impl<'a> Planner<'a> { // compose::render_fill: the pattern tiled from the layer's frame (transparent // when missing), then the layer's masks. let empty = self.clear(); - let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) else { return Ok(empty) }; + let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) else { return Ok(empty) }; let frame = photocraft_compose::fill_frame(layer, self.cx.canvas); let anchor = if frame.is_empty() { (0.0, 0.0) } else { (f64::from(frame.x0), f64::from(frame.y0)) }; let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle)); @@ -1097,7 +1097,7 @@ impl<'a> Planner<'a> { }; for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e - && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) + && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) { let paint = Paint::Pattern(pat, placement(anchor, *link, *phase, *scale, *angle)); l = self.paint(l, content, Cov::One, &paint, common.blend, common.opacity, F_GATE, clip, sb); @@ -1312,7 +1312,7 @@ impl<'a> Planner<'a> { match p { FxPaint::Color(c) => Paint::Color(c.to_rgb()), FxPaint::Gradient(g) => Paint::Gradient(g), - FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| !p.is_empty()) { + FxPaint::Pattern { name, id, scale } => match photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) { Some(pat) => Paint::Pattern(pat, placement(anchor, true, (0.0, 0.0), *scale, 0.0)), None => Paint::None, }, From 73a56d76032a9d94cda7c838d110f82c3914d8be Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 16:08:20 +0200 Subject: [PATCH 139/523] Format: overflow-safe blob decode and bounds-checked ZIP reads (#3486) (#3501) - store: `max_blob_bytes as u64 + 1` overflowed for usize::MAX (debug panic; release take(0), so every blob failed). The blob read and the tile decode now share decompress_bounded(), which saturates. - zip: ZipReader::read sliced `data_offset..data_offset + compressed` unchecked although Entry is pub + Clone; a foreign entry is now a Corrupt error. - store: Collect::tile reuses tile_le(); Object::content() gives the stored bytes once (existing_object_is_valid no longer builds them twice). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/format/src/store.rs | 65 +++++++++++++++---------------- crates/format/src/zip.rs | 20 +++++++++- crates/format/tests/corruption.rs | 9 +++++ 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/crates/format/src/store.rs b/crates/format/src/store.rs index 674c99f214..c14b488250 100644 --- a/crates/format/src/store.rs +++ b/crates/format/src/store.rs @@ -30,14 +30,24 @@ fn compress(data: &[u8]) -> Vec { ruzstd::encoding::compress_to_vec(data, ruzstd::encoding::CompressionLevel::Fastest) } -fn decompress(data: &[u8], expected: usize, what: &str) -> Result> { - let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; - let mut out = Vec::with_capacity(expected.min(64 << 20)); - (&mut dec).take(expected as u64 + 1).read_to_end(&mut out).map_err(|e| FormatError::corrupt(format!("{what}: zstd: {e}")))?; - if out.len() != expected { - return Err(FormatError::corrupt(format!("{what}: expected {expected} bytes, got {}", out.len()))); +/// Decodes a zstd frame of at most `max` bytes (`LimitExceeded` beyond). With `exact`, the +/// decoded length must be exactly that (`Corrupt` otherwise). +fn decompress_bounded(data: &[u8], max: usize, exact: Option, what: &str) -> Result> { + let zstd = |e: &dyn std::fmt::Display| FormatError::corrupt(format!("{what}: zstd: {e}")); + let mut dec = ruzstd::decoding::StreamingDecoder::new(data).map_err(|e| zstd(&e))?; + let mut out = Vec::with_capacity(exact.unwrap_or(0).min(max).min(64 << 20)); + // `max` may be `usize::MAX`: one byte past it must not overflow. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + (&mut dec).take(limit).read_to_end(&mut out).map_err(|e| zstd(&e))?; + match exact { + Some(n) if out.len() != n => Err(FormatError::corrupt(format!("{what}: expected {n} bytes, got {}", out.len()))), + None if out.len() > max => Err(FormatError::LimitExceeded(format!("{what} exceeds {max} bytes"))), + _ => Ok(out), } - Ok(out) +} + +fn decompress(data: &[u8], expected: usize, what: &str) -> Result> { + decompress_bounded(data, expected, Some(expected), what) } fn hash_bytes(b: &[u8]) -> Hash { @@ -228,12 +238,7 @@ impl Fetch for LoadFetch<'_> { let z = self.src.get(&path, self.opts.max_blob_bytes)?; // Blob length is not in the manifest; the frame header bounds it and // we cap at max_blob_bytes. - let mut dec = ruzstd::decoding::StreamingDecoder::new(&z[..]).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; - let mut data = Vec::new(); - (&mut dec).take(self.opts.max_blob_bytes as u64 + 1).read_to_end(&mut data).map_err(|e| FormatError::corrupt(format!("{path}: zstd: {e}")))?; - if data.len() > self.opts.max_blob_bytes { - return Err(FormatError::LimitExceeded(format!("{path} exceeds max_blob_bytes"))); - } + let data = decompress_bounded(&z, self.opts.max_blob_bytes, None, &path)?; self.account(data.len())?; if hash_bytes(&data) != hash { return Err(FormatError::corrupt(format!("{path}: content does not match its hash"))); @@ -313,13 +318,7 @@ impl Sink for Collect<'_> { self.tiles.entry(h.clone()).or_insert_with(|| (tile.clone(), format.sample)); return h; } - let h = if cfg!(target_endian = "big") { - let mut b = tile.bytes().to_vec(); - swap_to_le(&mut b, format.sample); - hash_bytes(&b) - } else { - hash_bytes(tile.bytes()) - }; + let h = hash_bytes(&tile_le(tile, format.sample)); if let Some(cache) = self.hash_cache.as_deref_mut() { cache.insert(key, (Arc::downgrade(tile), h.clone())); } @@ -414,21 +413,23 @@ enum Object { impl Object { fn compressed(&self) -> Vec { - match self { - Object::Tile(t, s) => compress(&tile_le(t, *s)), - Object::Blob(b) => compress(b), - } + compress(&self.content()) } - fn matches_content_hash(&self, compressed: &[u8], what: &str) -> bool { - let expected = match self { + /// The bytes this object stores (tiles little-endian). + fn content(&self) -> std::borrow::Cow<'_, [u8]> { + match self { Object::Tile(tile, sample) => tile_le(tile, *sample), Object::Blob(blob) => std::borrow::Cow::Borrowed(blob.as_slice()), - }; - decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(&expected)) + } } } +/// Whether `compressed` decodes to exactly `expected`. +fn content_matches(expected: &[u8], compressed: &[u8], what: &str) -> bool { + decompress(compressed, expected.len(), what).is_ok_and(|actual| hash_bytes(&actual) == hash_bytes(expected)) +} + fn file_signature(metadata: &std::fs::Metadata) -> Option { #[cfg(unix)] let unix = { @@ -587,10 +588,8 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result< } let signature = file_signature(&metadata); - let expected_len = match object { - Object::Tile(tile, sample) => tile_le(tile, *sample).len(), - Object::Blob(blob) => blob.len(), - }; + let expected = object.content(); + let expected_len = expected.len(); // This is the same bound used by the tile loader. It comfortably includes // zstd framing overhead while keeping damaged files bounded when inspected. let max_compressed_len = expected_len.saturating_add(expected_len / 8).saturating_add(4096); @@ -604,7 +603,7 @@ fn existing_object_is_valid(path: &Path, object: &Object, what: &str) -> Result< if compressed.len() > max_compressed_len { return Ok((false, None)); } - Ok((object.matches_content_hash(&compressed, what), signature)) + Ok((content_matches(&expected, &compressed, what), signature)) } /// A bundle path and its compressed bytes. diff --git a/crates/format/src/zip.rs b/crates/format/src/zip.rs index 1e296e16d1..9ec92c2fba 100644 --- a/crates/format/src/zip.rs +++ b/crates/format/src/zip.rs @@ -201,7 +201,12 @@ impl<'a> ZipReader<'a> { if e.uncompressed > max { return Err(FormatError::LimitExceeded(format!("zip entry `{}` is {} bytes (max {max})", e.name, e.uncompressed))); } - let raw = &self.bytes[e.data_offset..e.data_offset + e.compressed]; + // `Entry` is public and cloneable: one from another archive must not index out of range. + let raw = e + .data_offset + .checked_add(e.compressed) + .and_then(|end| self.bytes.get(e.data_offset..end)) + .ok_or_else(|| FormatError::corrupt(format!("zip entry `{}` out of range", e.name)))?; let data = match e.method { 0 => raw.to_vec(), 8 => { @@ -366,6 +371,19 @@ mod tests { assert!(matches!(archive.read_by_name("manifest.json", usize::MAX), Err(FormatError::Corrupt(_)))); } + #[test] + fn an_entry_from_another_archive_is_an_error_not_a_panic() { + let mut big = ZipWriter::new(); + big.add("a", &[7; 4096]).unwrap(); + let big = big.finish().unwrap(); + let foreign = ZipReader::new(&big).unwrap().entries[0].clone(); + let mut small = ZipWriter::new(); + small.add("a", b"x").unwrap(); + let small = small.finish().unwrap(); + let r = ZipReader::new(&small).unwrap(); + assert!(matches!(r.read(&foreign, usize::MAX), Err(FormatError::Corrupt(_)))); + } + #[test] fn offset_access_rejects_overflow() { assert!(u16_at(&[], usize::MAX).is_err()); diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index 2ef07bcd45..d293abed1e 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -419,6 +419,15 @@ fn tile_coordinates_outside_the_i32_pixel_range_are_rejected() { } } +#[test] +fn an_unlimited_blob_budget_still_loads_blobs() { + // `max_blob_bytes + 1` overflowed (debug panic; release read zero bytes, failing every blob). + let doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap(); + let opts = LoadOptions { max_blob_bytes: usize::MAX, ..LoadOptions::default() }; + assert_eq!(load_from_bytes_with(&bytes, &opts).unwrap(), doc); +} + /// Rewrites the first pattern's declared size (its tiles stay as saved). fn with_pattern_size(w: u64, h: u64) -> Vec { rebuild(sample_cached(), |n, d| { From 423421a879858fbfe46ac701a082ba706055c5f3 Mon Sep 17 00:00:00 2001 From: null 00000111 <287216888+s17sh70x2jfl031ag@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:19:06 -0700 Subject: [PATCH 140/523] Fuzz: ignore the same build leftovers in every fuzz crate (#3585) crates/raw/fuzz had no .gitignore, crates/psd/fuzz didn't ignore Cargo.lock and crates/format/fuzz didn't ignore coverage/, so running those fuzz targets left untracked files in the checkout. All five fuzz crates now ignore target/, corpus/, artifacts/, coverage/ and Cargo.lock, as codecs and affinity already did. --- crates/format/fuzz/.gitignore | 1 + crates/psd/fuzz/.gitignore | 1 + crates/raw/fuzz/.gitignore | 5 +++++ 3 files changed, 7 insertions(+) create mode 100644 crates/raw/fuzz/.gitignore diff --git a/crates/format/fuzz/.gitignore b/crates/format/fuzz/.gitignore index d932d310fa..fc87536ae7 100644 --- a/crates/format/fuzz/.gitignore +++ b/crates/format/fuzz/.gitignore @@ -2,3 +2,4 @@ target/ corpus/ artifacts/ Cargo.lock +coverage/ diff --git a/crates/psd/fuzz/.gitignore b/crates/psd/fuzz/.gitignore index 1a45eee776..5c404b9583 100644 --- a/crates/psd/fuzz/.gitignore +++ b/crates/psd/fuzz/.gitignore @@ -2,3 +2,4 @@ target corpus artifacts coverage +Cargo.lock diff --git a/crates/raw/fuzz/.gitignore b/crates/raw/fuzz/.gitignore new file mode 100644 index 0000000000..ab0eaa1a49 --- /dev/null +++ b/crates/raw/fuzz/.gitignore @@ -0,0 +1,5 @@ +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock From 0b935da8822f5294c22548f9facacf4c3fcd06e3 Mon Sep 17 00:00:00 2001 From: null 00000111 <287216888+s17sh70x2jfl031ag@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:22:11 -0700 Subject: [PATCH 141/523] HEIF: move the heic-rs pin from 0.1.1 to 0.1.4 (#3637) heic-rs 0.1.2 to 0.1.4 fix six panics on malformed files, found by fuzzing: four in release builds (a uuid box too small for its extended type, a block starting right of the picture, a conformance window larger than the picture, and a reference row below the picture) and two with overflow checks on (a PCM bit depth above the sample bit depth, a slice QP out of range). The API, MSRV and output are unchanged. Both pins move together: crates/heif and the codecs fuzz target, which calls heic-rs directly. guarded() stays as the backstop it was meant to be. --- Cargo.lock | 4 ++-- crates/codecs/fuzz/Cargo.toml | 2 +- crates/heif/Cargo.toml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 160403c482..a3948361bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2331,9 +2331,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "heic-rs" -version = "0.1.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" dependencies = [ "rayon", ] diff --git a/crates/codecs/fuzz/Cargo.toml b/crates/codecs/fuzz/Cargo.toml index 9cbb3d9815..62ec96c523 100644 --- a/crates/codecs/fuzz/Cargo.toml +++ b/crates/codecs/fuzz/Cargo.toml @@ -16,7 +16,7 @@ cargo-fuzz = true libfuzzer-sys = "0.4" photocraft-codecs = { path = "..", features = ["heif"] } # Called directly too: the codec turns its panics into errors (see decode_heif.rs). -heic-rs = "=0.1.1" +heic-rs = "=0.1.4" [profile.release] debug = 1 diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml index f8ddf6457a..18210b0c46 100644 --- a/crates/heif/Cargo.toml +++ b/crates/heif/Cargo.toml @@ -9,11 +9,11 @@ rust-version.workspace = true [dependencies] # Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files; # moving the pin is a reviewed change (re-run the fuzz target and the heif corpus tests). -heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"] } # The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] } +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"] } [lints] workspace = true From 27fecdab449fdc8a3336a747a6569860a60eec7e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 16:22:43 +0200 Subject: [PATCH 142/523] Translate print queue guidance in new Vietnamese catalog --- crates/ui-egui/src/i18n/vi.tsv | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca091c14d8..f7cd59aebb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1524,6 +1524,7 @@ cameraRaw Shadows Vùng tối Working… Đang xử lý… Reading… Đang đọc… Cancel (Esc) Hủy (Esc) + Manage submitted jobs in the system print queue Quản lý các lệnh in đã gửi trong hàng đợi in của hệ thống Esc to cancel Esc để hủy Cancel opening Hủy mở tệp Cancelled opening {name} Đã hủy mở {name} From 019a391f40728f94dc8e1ce8f3bda65bead81fcf Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 16:33:21 +0200 Subject: [PATCH 143/523] Codecs: decode only the requested EXR part; Cryptomatte uses the cumulative cap (#3721) (#3722) read_layer decoded every part and picked one; use a first-valid-layer reader that declines all other parts. Cryptomatte now goes through read_meta and read_layer. Enable exr/rayon on native targets. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codecs/Cargo.toml | 2 ++ crates/codecs/src/codecs/exr.rs | 33 ++++++++++++++++----- crates/codecs/src/codecs/exr_cryptomatte.rs | 12 ++------ crates/codecs/tests/exr_multipart.rs | 29 ++++++++++++++++++ 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3948361bb..f17edd1171 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1743,6 +1743,7 @@ dependencies = [ "miniz_oxide 0.8.9", "num-complex", "pulp", + "rayon-core", "smallvec", "zune-inflate", ] diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index a55dcf1213..c462d0a39b 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -36,6 +36,8 @@ photocraft-heif = { path = "../heif", optional = true } # Orientation is applied a band of rows per task on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rayon.workspace = true +# EXR block (de)compression runs on the rayon pool natively; wasm stays single-threaded. +exr = { version = "1.73", default-features = false, features = ["rayon"] } [dev-dependencies] proptest.workspace = true diff --git a/crates/codecs/src/codecs/exr.rs b/crates/codecs/src/codecs/exr.rs index a4014db0b3..2506dd062b 100644 --- a/crates/codecs/src/codecs/exr.rs +++ b/crates/codecs/src/codecs/exr.rs @@ -35,7 +35,7 @@ fn map_exr(e: exr::error::Error) -> CodecError { /// Read the headers without the `exr` crate's validation (it rejects deep parts outright), /// after checking every part's declared size against the limits, before any pixel allocation. -fn read_meta(bytes: &[u8], limits: &Limits) -> Result { +pub(super) fn read_meta(bytes: &[u8], limits: &Limits) -> Result { let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(err)?; // Flat decoding reads every part at once, so the parts' sizes are also capped together. let mut total: u64 = 0; @@ -93,12 +93,31 @@ fn pick_part(meta: &MetaData) -> usize { best } -/// Decode the flat layers via the `exr` crate (it handles every flat compression) and -/// return the requested part's layer. -fn read_layer(bytes: &[u8], index: usize) -> Result>, CodecError> { - let image = - read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err(map_exr)?; - image.layer_data.into_iter().nth(index).ok_or_else(|| err(format!("part {index} is missing from the file"))) +/// Selects one part for the `exr` crate's first-valid-layer reader: `create_channels_reader` +/// is asked about the headers in file order and the first `Ok` wins, so every header before +/// `index` (and, harmlessly, those after it) is declined. Only the chosen part's chunks are +/// then decompressed and only its planes allocated; the other parts are skipped unread. +struct OnlyPart { + inner: C, + index: usize, + seen: std::cell::Cell, +} + +impl<'s, C: ReadChannels<'s>> ReadChannels<'s> for OnlyPart { + type Reader = C::Reader; + + fn create_channels_reader(&'s self, header: &exr::meta::header::Header) -> exr::error::Result { + let position = self.seen.get(); + self.seen.set(position.saturating_add(1)); + if position == self.index { self.inner.create_channels_reader(header) } else { Err(exr::error::Error::Invalid("part not requested".into())) } + } +} + +/// Decode one part's flat layer via the `exr` crate (it handles every flat compression). +/// Other parts are not decompressed, so an AOV file costs one part per request, not the file. +pub(super) fn read_layer(bytes: &[u8], index: usize) -> Result>, CodecError> { + let only = OnlyPart { inner: read().no_deep_data().largest_resolution_level().all_channels(), index, seen: std::cell::Cell::new(0) }; + only.first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map(|image| image.layer_data).map_err(map_exr) } fn layer_to_image(layer: &Layer>) -> Result { diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index f28c57ab47..2fabd0c6c5 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -11,7 +11,6 @@ use std::io::Cursor; use exr::meta::MetaData; use exr::meta::attribute::AttributeValue; -use exr::prelude::{ReadChannels, ReadLayers, read}; use crate::Format; use crate::error::CodecError; @@ -200,7 +199,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< .iter() .find(|l| l.name.eq_ignore_ascii_case(layer_name)) .ok_or_else(|| CodecError::unsupported(F, format!("no Cryptomatte layer named {layer_name}")))?; - let meta = MetaData::read_from_buffered(Cursor::new(bytes), false).map_err(|e| CodecError::malformed(F, e))?; + // Also caps all parts' declared sizes together, like every other flat decode. + let meta = super::exr::read_meta(bytes, limits)?; if meta.requirements.has_deep_data { return Err(CodecError::unsupported(F, "this file has deep parts; Cryptomatte decoding stays with flat files")); } @@ -214,13 +214,7 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::>() as u64); limits.check_bytes(w32, h32, per_pixel)?; - let image = read().no_deep_data().largest_resolution_level().all_channels().all_layers().all_attributes().from_buffered(Cursor::new(bytes)).map_err( - |e| match e { - exr::error::Error::NotSupported(m) => CodecError::unsupported(F, format!("not supported: {m}")), - e => CodecError::malformed(F, e), - }, - )?; - let ex_layer = image.layer_data.get(layer.part).ok_or_else(|| CodecError::malformed(F, "the Cryptomatte part is missing"))?; + let ex_layer = super::exr::read_layer(bytes, layer.part)?; let mut ids: Vec> = vec![Vec::new(); layer.channels.len()]; let mut coverages: Vec> = vec![Vec::new(); layer.channels.len()]; diff --git a/crates/codecs/tests/exr_multipart.rs b/crates/codecs/tests/exr_multipart.rs index c8f9a3bc3f..f106d37791 100644 --- a/crates/codecs/tests/exr_multipart.rs +++ b/crates/codecs/tests/exr_multipart.rs @@ -197,3 +197,32 @@ fn parts_are_capped_together() { let e = exr_info(&aov_file(), &limits).unwrap_err().to_string(); assert!(e.contains("across 3 parts"), "{e}"); } + +/// Two ZIP1 parts of 4x4 RGBA; every chunk of part 0 is then overwritten with garbage, so +/// inflating it fails. +fn file_with_corrupt_first_part() -> Vec { + let px = |k: f32| -> Vec<(&'static str, Vec)> { ["R", "G", "B", "A"].iter().map(|c| (*c, vec![k; 16])).collect() }; + let mut bytes = write_image(vec![layer_f32("first", Compression::ZIP1, &px(0.25)), layer_f32("second", Compression::ZIP1, &px(0.75))]); + let mut cursor = std::io::Cursor::new(&bytes[..]); + exr::meta::MetaData::read_from_buffered(&mut cursor, false).unwrap(); + let table = cursor.position() as usize; + // Part 0 owns the first four of the eight chunks (one scan line per ZIP1 block). + for i in 0..4 { + let at = table + i * 8; + let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize; + let size = u32::from_le_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize; + bytes[offset + 12..offset + 12 + size].fill(0xA5); + } + bytes +} + +#[test] +fn decoding_one_part_does_not_decode_the_others() { + let bytes = file_with_corrupt_first_part(); + // Part 0 is unreadable ... + assert!(decode_exr_part(&bytes, 0, &DecodeOptions::default()).is_err()); + // ... but asking for part 1 must not touch it. + let img = decode_exr_part(&bytes, 1, &DecodeOptions::default()).expect("part 1 decodes on its own"); + assert_eq!((img.width(), img.height()), (4, 4)); + assert!(img.to_normalized().iter().all(|v| near(*v, 0.75)), "wrong pixels"); +} From a8b52f1dbfd4b7f0ace9c1ce0aaab22d5c6caa41 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 16:34:45 +0200 Subject: [PATCH 144/523] Codecs: enable JPEG and CCITT fax compression for TIFF (#3712) (#3727) Co-authored-by: Claude Opus 5.5 --- crates/codecs/Cargo.toml | 2 +- crates/codecs/tests/tiff_regressions.rs | 55 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index c462d0a39b..25f3f09be0 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -22,7 +22,7 @@ heif = ["dep:photocraft-heif"] thiserror.workspace = true image = { workspace = true, features = ["gif", "bmp", "tga", "ico", "qoi", "hdr"] } png = "0.18" -tiff = { version = "0.11", default-features = false, features = ["deflate", "lzw"] } +tiff = { version = "0.11", default-features = false, features = ["deflate", "fax", "jpeg", "lzw"] } exr = { version = "1.73", default-features = false } half = "2" serde_json.workspace = true diff --git a/crates/codecs/tests/tiff_regressions.rs b/crates/codecs/tests/tiff_regressions.rs index a4524e0874..a7c1aa486e 100644 --- a/crates/codecs/tests/tiff_regressions.rs +++ b/crates/codecs/tests/tiff_regressions.rs @@ -1,3 +1,6 @@ +mod common; + +use common::tiffgen::*; use photocraft_codecs::{Image, decode, exif_orientation, upright_exif}; fn classic_white_is_zero() -> Vec { @@ -95,3 +98,55 @@ fn big_tiff_orientation_6_and_8_are_applied_to_decoded_pixels() { assert_eq!(image.data(), &upright, "orientation {orientation}"); } } + +fn with_compression(mut dir: Dir, code: u64) -> Dir { + for entry in &mut dir.entries { + if entry.0 == 259 { + entry.2 = vec![V::N(code)]; + } + } + dir +} + +#[test] +fn jpeg_compressed_tiff_decodes() { + // A flat mid-grey 16x16 image as a baseline JPEG stored in one strip (Compression = 7). + let mut jpeg = Vec::new(); + let gray = vec![100u8; 16 * 16]; + jpeg_encoder::Encoder::new(&mut jpeg, 95).encode(&gray, 16, 16, jpeg_encoder::ColorType::Luma).expect("encode the JPEG strip"); + let mut dir = with_compression(strips(16, 16, 8, 1, 1, 16, &[0; 256]), 7); + dir.chunks = vec![jpeg]; + let bytes = build(true, false, &[dir], &[0]).bytes; + + let image = decode(&bytes).expect("a JPEG-compressed TIFF should decode"); + assert_eq!(image.dimensions(), (16, 16)); + assert!(image.data().iter().all(|&v| v.abs_diff(100) <= 4), "{:?}", image.data().get(..4)); +} + +#[test] +fn ccitt_group4_tiff_decodes() { + // 8x3 bilevel: a white row, then two black rows. Bits: V0 | H(white 0, black 8) | V0 V0 | EOFB. + let bits = "1".to_string() + "001" + "00110101" + "000101" + "11" + "000000000001" + "000000000001"; + let mut data = Vec::new(); + for chunk in bits.as_bytes().chunks(8) { + let mut byte = 0u8; + for (i, c) in chunk.iter().enumerate() { + if *c == b'1' { + byte |= 0x80 >> i; + } + } + data.push(byte); + } + let mut dir = with_compression(strips(8, 3, 1, 1, 0, 3, &[0; 3]), 4); + dir.chunks = vec![data]; + let bytes = build(true, false, &[dir], &[0]).bytes; + + let image = decode(&bytes).expect("a CCITT G4 TIFF should decode"); + assert_eq!(image.dimensions(), (8, 3)); + let d = image.data(); + assert_eq!(d.len(), 24, "{d:?}"); + assert!(d[..8].iter().all(|&v| v == d[0]), "{d:?}"); + assert!(d[8..].iter().all(|&v| v == d[8]), "{d:?}"); + assert_ne!(d[0], d[8], "{d:?}"); + assert_eq!(d[0], 255, "white row of a WhiteIsZero image"); +} From dc35ce763009969434d873c2063f59d96660b52d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:35:47 +0300 Subject: [PATCH 145/523] fix(linux): avoid duplicate AppImage desktop launcher (#3623) (#3641) --- packaging/linux/AppRun | 50 +++++++++++++------ .../linux/ai.storyteller.photocraft.desktop | 3 ++ packaging/linux/apprun-test.sh | 26 ++++++++-- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/packaging/linux/AppRun b/packaging/linux/AppRun index e8543ee2bb..42008b177b 100755 --- a/packaging/linux/AppRun +++ b/packaging/linux/AppRun @@ -35,25 +35,47 @@ integrate() { src_desktop="$APPDIR/usr/share/applications/$DESK_ID.desktop" [ -f "$src_desktop" ] || return 0 - # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop - # TryExec, which would hide the entry; everything else is shipped as packaged. + # If an external AppImage integrator (AppImageLauncher, appimaged, Gear Lever) is + # already managing this AppImage, do not write a second launcher (#3623); remove our + # own if left over from an earlier run, and still install the hicolor icons below. apps="$data/applications" mkdir -p "$apps" 2>/dev/null || return 0 - tmp="$apps/.$DESK_ID.tmp.$$" - esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" - if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then - if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then - rm -f "$tmp" - else - if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then - command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 - else - rm -f "$tmp" + external=0 + if [ -n "${DESKTOPINTEGRATION:-}" ]; then + external=1 + else + for f in "$apps"/*.desktop; do + [ -f "$f" ] && [ "$f" != "$apps/$DESK_ID.desktop" ] || continue + if grep '^Exec=' "$f" 2>/dev/null | grep -Fq "$appimage"; then + external=1 + break fi + done + fi + if [ "$external" = 1 ]; then + if [ -f "$apps/$DESK_ID.desktop" ]; then + rm -f "$apps/$DESK_ID.desktop" 2>/dev/null || true + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 fi else - rm -f "$tmp" - return 0 + # Rewrite Exec to this AppImage (the binary is not on the host PATH) and drop + # TryExec, which would hide the entry; everything else is shipped as packaged. + tmp="$apps/.$DESK_ID.tmp.$$" + esc="$(printf '%s' "$appimage" | sed 's/[\\&|]/\\&/g')" + if sed -e "s|^Exec=.*|Exec=\"$esc\" %F|" -e '/^TryExec=/d' "$src_desktop" 2>/dev/null >"$tmp"; then + if cmp -s "$tmp" "$apps/$DESK_ID.desktop"; then + rm -f "$tmp" + else + if mv -f "$tmp" "$apps/$DESK_ID.desktop" 2>/dev/null; then + command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$apps" >/dev/null 2>&1 + else + rm -f "$tmp" + fi + fi + else + rm -f "$tmp" + return 0 + fi fi # Icons: per-file compare so an unchanged install costs a handful of cmp calls. diff --git a/packaging/linux/ai.storyteller.photocraft.desktop b/packaging/linux/ai.storyteller.photocraft.desktop index 70ce82148e..a32b55cef5 100644 --- a/packaging/linux/ai.storyteller.photocraft.desktop +++ b/packaging/linux/ai.storyteller.photocraft.desktop @@ -13,3 +13,6 @@ StartupWMClass=ai.storyteller.photocraft Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;image;editor;psd;layers;retouch;paint; MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; +# AppRun registers the desktop entry and icons itself (#593); tell external +# AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623). +X-AppImage-Integrate=false diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh index 0c81a04052..475fb5ee5e 100755 --- a/packaging/linux/apprun-test.sh +++ b/packaging/linux/apprun-test.sh @@ -48,6 +48,7 @@ expected_exec="Exec=\"$(readlink -f "$APPIMAGE")\" %F" grep -Fxq "$expected_exec" "$DESKTOP" || fail "Exec not rewritten: $(grep '^Exec=' "$DESKTOP")" grep -q '^TryExec=' "$DESKTOP" && fail "TryExec kept, the entry would be hidden" grep -Fxq "Icon=$APP_ID" "$DESKTOP" || fail "Icon line lost" +grep -Fxq "X-AppImage-Integrate=false" "$DESKTOP" || fail "X-AppImage-Integrate=false missing (#3623)" grep -Fxq 'Categories=Graphics;2DGraphics;RasterGraphics;Photography;' "$DESKTOP" || fail "metadata lines lost" if command -v desktop-file-validate >/dev/null; then desktop-file-validate "$DESKTOP" || fail "generated desktop entry is invalid" @@ -71,23 +72,40 @@ export APPIMAGE="$TMP/moved.AppImage" "$APPDIR/AppRun" >/dev/null || fail "launch after move exited non-zero" grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %F" "$DESKTOP" || fail "Exec not refreshed after move" -# 6. Opt-out installs nothing. +# 6. External AppImage integrator (#3623): if another .desktop file in $XDG_DATA_HOME/applications +# already launches this AppImage (e.g. AppImageLauncher's appimagekit-*.desktop), AppRun removes +# its own duplicate .desktop entry while keeping the hicolor icons installed. +ext_desktop="$XDG_DATA_HOME/applications/appimagekit-photocraft.desktop" +printf '[Desktop Entry]\nType=Application\nName=PhotoCraft\nExec="%s" %%F\nIcon=%s\n' "$(readlink -f "$APPIMAGE")" "$APP_ID" >"$ext_desktop" +"$APPDIR/AppRun" >/dev/null || fail "launch with external integrator desktop entry exited non-zero" +[ ! -e "$DESKTOP" ] || fail "duplicate desktop entry kept alongside external integrator entry" +[ -f "$ext_desktop" ] || fail "external integrator desktop entry removed" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons missing with external integrator" +rm -f "$ext_desktop" + +# 7. DESKTOPINTEGRATION set by an external integrator skips creating a second .desktop entry. +rm -rf "$XDG_DATA_HOME" +DESKTOPINTEGRATION=AppImageLauncher "$APPDIR/AppRun" >/dev/null || fail "DESKTOPINTEGRATION launch exited non-zero" +[ ! -e "$DESKTOP" ] || fail "DESKTOPINTEGRATION still wrote duplicate desktop entry" +[ -f "$XDG_DATA_HOME/icons/hicolor/256x256/apps/$APP_ID.png" ] || fail "icons not installed under DESKTOPINTEGRATION" + +# 8. Opt-out installs nothing. rm -rf "$XDG_DATA_HOME" PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero" [ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated" -# 7. An unwritable data dir never blocks the launch. +# 9. An unwritable data dir never blocks the launch. mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro" out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir" [ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out" chmod u+w "$TMP/ro" -# 8. No $APPIMAGE (extracted AppDir): launch only, nothing installed. +# 10. No $APPIMAGE (extracted AppDir): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero" [ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE" -# 9. A path that would need Exec escaping (here a `$`): launch only, nothing installed. +# 11. A path that would need Exec escaping (here a `$`): launch only, nothing installed. rm -rf "$XDG_DATA_HOME" odd="$TMP/odd\$(id).AppImage" : >"$odd" From f4fa53140cb005854021a8ef69b8b6a4c09e3ffc Mon Sep 17 00:00:00 2001 From: Streetblock Date: Mon, 12 Oct 2026 16:43:07 +0200 Subject: [PATCH 146/523] ops: count selection masks in history pixel budget (#3614) * ops: count selection masks in history pixel budget * test: expect unclipped HDR levels in float documents * web: optimize engine code for the wasm size budget --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ops/src/lib.rs | 88 +++++++++++++++++++++++++++++++++++++++-- crates/ops/src/tiles.rs | 3 ++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/crates/ops/src/lib.rs b/crates/ops/src/lib.rs index 70cbebe0f7..2a82edb417 100644 --- a/crates/ops/src/lib.rs +++ b/crates/ops/src/lib.rs @@ -5,8 +5,8 @@ //! stores whole-document snapshots per transaction, which is simple, obviously correct, and the //! same approach Photoshop's History panel exposes to users (one state per step). //! -//! Memory is bounded by `max_states` plus an approximate byte budget. Only tiles *not shared* -//! with the current document count against it. +//! Memory is bounded by `max_states` plus an approximate byte budget. The current document's +//! tiles and tiles only history holds count against it, including selection masks. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -248,7 +248,7 @@ impl History { } } -/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels) not already in `seen`. +/// Bytes of the pixel tiles of `doc` (layers, masks, alpha channels, selection) not already in `seen`. fn tile_bytes(doc: &Document, seen: &mut HashSet) -> usize { let mut n = 0usize; tiles::surfaces(doc, |surface| { @@ -408,6 +408,85 @@ mod tests { assert_eq!(h.unique_bytes(&cur), 256 * 256 * 4); } + #[test] + fn selection_tiles_count_current_and_history_without_double_counting() { + use photocraft_doc::{PixelFormat, Surface}; + for depth in SampleType::ALL { + let mut doc = Document::new("selection", Size::new(300, 64), ColorMode::Rgb, depth); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + selection.write_pixel(1, 1, &[1.0]); + selection.write_pixel(257, 1, &[1.0]); + doc.selection = Some(selection); + let mut cur = Arc::new(doc); + let tile = match depth { + SampleType::U8 => 65_536, + SampleType::U16 => 131_072, + SampleType::F32 => 262_144, + }; + let mut h = History { max_bytes: usize::MAX, ..History::default() }; + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "current selection counts without any history"); + assert_eq!(h.unique_bytes(&cur), 0); + edit(&mut h, &mut cur, "Rename", |doc| doc.name = "renamed".into()); + assert_eq!(h.pixel_bytes(&cur), 2 * tile, "the shared selection tiles count once"); + edit(&mut h, &mut cur, "Selection", |doc| { + doc.selection.as_mut().unwrap().write_pixel(1, 1, &[0.5]); + }); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "only the changed selection tile is copied"); + assert_eq!(h.unique_bytes(&cur), tile); + h.trim(&cur); + assert_counter(&h, &cur); + let selected_value = cur.selection.as_ref().unwrap().sample_channel(1, 1, 0); + edit(&mut h, &mut cur, "Deselect", |doc| doc.selection = None); + assert_eq!(h.pixel_bytes(&cur), 3 * tile, "deselecting leaves the previous masks in history"); + assert_eq!(h.unique_bytes(&cur), 3 * tile); + assert_counter(&h, &cur); + cur = h.undo(cur).unwrap().0; + assert_eq!(cur.selection.as_ref().unwrap().sample_channel(1, 1, 0), selected_value); + assert_eq!(h.pixel_bytes(&cur), 3 * tile); + assert_eq!(h.unique_bytes(&cur), tile); + assert_counter(&h, &cur); + // Coalescing can change the current mask without recording another state. + Arc::make_mut(&mut cur).selection.as_mut().unwrap().write_pixel(257, 1, &[0.25]); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert_eq!(h.trim(&cur), 0); + assert_counter(&h, &cur); + cur = h.redo(cur).unwrap().0; + assert!(cur.selection.is_none()); + assert_eq!(h.pixel_bytes(&cur), 4 * tile); + assert_counter(&h, &cur); + } + } + + #[test] + fn selection_byte_budget_drops_old_masks_and_keeps_the_last_undo() { + use photocraft_doc::{PixelFormat, Surface}; + for depth in SampleType::ALL { + let mut doc = Document::new("selection", Size::new(64, 64), ColorMode::Rgb, depth); + doc.selection = Some(Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false))); + let mut cur = Arc::new(doc); + let tile = 256 * 256 * depth.bytes(); + let mut h = History::default(); + for i in 1..=6 { + edit(&mut h, &mut cur, "Selection", |doc| { + doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 8.0]); + }); + } + assert_eq!(h.pixel_bytes(&cur), 6 * tile); + h.max_bytes = 3 * tile; + assert_eq!(h.trim(&cur), 4); + assert_eq!(h.past_len(), 2); + assert_eq!(h.pixel_bytes(&cur), 3 * tile); + assert_eq!(h.unique_bytes(&cur), 2 * tile); + assert_counter(&h, &cur); + h.max_bytes = 1; + assert_eq!(h.trim(&cur), 1); + assert_eq!(h.past_len(), 1, "the most recent undo survives even below one mask's size"); + assert_eq!(h.pixel_bytes(&cur), 2 * tile); + cur = h.undo(cur).unwrap().0; + assert_counter(&h, &cur); + } + } + fn assert_counter(h: &History, current: &Document) { let counter = h.tiles.as_ref().expect("budget initializes the counter"); let total = tile_bytes(current, &mut HashSet::new()).saturating_add(h.unique_bytes(current)); @@ -506,11 +585,13 @@ mod tests { fn counter_tracks_eviction_undo_redo_branching_purge_and_clones() { for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { let mut cur = Arc::new(Document::with_background("h", Size::new(300, 64), ColorMode::Rgb, depth, Color::WHITE)); + Arc::make_mut(&mut cur).selection = Some(photocraft_doc::Surface::new(photocraft_doc::PixelFormat::GRAY8)); let mut h = History::new(4); h.max_bytes = usize::MAX; for i in 0..8 { edit(&mut h, &mut cur, "Paint", |doc| { doc.layers[0].surface_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0, 0.0, 0.0, 1.0]); + doc.selection.as_mut().unwrap().write_pixel(1, 1, &[i as f32 / 10.0]); }); h.trim(&cur); if i >= 1 { @@ -547,6 +628,7 @@ mod tests { gray.write_pixel(1, 1, &[1.0]); doc.layers[0].mask = Some(LayerMask { surface: gray.clone(), ..LayerMask::reveal_all() }); doc.channels.push(AlphaChannel::new("alpha", gray.clone())); + doc.selection = Some(gray.clone()); doc.quick_mask = Some(AlphaChannel::new("quick", gray)); doc.layers.push(doc.layers[0].clone()); let mut cur = Arc::new(doc); diff --git a/crates/ops/src/tiles.rs b/crates/ops/src/tiles.rs index c4178b5b31..feb639c871 100644 --- a/crates/ops/src/tiles.rs +++ b/crates/ops/src/tiles.rs @@ -30,6 +30,9 @@ pub(crate) fn surfaces(doc: &Document, mut visit: impl FnMut(&Surface)) { for channel in doc.channels.iter().chain(&doc.quick_mask) { visit(&channel.surface); } + if let Some(selection) = &doc.selection { + visit(selection); + } } impl Tiles { From 9163afbdcfa8f6ab5e7b6967c2aa98eab9d8c608 Mon Sep 17 00:00:00 2001 From: zenjabba Date: Mon, 12 Oct 2026 10:45:05 -0400 Subject: [PATCH 147/523] Verify PngSuite archives and cached corpus files (#3973) Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com> --- xtask/pngsuite.sha256 | 178 +++++++++++++ xtask/src/corpus.rs | 538 +++++++++++++++++++++++++++++++++++++-- xtask/src/corpus_pins.rs | 4 + 3 files changed, 696 insertions(+), 24 deletions(-) create mode 100644 xtask/pngsuite.sha256 diff --git a/xtask/pngsuite.sha256 b/xtask/pngsuite.sha256 new file mode 100644 index 0000000000..a13873a568 --- /dev/null +++ b/xtask/pngsuite.sha256 @@ -0,0 +1,178 @@ +71e1f5bf865131b5d9996e76a78372146283e5089e50f900ae927a6458b62101 PngSuite.LICENSE +a2853f2be68e460dcb9992819125b74503a01383e79f79a04b971ffa9d248544 PngSuite.README +6cf3bcd1757bfad2a7ce9c9659d4f609297a0828cafc7c9eddee18c5576ba9e9 PngSuite.png +9bf1c4610280efecc291361e2b7b026d602d03a7137f476ff84af626bb7b3a62 basi0g01.png +50ed4ff9c1c80578ae58ead6fb28ea2e44016b5b56a7b3e95c235ec86f97c0d8 basi0g02.png +0cce4212157dae7c0f71f5f72cef39cecab3347de455da0ace7de83eebd47849 basi0g04.png +b9300750e7414c9fed68cc7f4f531f5e2cdd2724f50142e8aa3a0070836bece9 basi0g08.png +d354026bdd0aa6ed7bedea7c92e5eef2ebbd4854848b8a0a48f562cc71bfe80f basi0g16.png +b2690d4475cdc39faf5a7d2de20de4e14eb96c6360fa791ec2003b4085ecacde basi2c08.png +ae13eba5fabbf7cbe8c01ec4458cdaa7e6bfdfcbd5109e831efaac9f39096027 basi2c16.png +8e1edfbc0382e0b545c70209271cedead2d530df1ce40f5296f0749226b36006 basi3p01.png +85f304eaaf6831b2bcc2c08892b947fef52f06337aaf23312d6326e367d73f77 basi3p02.png +78890ee2c4da71e460b5a1527efe7dc17d673ebe997f49ca1b4dcd7347d21e56 basi3p04.png +ca103eef7a7c50abc7af9d5ec0dc9fdc07668e2f030adbce226e050dcec9eea3 basi3p08.png +dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b basi4a08.png +ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e basi4a16.png +fe70ea20c8447e82abb7bc9dd05344a61a4631cc69e0974378df8f6263af0337 basi6a08.png +7fdd6bf08f04692bcf06b5c4262e7f76c49d8adb992789fd3f05aa0435645cd8 basi6a16.png +c8b1364d7771dd2f5a1b2d7d633abcf3f48dafee608558ecd2e5fc98f61894cd basn0g01.png +ca172e92d179c77b3d321ad501e452a59bdaafafde8ebd74fd0669c4eae58efc basn0g02.png +fb2d42c4c8f5c51590e675994a9b0c718fbf71c80e28142f231cfa7c5b0e26e8 basn0g04.png +268d061075d1dd2eeec62b31303d09f6998549e1bfb447a5f09c80a2b0978ac3 basn0g08.png +1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 basn0g16.png +c90e86090a625661b19960cafdde6e347d6e32d73837aaae533f66dd3f099506 basn2c08.png +1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b basn2c16.png +c2d7cd682df5f74506b33a5d70c344aaee248fda79fdfef8e873426fd6f2b75b basn3p01.png +0466bb7ed9984cf03b70704564bcffab1df8ec0e8167473ba0f75e4fedce5a8f basn3p02.png +e1fc7be978d3149b98533d0076245ae64353b7967290f4204c1282ecb4ec1aba basn3p04.png +d58256cd2eb16b5740d4c1403d25ce43d8dd03e270627ab709d2fb141e3d904c basn3p08.png +7be24b618bfd437b921ae0caf9341d112338c6290cc62466deb19d2c7a512968 basn4a08.png +1c92ffd11d2fc89a36f170d0239668436407f5c8e3f8d93483fb3fc6bca361d7 basn4a16.png +559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 basn6a08.png +8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa basn6a16.png +dfae01a4548e30519157bb51ce29d4de3b35b74288e9568b4d5911de01c6048b bgai4a08.png +ef7df23ccd912309a4f89ca3c3094bf5ab55add25218e571e51cc6cb11cfad9e bgai4a16.png +559c594166eb156f461c9beff0f053196730dc998fdb0d2b801c89e6680860a5 bgan6a08.png +8f9d81060aebf4576461403c5057de7f23f73157016b659402b906df805845aa bgan6a16.png +71d041df5c8949ce07d5e93b3c9f752cefc75ef7326c49df05ae73915f1a755f bgbn4a08.png +c45de44d3442bd33072d68456541b10e0cc57c5d8bfc809208995d939091eb2d bggn4a16.png +2da3c3efa01bf166958852a104ece92daaede8ffb5e0452a55d40526258a8bf5 bgwn6a08.png +875bc2b44a4355551c51260def9664da68be1af8c14b82f76173aaf01ddb1929 bgyn6a16.png +c88909e74e039dd3df829bf24144b487171e53b17f5c5c07cbc08688247d24b5 ccwn2c08.png +dc365b39d49c287669d837872dd59aef30763a611dfc8c046c481e35e49a390a ccwn3p08.png +438018f19c85e582cb586ac7cca2220008ecb7fc70ce50e8f2a76b494c128a20 cdfn2c08.png +a068eaf1f7040490e08eda3259befb6689849dd0ff8bb4cc03c705d117cb2b9f cdhn2c08.png +ee08dfa303a3c09171280fc1821180dc0a4c3591af7e9077e8ce0435642c76d2 cdsn2c08.png +a849d89ba3d92146940d7c56d2fe74c9c3ec96725c7f24eefaf44160a6304de2 cdun2c08.png +587a8624228d3f605f475bd751a1f3a97f0bbab590c0ff518bce5da3abcb6f2b ch1n3p04.png +e3cace549047c6e5e693b9b135d53f7f9ca5b1088bf30cc408634598ba53758a ch2n3p08.png +79ccaf4abe5091e1f65f8a61496363ed3312ebb9ea1aaa98affadac3d98507de cm0n0g04.png +127bb50d224ac3ebff36160482a03b9e6686072e86c8ff4cd43e3ce2271b2315 cm7n0g04.png +774b384d0ad73698e7d93fc5a29976b332c619045e5966276c609b11459a4152 cm9n0g04.png +bc812de2b83abc9ae2a6d1ae01e91f9090214b9ed8fee1d56cdfea58f4f259d0 cs3n2c16.png +267104dced0858dd828cdd41edc0e87042c7094c77191565192a67045064b23f cs3n3p08.png +028fa49fbba3954c2eba27a4c3607196e87c1dae74192df3b3ad7afe5e0a3f0c cs5n2c08.png +a7a4021983d6b22685529cbafb65cd087c9e1af1c5d205c2e13e3efc4ae87b6a cs5n3p08.png +d90ddab7313e4e1fd7f20f16a6c546b449697fbcac1a7776298a74896f4662da cs8n2c08.png +5bffdb88e307a851f8e85b137a69c59e557a881db816e990061934c0155c894c cs8n3p08.png +081d1ec26b4157fbc032b76dc716321420f2d032a425de046557c7842766826d ct0n0g04.png +259116f8ecf849d83d824688eb02b4575aed11980f02ab6fb123281c25a6459d ct1n0g04.png +4583e63d1bdfa18b6abb47439dee9a73bed311ed1b0a71d22bed75fe4767832b cten0g04.png +d0607280c3539a8934c5cfd22788b382cdc31321cdedc75ae8a29e3948580ba6 ctfn0g04.png +5f4c87f5a8f589d029a908050880ac5d6aed3810e55e75444802d6d3fb27cf33 ctgn0g04.png +8ef799e59871755578e563c5b411bc3895c2f7112c115aaa921b6c04fe255bf2 cthn0g04.png +ca91332ecc04e5faac1f39ca4df4996b072a93f3e574e7f4b50d52aafbfe67b6 ctjn0g04.png +c0765e635a6423ec64f0314400c3df20745c4a4469ffcc323c7b00ba66b4aceb ctzn0g04.png +d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809 exif2c08.png +d055cc0bb505b37c6ecf88a808d71675eca5dd0ee683d70ebc9c0be0e1bb8e57 f00n0g08.png +0fe92b2aa2da04c885d1dbd85c834716f6cdd946364d97dcd597bb79d9e14427 f00n2c08.png +956ddaf133e6d9a8d36f1e00604d87fbc2d0295e933bb8caaef517e7d87e5342 f01n0g08.png +ef072ec6815ebf9b33e0553d2e4e4e7ed6911860a2512c67bcd10a9f0f09b9de f01n2c08.png +1f5b49c06ecc2a1756f0423b3de936bd18497866d035ac8eded78a6f17408a2c f02n0g08.png +ca4b937b3c587d5c007f193a2eec14dc96b0d23ff7d6aa9004e3badd1af9fe8f f02n2c08.png +d0563ae30c6ce7a01eae7a5cdc0ef780175bfa7653888ed93f17bef5fe4b2107 f03n0g08.png +2d101e3ef4f78a69437034671e93fe11faac0cfc4d44210dcca1b944caa886f7 f03n2c08.png +fec1a3c53833d0ddefa4486a98934c393cb4335b2dc31da70e6540a4686f1457 f04n0g08.png +c365c24153cb69fd3c162f00b296ae23a71a1595645d1aeb0ad23af680d7b4be f04n2c08.png +8ac0f095d2a943157e820fa121bccde08d5230af1b5830c3041d5f4da3524eba f99n0g04.png +3494b914dd1b094afa9a74a89bfa75219030e21af6fca3ed66c9d45032a047e9 g03n0g16.png +abce774b9624952c2d0c57ff681790ba620cf5616fe7e0ef0600600cdce3c22a g03n2c08.png +541389db0c72721c6c49501e1dbf6e32df41b4fcfa318676955e278f3f4332b3 g03n3p04.png +72350a2d9db2df09b626f1f1dd48061e9c749d471bf19814d2c5c10acbf007e5 g04n0g16.png +bfd3edfc4d85a43383556a51023533fa3888184aa53a6b43769910782beebfce g04n2c08.png +5ea85ebefb58b2ab113fc05bf1cb79ffb068359e3a5620843590a0a7b59bd49a g04n3p04.png +be19721d28e0b268af1e29e8190c952b94315bd08899383ae2c91e6dc8c4f889 g05n0g16.png +74ab5b8477992d65e220eeaff8df466522c1b392fa90c6f9935da5bdb7113c9d g05n2c08.png +2f46afd7ec15836b523cbce6965f34d89833b8d777afffaf0f53bce620d5f65e g05n3p04.png +9070f843981f765cc8e26a63751be5d58631d85a8a7b4e826358f80e8a679d03 g07n0g16.png +ca4cd32d222f65fb7beb7c9f40d8b6833b36d552b830ff1a1d67dae73b8ed0d8 g07n2c08.png +b8e7abeabcfd50b71cca78b6cdff7372310cfc179cd63304c3e904f469b333cb g07n3p04.png +a22486acb74ee5f947db3a20b1f314a9dd8aca3339840049ee559f4d676a6b5c g10n0g16.png +4ac23729aea109e8f6b1e831448115cf02dcd97559e9403847cbe6dd7e5c7347 g10n2c08.png +36cc2ea4b5b33cd18e0a01c7de85dbcaf7161d258cd0f2265a90cd835b15cf6d g10n3p04.png +1fae707d809296d2cf0d6aa653bead6199644fb1c859558ec7d2d69e709a423a g25n0g16.png +9b128cfa1bb417dd99251914d729062621fcea9f346168549b16a75fe030b0f6 g25n2c08.png +4197a0a25c4f74d42c2285d75b9fabe1eedbed45bc224ff1ec1784f5797e9dbf g25n3p04.png +1a1fe155f40c11d79350b3f84c7eb2b29719037e57073cb7ae2ab9e8323f9360 oi1n0g16.png +1e1c28ec0ee5da6224404b4005076dd30e2335a1a80c0ca722046a245c4a2b3b oi1n2c16.png +142bc4b1e9f8a9e3b0ba3d2c2025e485bf7e76df88f914bf8c9041b4562cc080 oi2n0g16.png +2c523f138e0ade1bed8f31d59ec0f34b82660b192d6af8b8f395f82b9ba9dabb oi2n2c16.png +aa2d77723ba44f654a41a2ab97e01271fe942fecd2f299c0a81da90acb2887ac oi4n0g16.png +661220a0ed03ca6d63ec8dcaac738d4a9fd089b491c40dd896267ffb6cf5015d oi4n2c16.png +ae70c9effd0d9957a0d42771bc987529a1f3715e068057fae63f0c7e50d5c53a oi9n0g16.png +fef98e9bebe6c4046c29f2568f7a735305c8661aa01f9c37a718135e5bc4f941 oi9n2c16.png +72406b6173ec20dd059e8cea594ef5bc8f81bac5e416e2b53058d2debd51d195 pp0n2c16.png +d5a6f964a601a5e1815525117c12c32b61e2aa09fd6040776d35f2f7115b7110 pp0n6a08.png +b321b9ac8f2aa641475b6936365954181726dcb8662dbf641284d82a83cc22a5 ps1n0g08.png +6c59d673032fe2d04dbd2ea91cc9bc7bebe7d5ab84911f00e36fb28994793576 ps1n2c16.png +bd2ae1b45fd771aaa8c57dccd183f3834fc9742f6dfc692606865049efb116e5 ps2n0g08.png +120c62ce9d014f7772c4b38a446991b5bbe6a22ef5ea59239d32e023bc167585 ps2n2c16.png +235eb82f1337e3fcefbdca566a53af3d3523db7588ba3cd7fa5dab19afe48a41 s01i3p01.png +6a4f6da9fe36d195219ad2beeac1a0423e2ee6de03dfb064a3b6fa31623eb230 s01n3p01.png +e7d26ab4b31e7066b0674a4374c093967a9f1761b96836aa3dbe624acd73ab52 s02i3p01.png +f4b324f2b1d013ee77f632e42129c7cd3eca55da846e775e744a1f224ad1efcd s02n3p01.png +4513dec80f54ffd04a28834b114254d6e6d66e52e32e0059174474f2fb15d6bc s03i3p01.png +4dee297bd7f747f87488509af11a1e1e0531fa67e6e8d0f8ab7f4df7940f0ff3 s03n3p01.png +918f94f3557e10de91efa680df507c8eafc347fa5023af43037701398128ad0f s04i3p01.png +e73c52f764b684ec5133cce11f7f18cc60065acdd308e2c3d871e0a792a1080e s04n3p01.png +77fe040e17167f9656afe46cf0031832e635745e4e7f19ba12dee32f5ec03d96 s05i3p02.png +1b9e244a5c1d6cc9830391fff4eac565ada026d000e60faf7525c8add6bdc726 s05n3p02.png +cf8d5fdb34c0afd38b1b4745bed6167771767ccdfe847c4d8766f7fcc5c41176 s06i3p02.png +5caabfc4b147fad5e49e558143faaab91888324240acf45e5db8fc49f7a7f667 s06n3p02.png +d9874bfad10353a9ea2876ee388c3e7f81f697ed141d0d79c5c2019d62a7a698 s07i3p02.png +e7d447394f5fa64c27353216af387352a77a1fb534922a2d974e60d965d4e414 s07n3p02.png +62adf855c6b1e3a007f9dcf3ce2ba244616c089269a511dd939e89a4b0853453 s08i3p02.png +544fdd476e566e8aef4c98604124ae05acef48be20d1aebb9b6674b08b2abbb6 s08n3p02.png +f2cf5b3109690704d106a0b22ea079e36064cc6077e1fb9a1ba87397d0bbcca1 s09i3p02.png +6be8f58b638e7f7114750f5796f66f650db8fb7242a36183850f02117f4eaa65 s09n3p02.png +a27e0dbf923ef868e7399560b76c23a50b2d8926cddd9775e2bc4e050273f205 s32i3p04.png +c80d9fc7ded0b7834ec11d0e324540c7be74016b61bc302a1d167218fa35fbf9 s32n3p04.png +cc6ad98dcf686d92f1165d951f90fac70e568cedb1fcbba33e8890d0e6cdad4d s33i3p04.png +998d08e084e3eea288984475bfed0d48ee2d8250e20b96755898b59152e36a34 s33n3p04.png +83eb2e82eb14ae3c608833ef371a6f472c4c6e63b7e9f4f92f5472f975793b97 s34i3p04.png +a25451168c0b03ab8f52dcc60c6b9c12bc5df5d55afabe1892b87d0a856bb4a0 s34n3p04.png +db5988c27ad577de1e494cb50229dc2772678531adde2db84493907eae003e54 s35i3p04.png +cd99fc4cc69bda1ff8e8697d8b423ae8d9e84a58ba1d2b480751953b6869e3b3 s35n3p04.png +a36b40927b730a31d60c7818fb4a32e67ace06393cdaa26edc09520b6cee066d s36i3p04.png +ebc08f6a1e0918c5d3a9a08e184c23019a3cf90d8843e4b71bf2ae16040b1a84 s36n3p04.png +565c79404c4b9e46c27edb2f93d7b26b93ec0a0481ab7a21fc405152a21f53ba s37i3p04.png +964078b1f3f472268b8ac97a8976b4d1cdb6a47e6243d67eddcb0205e65b9f61 s37n3p04.png +b107f120ffaffcb949ac4547e0f91555ac681f78c7e68c724e7ceddfd8e5387b s38i3p04.png +045b0f5ea35874741bf6cac40f2e63ec8726450ab241475df4b7e9303c39e339 s38n3p04.png +4b5a6d8c04c8c70e1de3bcb6f7b5cc3ba359b13e28de6f52e21a291d4bc8a937 s39i3p04.png +2115b6cad0140ea97fe6ec056b47307ef1e8b5c00db5f08707a0572b165e5f08 s39n3p04.png +48a3f19b5c3b2f7eca17fd34765a2a3e385d770f24c357bc66bd74417eb60a5a s40i3p04.png +c4598ea73b1ec015bef7dcfd7f67bc7bfc41546619953d1a92cd9b8657a7033a s40n3p04.png +c5e7eeaffa677bfac002ee968a0423e0fea3be7701d39ccd6a015df1d2d85e40 tbbn0g04.png +8fcfd031a5b716201854fdab25691feed6cd3ed7b713a51b94e1f43fc63fe0c1 tbbn2c16.png +8eb43a6020fda8ed7c8c9b88b974ef794ee8452db29212ae7e5c11003bfd76a3 tbbn3p08.png +49711ee271f96099521b83b75d58ed2f76d42ed46cb3c06656fde2cad697cf59 tbgn2c16.png +6f798628935a021fe446dce3d62861d1f7369aaf01bad10daa4917c5abccad7d tbgn3p08.png +8223d8b0c5f84b149f0c1f0e1f658346e07fcbd03ffc02b2e9eeea1481eb19bf tbrn2c08.png +01e86f108b244bdee4d72e384aced8a62369965ca2993ee6ef88f131c647f7d1 tbwn0g16.png +978b28cec43bd219517083b63661bb30a7b9cdf9fd4476ab4e70844565980d51 tbwn3p08.png +7b4eb64b4bd7eb36417752873ddaca027ce77a07f474b2af569f4eea1e984111 tbyn3p08.png +f05e317f64a038123faf96fdbd6fc3ab3eec14e1fc1eb87fd7da4d996be63526 tm3n3p02.png +a253e055d8cd94049cd95e9392e7aa60f3d5c922a8f0cccb84762b0c5f547545 tp0n0g08.png +5dced3fde56b950e61ce86f4ad54458336f66389ce8ca68524d4fd8ec546ff5b tp0n2c08.png +da40ddb18bd45db32ed03dc89e2250b096d808efe975a88477e4f5a91a4677c9 tp0n3p08.png +50bc927fe6cf1816454a7c27f8c743734be8db45e37e5ee3ede540f4e895e6c5 tp1n3p08.png +4059f7e6a1c5bac1801f70e09f9ec1e1297dcdce34055c13ab2703d6d9613c7e xc1n0g08.png +e252a0e7df3e794e52ce4a831edafef76e7043d0d8d84019db0f7fd0b30e20f4 xc9n2c08.png +3c0c2a68dd416a7be79400e0e2ceb358ba3191d5309d4bad9dd04b32ff6d5b60 xcrn0g04.png +71e4b2826f61556eda39f3a93c8769b14d3ac90f135177b9373061199dbef39a xcsn0g01.png +c1287690808e809dc5d4fb89d8a7fd69ed93521f290abd42021ca00a061a1ba4 xd0n2c08.png +00b53c3bbd0641454521b982bc6f6bcfda7c91f1874cefb3a9bac37d80a1a269 xd3n2c08.png +16e5b40fb2600db1af20fb79ff715c2869255e2d4bef20702f16534e5dd6a847 xd9n2c08.png +f9d1fb2a708703518368c392c74765a6e3e5b49dbb9717df3974452291032df9 xdtn0g01.png +318864720c8fc0dbe4884035f2183cc3bd3d92ec60d447879982942600e9fe2e xhdn0g08.png +968fd21abb8acf40fbca90676de3abcb3a6e35b01ba7a3b9190184eafb99c83d xlfn0g04.png +776701227c7094dd10b78f508430b8ea4f03471d072096382cbcca628dea1d2b xs1n0g01.png +9dd7e93ba9211f0caee09d8f12e37742936c469f45fcad21e577c9f0640cf99e xs2n0g01.png +166c8633d116d1c26631cb98f3a75340cf4385fa7ae6a67913bc78e709bcb30d xs4n0g01.png +7a380568beeac969908196ac89cd2347c96ac2f26fdc2b8e0314b62c59f0e308 xs7n0g01.png +e4767d3259e1b331c9ece9fb07fc30f385879ac12b3cd0e1cb923f3bfac81dcf z00n2c08.png +939abfc6e0a3e349bb5bcd8c4e427a6cdb2c7e0c23a773aefa1c4fc9f07e4cb4 z03n2c08.png +21652fe42f472acd83dcb207806add5737db0020b21d6b38b0a00be3e9e37990 z06n2c08.png +98d90a7e61aa1a9ac9d99af44dd25c70cd5a7b4a11a85da60f82923e18a78f9f z09n2c08.png diff --git a/xtask/src/corpus.rs b/xtask/src/corpus.rs index 43f943c807..e6c929a1be 100644 --- a/xtask/src/corpus.rs +++ b/xtask/src/corpus.rs @@ -1,10 +1,15 @@ //! `cargo xtask corpus [...]` (fetch test corpora) and `cargo xtask test-corpus [...]` (fetch, //! then run the corpus tests). Pins: `corpus_pins.rs`. Docs: `docs/development.md` › Test corpora. -use std::path::PathBuf; -use std::process::Command; +use std::collections::BTreeMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; -use crate::corpus_pins::{self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_URL, PSD_TOOLS_COMMIT}; +use crate::corpus_pins::{ + self, AG_PSD_COMMIT, HEIC_RS_COMMIT, OPENEXR_COMMIT, PHOTOCRAFT_CORPUS_COMMIT, PILLOW_HEIF_COMMIT, PNGSUITE_SHA256, PNGSUITE_URL, PSD_TOOLS_COMMIT, +}; use crate::pinned::{PinnedCorpus, USER_AGENT}; use crate::{cargo, root, run}; @@ -119,7 +124,7 @@ fn status(present: bool) -> &'static str { fn list() { let corpus = root().join("corpus"); let png = corpus.join("pngsuite"); - let png_ok = pngsuite_count(&png) > 0; + let png_ok = verify_pngsuite(&png, PNGSUITE_MANIFEST).is_ok(); println!( "Test corpora live under {} (gitignored, never committed). The corpus tests are opt-in (cargo feature `corpus`) and fail, not skip, when a corpus is missing. CI always runs them. @@ -162,31 +167,212 @@ Pins: xtask/src/corpus_pins.rs. Moving one: change it, then -- --update-ma ); } -fn pngsuite_count(dir: &std::path::Path) -> usize { - std::fs::read_dir(dir).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0) +const PNGSUITE_MANIFEST: &str = include_str!("../pngsuite.sha256"); +const PNGSUITE_MAX_ARCHIVE_BYTES: u64 = 1024 * 1024; +const PNGSUITE_MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; +static PNGSUITE_STAGE_ID: AtomicU64 = AtomicU64::new(0); + +/// Do not trust cached filenames, symlinks, or a file's size remaining unchanged during a read. +fn pngsuite_bytes(path: &Path, limit: u64) -> Result, String> { + let meta = std::fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() || meta.len() > limit { + return Err(format!("{}: expected a regular file of at most {limit} bytes", path.display())); + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .map_err(|e| format!("{}: {e}", path.display()))? + .take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() as u64 > limit { + return Err(format!("{}: exceeds {limit} bytes", path.display())); + } + Ok(bytes) } -/// PngSuite: 175 PNGs from a fixed release archive (skipped when already present). -fn fetch_pngsuite() -> Result<(), String> { - let corpus = root().join("corpus"); +fn verify_pngsuite(dir: &Path, manifest: &str) -> Result<(), String> { + let mut expected = BTreeMap::new(); + for line in manifest.lines() { + let Some((hash, name)) = line.split_once(" ") else { return Err("pngsuite: invalid manifest line".into()) }; + if hash.len() != 64 + || !hash.bytes().all(|c| c.is_ascii_hexdigit()) + || name.is_empty() + || name.contains("..") + || !name.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + || expected.insert(name, hash).is_some() + { + return Err("pngsuite: invalid manifest entry".into()); + } + } + if expected.is_empty() { + return Err("pngsuite: empty manifest".into()); + } + let meta = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + if !meta.is_dir() { + return Err(format!("{}: expected a regular directory", dir.display())); + } + let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let mut seen = 0; + for entry in entries { + let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?; + let name = entry.file_name(); + let hash = name.to_str().and_then(|n| expected.get(n)).ok_or_else(|| format!("pngsuite: unexpected file {}", entry.path().display()))?; + let bytes = pngsuite_bytes(&entry.path(), PNGSUITE_MAX_FILE_BYTES)?; + if crate::sha256::hex(&bytes) != **hash { + return Err(format!("pngsuite: {}: sha256 mismatch", entry.path().display())); + } + seen += 1; + } + if seen != expected.len() { + return Err(format!("pngsuite: missing files ({seen} of {})", expected.len())); + } + Ok(()) +} + +struct PngSuiteStage { + path: PathBuf, + keep: bool, +} + +impl PngSuiteStage { + fn new(corpus: &Path) -> Result { + std::fs::create_dir_all(corpus).map_err(|e| format!("create {}: {e}", corpus.display()))?; + for _ in 0..100 { + let id = PNGSUITE_STAGE_ID.fetch_add(1, Ordering::Relaxed); + let path = corpus.join(format!(".pngsuite-{}-{id}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Ok(Self { path, keep: false }), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("create {}: {e}", path.display())), + } + } + Err("pngsuite: could not create a staging directory".into()) + } +} + +impl Drop for PngSuiteStage { + fn drop(&mut self) { + if !self.keep { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} + +/// Keep the old cache until the verified replacement is in place. On a failed rollback its +/// backup remains in staging and the error names it, rather than deleting the only copy. +fn install_pngsuite( + dest: &Path, + files: &Path, + backup: &Path, + backup_retained: &mut bool, + mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result<(), String> { + let had_cache = match std::fs::symlink_metadata(dest) { + Ok(_) => true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => return Err(format!("{}: {e}", dest.display())), + }; + if had_cache { + rename(dest, backup).map_err(|e| format!("pngsuite: preserve {}: {e}", dest.display()))?; + *backup_retained = true; + } + if let Err(e) = rename(files, dest) { + if had_cache && let Err(restore) = rename(backup, dest) { + return Err(format!("pngsuite: install failed ({e}); restore failed ({restore}); old cache retained at {}", backup.display())); + } + *backup_retained = false; + return Err(format!("pngsuite: install failed: {e}")); + } + *backup_retained = false; + Ok(()) +} + +fn fetch_pngsuite_in( + corpus: &Path, + archive_hash: &str, + manifest: &str, + download: impl FnOnce(&Path) -> Result<(), String>, + extract: impl FnOnce(&Path, &Path) -> Result<(), String>, + rename: impl FnMut(&Path, &Path) -> std::io::Result<()>, +) -> Result<(), String> { let dest = corpus.join("pngsuite"); - let have = pngsuite_count(&dest); - if have >= 175 { - println!("pngsuite: {have} PNG files in {} already", dest.display()); + if verify_pngsuite(&dest, manifest).is_ok() { + println!("pngsuite: {} already matches its manifest", dest.display()); return Ok(()); } - std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?; - let tgz = corpus.join("PngSuite-2017jul19.tgz"); - let mut curl = Command::new("curl"); - curl.args(["-fsSL", "--retry", "3", "-A", USER_AGENT, "-o"]).arg(&tgz).arg(PNGSUITE_URL); - run(curl, &format!("curl {PNGSUITE_URL}"))?; - let mut tar = Command::new("tar"); - tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest); - run(tar, "tar -xzf PngSuite-2017jul19.tgz")?; - let _ = std::fs::remove_file(&tgz); - let n = pngsuite_count(&dest); - println!("pngsuite: {n} PNG files in {}", dest.display()); - if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) } + let mut stage = PngSuiteStage::new(corpus)?; + let archive = stage.path.join("archive.tgz"); + download(&archive)?; + let bytes = pngsuite_bytes(&archive, PNGSUITE_MAX_ARCHIVE_BYTES)?; + if crate::sha256::hex(&bytes) != archive_hash { + return Err("pngsuite: archive sha256 mismatch; nothing extracted".into()); + } + let files = stage.path.join("files"); + std::fs::create_dir(&files).map_err(|e| format!("create {}: {e}", files.display()))?; + extract(&archive, &files)?; + verify_pngsuite(&files, manifest)?; + let backup = stage.path.join("previous"); + install_pngsuite(&dest, &files, &backup, &mut stage.keep, rename)?; + println!("pngsuite: verified {}", dest.display()); + Ok(()) +} + +/// Older curl versions cannot enforce --max-filesize for unknown-length responses. Write +/// only the allowed prefix, then probe for excess without spooling that byte to disk. +fn copy_pngsuite_download(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<()> { + std::io::copy(&mut reader.take(PNGSUITE_MAX_ARCHIVE_BYTES), writer)?; + let mut extra = [0; 1]; + loop { + match reader.read(&mut extra) { + Ok(0) => return Ok(()), + Ok(_) => return Err(std::io::Error::other(format!("pngsuite: archive exceeds {PNGSUITE_MAX_ARCHIVE_BYTES} bytes"))), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } +} + +fn finish_pngsuite_download(child: &mut Child, mut reader: impl Read, writer: &mut impl Write) -> Result<(), String> { + let result = copy_pngsuite_download(&mut reader, writer).map_err(|e| format!("pngsuite: download: {e}")).and_then(|()| { + let status = child.wait().map_err(|e| format!("pngsuite: wait for curl: {e}"))?; + if status.success() { Ok(()) } else { Err(format!("pngsuite: curl failed: {status}")) } + }); + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + result +} + +fn download_pngsuite(mut curl: Command, archive: &Path) -> Result<(), String> { + let mut file = std::fs::File::create(archive).map_err(|e| format!("create {}: {e}", archive.display()))?; + let mut child = curl.stdout(Stdio::piped()).spawn().map_err(|e| format!("pngsuite: start curl: {e}"))?; + let Some(stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return Err("pngsuite: curl stdout unavailable".into()); + }; + finish_pngsuite_download(&mut child, stdout, &mut file) +} + +/// PngSuite: fixed archive checked before tar; every cached file checked on each invocation. +fn fetch_pngsuite() -> Result<(), String> { + fetch_pngsuite_in( + &root().join("corpus"), + PNGSUITE_SHA256, + PNGSUITE_MANIFEST, + |archive| { + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "--max-filesize", &PNGSUITE_MAX_ARCHIVE_BYTES.to_string(), "-A", USER_AGENT, "-o", "-", PNGSUITE_URL]); + download_pngsuite(curl, archive) + }, + |archive, files| { + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(archive).arg("-C").arg(files); + run(tar, "tar -xzf PngSuite-2017jul19.tgz") + }, + |from, to| std::fs::rename(from, to), + ) } /// Files changed relative to `origin/main` (committed, staged or not). @@ -286,4 +472,308 @@ mod tests { fn all_without_update_manifest_only_verifies() { assert!(calls(false, false).iter().all(|(_, u)| !*u)); } + + const FIXTURE_FILES: &[(&str, &[u8])] = &[("a.png", b"alpha"), ("b.png", b"beta"), ("PngSuite.LICENSE", b"fixture licence")]; + const FIXTURE_ARCHIVE: &[u8] = b"owned archive fixture"; + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + let id = PNGSUITE_STAGE_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("photocraft-pngsuite-test-{}-{id}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn cache(&self) -> PathBuf { + self.0.join("pngsuite") + } + + fn old_cache(&self) { + std::fs::create_dir(self.cache()).unwrap(); + std::fs::write(self.cache().join("old.png"), b"old cache bytes").unwrap(); + } + + fn assert_old_cache(&self) { + assert_eq!(std::fs::read(self.cache().join("old.png")).unwrap(), b"old cache bytes"); + assert_eq!(std::fs::read_dir(&self.0).unwrap().count(), 1, "staging was not cleaned"); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn fixture_manifest() -> String { + FIXTURE_FILES.iter().map(|(name, bytes)| format!("{} {name}\n", crate::sha256::hex(bytes))).collect() + } + + fn fixture_extract(_: &Path, files: &Path) -> Result<(), String> { + for (name, bytes) in FIXTURE_FILES { + std::fs::write(files.join(name), bytes).map_err(|e| e.to_string())?; + } + Ok(()) + } + + fn fixture_download(archive: &Path) -> Result<(), String> { + std::fs::write(archive, FIXTURE_ARCHIVE).map_err(|e| e.to_string()) + } + + fn fixture_fetch(corpus: &Path) -> Result<(), String> { + fetch_pngsuite_in(corpus, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| { + std::fs::rename(from, to) + }) + } + + #[test] + fn pngsuite_cache_checks_bytes_names_and_regular_files() { + let f = Fixture::new(); + std::fs::create_dir(f.cache()).unwrap(); + fixture_extract(Path::new("unused"), &f.cache()).unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).is_ok()); + std::fs::write(f.cache().join("a.png"), b"changed").unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("sha256 mismatch")); + fixture_extract(Path::new("unused"), &f.cache()).unwrap(); + std::fs::remove_file(f.cache().join("a.png")).unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("missing files")); + fixture_extract(Path::new("unused"), &f.cache()).unwrap(); + std::fs::write(f.cache().join("extra.png"), b"extra").unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("unexpected file")); + std::fs::remove_file(f.cache().join("extra.png")).unwrap(); + std::fs::remove_file(f.cache().join("a.png")).unwrap(); + std::fs::create_dir(f.cache().join("a.png")).unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("regular file")); + } + + #[test] + fn pngsuite_valid_cache_needs_no_download_or_extraction() { + let f = Fixture::new(); + fixture_fetch(&f.0).unwrap(); + fetch_pngsuite_in( + &f.0, + "unused", + &fixture_manifest(), + |_| panic!("valid cache downloaded"), + |_, _| panic!("valid cache extracted"), + |_, _| panic!("valid cache renamed"), + ) + .unwrap(); + } + + #[test] + fn pngsuite_repairs_unverified_175_file_cache_and_removes_extras() { + let f = Fixture::new(); + std::fs::create_dir(f.cache()).unwrap(); + for i in 0..175 { + std::fs::write(f.cache().join(format!("fake-{i}.png")), b"not a PNG").unwrap(); + } + fixture_fetch(&f.0).unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).is_ok()); + assert_eq!(std::fs::read_dir(f.cache()).unwrap().count(), FIXTURE_FILES.len()); + assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 1); + } + + #[test] + fn pngsuite_unexpected_or_truncated_archive_never_reaches_extractor() { + for bad in [b"unexpected".as_slice(), &FIXTURE_ARCHIVE[..4]] { + let f = Fixture::new(); + f.old_cache(); + let e = fetch_pngsuite_in( + &f.0, + &crate::sha256::hex(FIXTURE_ARCHIVE), + &fixture_manifest(), + |a| std::fs::write(a, bad).map_err(|e| e.to_string()), + |_, _| panic!("unverified archive reached extractor"), + |_, _| panic!("unverified archive reached installation"), + ) + .unwrap_err(); + assert!(e.contains("archive sha256 mismatch")); + f.assert_old_cache(); + } + } + + #[test] + fn pngsuite_download_or_extraction_failure_keeps_old_cache() { + let f = Fixture::new(); + f.old_cache(); + assert!( + fetch_pngsuite_in( + &f.0, + "unused", + &fixture_manifest(), + |_| Err("offline".into()), + |_, _| panic!("failed download extracted"), + |_, _| panic!("failed download installed") + ) + .unwrap_err() + .contains("offline") + ); + f.assert_old_cache(); + assert!( + fetch_pngsuite_in( + &f.0, + &crate::sha256::hex(FIXTURE_ARCHIVE), + &fixture_manifest(), + fixture_download, + |_, files| { + std::fs::write(files.join("partial.png"), b"partial").unwrap(); + Err("extract failed".into()) + }, + |_, _| panic!("failed extraction installed") + ) + .unwrap_err() + .contains("extract failed") + ); + f.assert_old_cache(); + for extra in [false, true] { + let e = fetch_pngsuite_in( + &f.0, + &crate::sha256::hex(FIXTURE_ARCHIVE), + &fixture_manifest(), + fixture_download, + |a, files| { + fixture_extract(a, files)?; + if extra { + std::fs::write(files.join("extra.png"), b"extra").unwrap(); + } else { + std::fs::remove_file(files.join("a.png")).unwrap(); + } + Ok(()) + }, + |_, _| panic!("unverified extraction installed"), + ) + .unwrap_err(); + assert!(e.contains(if extra { "unexpected file" } else { "missing files" })); + f.assert_old_cache(); + } + } + + #[test] + fn pngsuite_failed_installation_restores_old_cache() { + for fail_backup in [false, true] { + let f = Fixture::new(); + f.old_cache(); + let e = fetch_pngsuite_in(&f.0, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| { + if (fail_backup && from == f.cache()) || (!fail_backup && from.file_name().is_some_and(|n| n == "files")) { + Err(std::io::Error::other("injected rename failure")) + } else { + std::fs::rename(from, to) + } + }) + .unwrap_err(); + assert!(e.contains("injected rename failure")); + f.assert_old_cache(); + } + } + + #[test] + fn pngsuite_failed_rollback_retains_and_names_the_backup() { + let f = Fixture::new(); + f.old_cache(); + let e = fetch_pngsuite_in(&f.0, &crate::sha256::hex(FIXTURE_ARCHIVE), &fixture_manifest(), fixture_download, fixture_extract, |from, to| { + if from == f.cache() { std::fs::rename(from, to) } else { Err(std::io::Error::other("injected install and rollback failure")) } + }) + .unwrap_err(); + let stage = std::fs::read_dir(&f.0).unwrap().next().unwrap().unwrap().path(); + let backup = stage.join("previous"); + assert!(e.contains(&backup.display().to_string()), "{e}"); + assert_eq!(std::fs::read(backup.join("old.png")).unwrap(), b"old cache bytes"); + std::fs::rename(backup, f.cache()).unwrap(); + } + + #[test] + fn pngsuite_reads_are_bounded_and_malformed_manifests_are_rejected() { + let f = Fixture::new(); + let file = f.0.join("large"); + std::fs::File::create(&file).unwrap().set_len(PNGSUITE_MAX_ARCHIVE_BYTES + 1).unwrap(); + assert!(pngsuite_bytes(&file, PNGSUITE_MAX_ARCHIVE_BYTES).unwrap_err().contains("regular file")); + for malformed in ["", "invalid", "00 a.png", &format!("{} ../outside\n", crate::sha256::hex(b""))] { + assert!(verify_pngsuite(&f.0, malformed).is_err()); + } + } + + #[test] + fn pngsuite_download_copy_accepts_boundary_and_never_writes_excess() { + let limit = PNGSUITE_MAX_ARCHIVE_BYTES as usize; + for len in [limit - 1, limit, limit + 1, limit * 2] { + let data = vec![0xa5; len]; + let mut out = Vec::new(); + let result = copy_pngsuite_download(&mut std::io::Cursor::new(&data), &mut out); + assert_eq!(result.is_ok(), len <= limit); + assert_eq!(out, data[..len.min(limit)]); + } + } + + // The ordinary test invocation returns immediately. Only this test's child command + // enables emission; it stays alive so cleanup must kill and reap that particular child. + #[test] + fn pngsuite_stream_fixture() { + if std::env::var_os("PHOTOCRAFT_PNGSUITE_STREAM_TEST").is_some() { + let mut out = std::io::stdout().lock(); + for _ in 0..256 { + let _ = out.write_all(&[0xa5; 8192]); + } + std::thread::park(); + } + } + + fn stream_child() -> Child { + Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "corpus::tests::pngsuite_stream_fixture", "--nocapture"]) + .env("PHOTOCRAFT_PNGSUITE_STREAM_TEST", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap() + } + + #[test] + fn pngsuite_oversize_and_io_failures_kill_and_reap_only_the_download_child() { + struct FailedRead; + impl Read for FailedRead { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("injected read failure")) + } + } + struct FailedWrite; + impl Write for FailedWrite { + fn write(&mut self, _: &[u8]) -> std::io::Result { + Err(std::io::Error::other("injected write failure")) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut child = stream_child(); + let stdout = child.stdout.take().unwrap(); + let mut out = Vec::new(); + assert!(finish_pngsuite_download(&mut child, stdout, &mut out).unwrap_err().contains("archive exceeds")); + assert_eq!(out.len() as u64, PNGSUITE_MAX_ARCHIVE_BYTES); + assert!(child.try_wait().unwrap().is_some()); + + let mut child = stream_child(); + assert!(child.try_wait().unwrap().is_none()); + assert!(finish_pngsuite_download(&mut child, FailedRead, &mut Vec::new()).unwrap_err().contains("injected read failure")); + assert!(child.try_wait().unwrap().is_some()); + + let mut child = stream_child(); + let stdout = child.stdout.take().unwrap(); + assert!(finish_pngsuite_download(&mut child, stdout, &mut FailedWrite).unwrap_err().contains("injected write failure")); + assert!(child.try_wait().unwrap().is_some()); + } + + #[cfg(unix)] + #[test] + fn pngsuite_cache_rejects_symlinks_even_when_target_bytes_match() { + let f = Fixture::new(); + std::fs::create_dir(f.cache()).unwrap(); + fixture_extract(Path::new("unused"), &f.cache()).unwrap(); + std::fs::write(f.0.join("target"), b"alpha").unwrap(); + std::fs::remove_file(f.cache().join("a.png")).unwrap(); + std::os::unix::fs::symlink(f.0.join("target"), f.cache().join("a.png")).unwrap(); + assert!(verify_pngsuite(&f.cache(), &fixture_manifest()).unwrap_err().contains("regular file")); + } } diff --git a/xtask/src/corpus_pins.rs b/xtask/src/corpus_pins.rs index b8d3770493..917412709a 100644 --- a/xtask/src/corpus_pins.rs +++ b/xtask/src/corpus_pins.rs @@ -40,6 +40,10 @@ pub const ASSET_STORE_TEMPLATE_COMMIT: &str = "f671981ee89d15526285b3cc87b4393d3 pub const AFFINITY_SAMPLES_COMMIT: &str = "96aca6bd86bbe7a677f1cf0630bd832d23943be8"; /// PngSuite (public domain), a fixed release archive. pub const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz"; +/// Fixed archive, corroborated by HTTPS records in opencodecs at 6f070029 and rivet-png at +/// 134faad8 (corpus/checksums.json and tests/pngsuite/SHA256SUMS). Check the legacy HTTP +/// download against this committed digest before extraction. +pub const PNGSUITE_SHA256: &str = "0294b244c95a8342c01b00010cf34abdcabc7c6a34fd0fe1bd963917537bfdc8"; /// `corpus/photoshop`: 258 PSDs authored with Photoshop by the photocraft-corpus generator. pub const PHOTOSHOP: PinnedCorpus = PinnedCorpus { From 67e4b8a499b55ef728b1da700005382c45e4677d Mon Sep 17 00:00:00 2001 From: Sean Dellis <1584916+seandellis@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:52:19 -0500 Subject: [PATCH 148/523] =?UTF-8?q?feat:=20Alt+Cancel=20=E2=86=92=20Reset?= =?UTF-8?q?=20for=20opt-in=20command=20dialogs=20(#3871)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update .gitignore and add UI Dialogs documentation - Added .specstory, .private, and .vscode to .gitignore to exclude additional files from version control. - Introduced a new documentation file, ui-dialogs.md, detailing the architecture, rules, and usage of modal and modeless dialogs in the egui shell, mirroring Photoshop's functionality. * Enhance UI Dialogs documentation in ui-dialogs.md - Expanded the section on command routing and dialog types, clarifying the roles of various dialog kinds and their associated modules. - Improved descriptions of routing markers and private keys, ensuring better understanding of the dialog architecture and usage. - Updated the menu command entry details to reflect changes in module organization and functionality. * Implement Alt+Cancel (Reset) functionality for various UI dialogs - Introduced a new module `dialog_alt_reset` to handle the Alt+Cancel behavior, allowing users to reset dialog fields to their original values while keeping the dialog open. - Updated multiple dialog modules to support this feature, including adjustments for color pickers, sizing, and guide layouts. - Added tests to ensure that the reset functionality correctly restores original values and maintains dialog state. - Enhanced documentation to reflect the new Alt+Reset behavior and its implementation details across different dialog types. * Update manual UI testing command to include build timing summary - Modified the `manual-ui-testing` command in `.cargo/config.toml` to include the `--timings` flag, enabling the generation of a build timing summary after compilation and linking. - Refactored the `reset_fields` function in `filter_dialog.rs` for improved readability by consolidating the retrieval of the `spec` variable into a single line, enhancing code clarity. * Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) (#3125) * Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) - blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping: the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the Background is left alone). One undo step per press. - panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the "Effects" row clears the whole style (layer.layerStyle.clear). - engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step); bad indices and layers return Err. - scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already on main via #1961, #965). Co-Authored-By: Claude Opus 5.5 * Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102) On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+` types `*`, which egui has no key for, so egui-winit reports the physical key: `]` on German. That matched Shift+] (Increase Brush Hardness) before the blend keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`. shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-` before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys. Co-Authored-By: Claude Opus 5.5 * Shortcuts: Ö / # are the brush keys on a German layout (#3109) Photoshop's German shortcuts step the brush size with Ö / # and the hardness with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries Alt on the Mac and Windows, which the shortcut match rejects). egui can't name ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+# types ' and arrived as Quote. shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of shortcuts::handle (unless a text field has focus), so Liquify's and the Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took from the physical key or from that very text change, and never digits. Co-Authored-By: Claude Opus 5.5 * Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113) A drag that started on a layer's mask thumbnail dragged the whole row, and dropping it on the Trash deleted the layer. Now a drag from the pixel mask thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer Mask" ghost and starts neither a layer reorder nor a cross-document transfer. Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before removing?" with Apply (layer.layerMask.apply, greyed out where it can't run), Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes the targeted mask at once (#2197). The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps. Two new strings in all 16 catalogs. Co-Authored-By: Claude Opus 5.5 * Layers panel: dragging the vector mask to the Trash asks to delete just the mask (#3119) A drag that started on a row's vector mask thumbnail picked up the whole layer row, so dropping it on the Trash deleted the layer. The mask drag from #3113 now covers both thumbnails: a vector mask dropped on the Trash opens Photoshop's "Delete the vector mask?" (OK / Cancel), and OK runs layer.vectorMask.delete. Clicks on the thumbnail are unchanged. Co-Authored-By: Claude Opus 5.5 * Brush keys: layout table plus bracket-position fallback for every layout (#3109) Co-Authored-By: Claude Opus 5.5 * layout_keys: drop unused field, fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Interface: an Enable Animations preference (#3273) (#3276) Preferences › Interface › Enable animations (interface.enableAnimations, on by default). Off, prefs_ui::sync_animations sets egui's animation_time to 0 and scroll_animation to none in both the dark and light styles, so menus, popups and collapsing sections change at once and scroll areas jump. It is re-applied each frame, so a theme change keeps it. Translated in all 16 catalogs; the scorecard row UI-217-10 goes to partial. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Edit > Copy takes a targeted layer mask, so masks travel between layers and documents (#2928) (#3267) * automation: export layers, data sets and CSVs through the granted roots (#3216) (#3230) * automation: doc.place reads a file through the read root (#2810) file.placeEmbedded takes an ambient path, so automation refuses it and an agent could not place a file into the open document at all. doc.place resolves the path beneath the read root the way doc.open does and hands the bytes it already read to the public file_cmds::place_bytes, so the existing cap-std rules stay the only path rules: no new scope logic, and the ambient command keeps its refusal. Trusted-local callers still run the command. * automation: export layers, data sets and CSVs through the roots (#3216) file.export.layersToFiles and file.export.dataSetsAsFiles take an ambient dir, file.import.variableDataSets an ambient path, so all three are refused in automation even for files inside the granted roots, and an agent could not export a single file or a data set at all. Both halves already existed: the engine writes bytes it has just encoded (save_doc is encode + write_file, and the data-set exporter encodes with photocraft_io::export before writing), and the automation side already holds a write capability — AuthorizedWorkspace::export_sequence creates the directory, refuses to overwrite, accepts only single-component names and removes every file it created when a step fails, which is what Render Video uses. So each command body moves behind the same sink callback render_video_with takes: the command keeps writing the bytes it is handed to its dir, the new methods write the very same bytes through the root, and the two implementations cannot drift. --------- Co-authored-by: switch87 <8284204+switch87@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 * Type: fall back to installed Khmer, Lao and Burmese fonts (#218) (#3259) Text in these scripts drew .notdef boxes although the OS had a font for them. Detection was never the problem: the system font scan loads them, and asking for one by name shapes correctly. The fallback stack is a list of family names intersected with the installed fonts, and it had no entry for any of the three, so an installed Khmer, Lao or Burmese font could not be reached from a Latin-only face. - photocraft-text: KHMER_FAMILIES, LAO_FAMILIES and MYANMAR_FAMILIES join fallback_candidates next to THAI_FAMILIES; the web build's served-font scripts gain Khmr, Laoo and Mymr (with their ranges and languages). - photocraft-ui-egui: the UI's lazy loader had one non-CJK slot (Thai). It becomes a LazyScript slot table, so the three scripts are data rather than a third and fourth copy of the same state machine. - Tests assert detection and selection separately, so a future failure names its layer, and skip when a machine lacks the script's font. Co-authored-by: Raccoon <235524086+RaccoonPie-314@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 * Curves: pencil mode, Smooth and Edit points (#3088) (#3108) * Curves: pencil mode, Smooth and Edit points (#3088) The Curves editor (Image > Adjustments dialog and the adjustment layer's Properties panel) gains Photoshop's "Edit points to modify the curve" and "Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn curve is a 256-level map per channel, evaluated piecewise-linearly. - doc: Adjustment::Curves gains `pencil` (serde default), with curve_map / curve_map_outputs helpers. - compose: curve_map_lut / curve_lut_mode for drawn curves. - engine: curves params take `pencil` and `smooth`; channels accept 256-level maps; switching modes draws the spline into maps or fits up to 16 points to the drawing. Eyedroppers fit points first. - io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read as padding, so pencil curves from Photoshop were misparsed; they are now read and written as 256-byte maps, and smart-filter Curves use `Mpng`. - scorecard UI-217-15 note updated. Co-Authored-By: Claude Opus 5.5 * Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * fix(io): smooth PSD gradients no longer overshoot their stops (#3028) (#3285) The Catmull-Rom tangents used for Smoothness are derived from the neighbouring stops, so a segment between two equal-coloured stops (a flat plateau) picked up a non-zero tangent and dipped below its own colour. In the Gradient Map from #3028 the 27%-53% plateau (#1c1c1c) rendered about 19/255 too dark. Bound each interpolated channel to the range of the segment's two end colours. Smoothness still bends the ramp, but never invents a colour outside the segment, so plateaus stay exactly flat. The regression test uses the four stop positions and grey values from the issue (synthetic, no artwork) and fails without the clamp. * Codecs: decode PNM straight into the budgeted output buffer (#2938) (#3291) Limits::max_alloc budgets the decoded pixel buffer, but the PNM decoder collected one u32 per sample first (4x an 8-bit image, plus a u16 copy for 16-bit) and PFM built a Vec beside the output bytes. Samples are now rescaled and written straight into the native-endian output buffer (reserved fallibly), so the only header-sized allocation is the one Limits::check already charged. Co-authored-by: Claude Opus 5.5 * Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) (#3115) * Curves: pencil mode, Smooth and Edit points (#3088) The Curves editor (Image > Adjustments dialog and the adjustment layer's Properties panel) gains Photoshop's "Edit points to modify the curve" and "Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn curve is a 256-level map per channel, evaluated piecewise-linearly. - doc: Adjustment::Curves gains `pencil` (serde default), with curve_map / curve_map_outputs helpers. - compose: curve_map_lut / curve_lut_mode for drawn curves. - engine: curves params take `pencil` and `smooth`; channels accept 256-level maps; switching modes draws the spline into maps or fits up to 16 points to the drawing. Eyedroppers fit points first. - io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read as padding, so pencil curves from Photoshop were misparsed; they are now read and written as 256-byte maps, and smart-filter Curves use `Mpng`. - scorecard UI-217-15 note updated. Co-Authored-By: Claude Opus 5.5 * Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) The Set Black/Neutral Gray/White Point pickers existed only in the Image > Adjustments > Curves dialog (#873). The Properties editor of a Curves adjustment layer now has them too (layer_picker.rs): with one armed, a click on the image runs one layer.setAdjustment with the new `eyedropper` param, which samples the image below the layer (the layer hidden, like its Properties histogram) at the Eyedropper's Sample Size, so a repeated click keeps the curve and the edit is one undoable, recordable step agents can send directly. Esc, another tool or another layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it. Scorecard UI-217-15 is done. Co-Authored-By: Claude Opus 5.5 * Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Format: admit the declared .pcraft canvas before loading it (#2935) (#3295) * Format: admit the declared .pcraft canvas before loading it (#2935) A .pcraft manifest's canvas size was copied into the Document unchecked, so a few bytes of JSON could declare a 300000x300000 (or u32::MAX) canvas that the CPU reduction, thumbnails and display textures then work against in proportion to its area. The loader now refuses an empty canvas, a side above 300000 (Photoshop's PSB limit, as for PSD and file.new) and an area above 4 gigapixels, right after reading the manifest and before any tile or blob is decoded. Both limits are LoadOptions fields so trusted callers can change them. Co-Authored-By: Claude Opus 5.5 * Format: admit every canvas the app can create on .pcraft load (#2935) The default pixel budget was 2^32 (65536 x 65536), but file.new, Canvas Size, Image Size with Resample off and PSB import all reach 300000 x 300000, so a document a user could create and save would not reopen. The default is now the full 300000^2 PSB canvas; the side limit still refuses anything larger, and callers handling untrusted files can lower either budget. Loading stays proportional to the file: layers keep only the tiles the bundle holds, so a huge declared canvas allocates nothing for its area. A compile-time assert in document_preset_cmds ties file.new's MAX_DIMENSION to the loader's defaults. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 * fix(format): save the target file name in native bundle manifests (#3312) (#3319) * Levels: black, gray and white point eyedroppers (#3116) (#3128) * Curves: pencil mode, Smooth and Edit points (#3088) The Curves editor (Image > Adjustments dialog and the adjustment layer's Properties panel) gains Photoshop's "Edit points to modify the curve" and "Draw to modify the curve" buttons plus Smooth, with tooltips. A drawn curve is a 256-level map per channel, evaluated piecewise-linearly. - doc: Adjustment::Curves gains `pencil` (serde default), with curve_map / curve_map_outputs helpers. - compose: curve_map_lut / curve_lut_mode for drawn curves. - engine: curves params take `pencil` and `smooth`; channels accept 256-level maps; switching modes draws the spline into maps or fits up to 16 points to the drawing. Eyedroppers fit points first. - io: the PSD `curv` map flag (first byte, psd-tools `is_map`) was read as padding, so pencil curves from Photoshop were misparsed; they are now read and written as 256-byte maps, and smart-filter Curves use `Mpng`. - scorecard UI-217-15 note updated. Co-Authored-By: Claude Opus 5.5 * Properties: Curves adjustment layers get the black/gray/white eyedroppers (#3110) The Set Black/Neutral Gray/White Point pickers existed only in the Image > Adjustments > Curves dialog (#873). The Properties editor of a Curves adjustment layer now has them too (layer_picker.rs): with one armed, a click on the image runs one layer.setAdjustment with the new `eyedropper` param, which samples the image below the layer (the layer hidden, like its Properties histogram) at the Eyedropper's Sample Size, so a repeated click keeps the curve and the edit is one undoable, recordable step agents can send directly. Esc, another tool or another layer disarms it; ui.pointer and ui.inspect (`layerPicker`) cover it. Scorecard UI-217-15 is done. Co-Authored-By: Claude Opus 5.5 * Levels: black, gray and white point eyedroppers (#3116) Image > Adjustments > Levels and a Levels adjustment layer's Properties panel get Photoshop's Set Black/Neutral Gray/White Point eyedroppers. Black/white move each channel's input black/white to the sample, gray sets each channel's midtone gamma so the sample turns neutral at its luma (Grayscale: composite input, no gray). The engine takes the same `eyedropper` param as Curves on image.adjustments.levels, layer.newAdjustmentLayer.levels and layer.setAdjustment. On a smart object, image.adjustments.levels/curves with `eyedropper` now record the resolved adjustment as the smart filter instead of the raw sample, which re-rendered as an identity (#3127). Co-Authored-By: Claude Opus 5.5 * Merge upstream/main: Curves in new compose/gpu HDR tests gets the pencil field (#3088) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Puppet Warp: the mesh follows the object outline (#3105) (#3117) The mesh was every square grid cell touching the layer's opaque pixels: a staircase up to a cell outside the shape, and shapes closer than about a cell shared cells, so pinning one dragged the other along. build_mesh now traces the outline of the opaque region grown (or shrunk) by Expansion from a signed distance field, simplifies it (Douglas-Peucker, corners kept), splits its sides to the Density spacing, fills the inside with a triangular lattice and triangulates the points (Bowyer-Watson Delaunay, outline sides recovered by midpoint splits), keeping the triangles inside the outline. Separate shapes get separate meshes, so an unpinned one stays put, as in Photoshop. Vertices are numbered in reverse Cuthill-McKee order to keep the solver's banded factor narrow, and the density's spacing counts the grown region so a large Expansion on a small layer no longer builds tens of thousands of vertices. Scorecard TOOL-214-6 done; docs/gaps.md G7 updated. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Clone Source: W/H link and overlay blend modes (#3298) (#3310) The Clone Source panel gets Photoshop's W/H chain (Maintain aspect ratio): with it on, a new W or H scales the other by the same factor. cloneSource.set takes "linked" and cloneSource.list reports it. The overlay's Darken, Lighten and Difference modes were stored but never drawn. draw_clone_preview now blends the source with the composite under the brush (clone_source::overlay_blend), so Difference goes black where source and image line up. Co-authored-by: Claude Opus 5.5 * Vanishing Point: on-canvas plane editor with Create/Edit Plane, grid and paste (#3122) (#3133) Filter > Vanishing Point now opens a dialog mode on the canvas instead of the generic parameter dialog: - Create Plane (C): four clicks, in any order, make a plane (corners are normalised clockwise from the top-left, the order paste expects). - Edit Plane (V): drag corner nodes, or edge nodes to slide an edge along the plane's own perspective; click a plane to select it, Delete removes it. - The plane grid is drawn at the Grid Size, in the accent colour when the plane is valid, warning yellow when its perspective is doubtful and red (outline only) when the quad isn't convex (VpPlane::check). - With an image on the clipboard the selected plane previews it pasted in perspective; drag to move it, Width sets its size, New Layer renders it on a new layer. OK / Enter runs filter.vanishingPoint with the planes and the paste as one history step; the control channel drives the same session through {"ui": {...}}. The engine now rejects a drawn plane whose corners don't form a convex quad (bow-tie, dented) instead of warping along it. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas * Let `[` shrink the brush to 1 px (#3229) (#3332) `[` steps the brush size down by 25 %, rounded: from 2 px that is 1.6, which rounds back to 2, so the key never reached 1 px and pixel artists had to type the size. `]` already grows by at least a pixel; `[` now shrinks by at least a pixel the same way, still never below 1 px. * MCP: command_batch says it is not atomic, and the docs explain recovery (#3338) Agents read the tool description, not docs/mcp.md, and the command_batch description only said it stops at the first error. It now also says the steps run in order and that earlier successful steps stay applied: there is no rollback. docs/mcp.md gives the stop_on_error default and a recovery recipe: inspect with doc_inspect, undo with edit.undo once per history entry (undo does not reverse files written to disk), or experiment on a copy made with image.duplicate or history.newDocument. A test checks the listed description. Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Valentin Klein Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas Co-authored-by: Vikram Bhaskaran Co-authored-by: Gert Pellin - GPE Co-authored-by: switch87 <8284204+switch87@users.noreply.github.com> Co-authored-by: RaccoonPie-314 Co-authored-by: Raccoon <235524086+RaccoonPie-314@users.noreply.github.com> Co-authored-by: Sir Starch Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Co-authored-by: myangaa1 <58806636+myangaa1@users.noreply.github.com> Co-authored-by: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> --- crates/ui-egui/src/adjust_dialog.rs | 51 +++-- crates/ui-egui/src/adjust_editors.rs | 8 + crates/ui-egui/src/color_picker_ui.rs | 21 ++ crates/ui-egui/src/dialog_alt_reset.rs | 93 +++++++++ crates/ui-egui/src/dialogs.rs | 5 +- crates/ui-egui/src/fill_ui.rs | 23 +- crates/ui-egui/src/fill_ui_tests.rs | 16 ++ crates/ui-egui/src/filter_dialog.rs | 123 +++++++---- crates/ui-egui/src/guide_layout_ui.rs | 27 +++ crates/ui-egui/src/guide_layout_ui/tests.rs | 17 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/sizing.rs | 38 ++++ crates/ui-egui/src/variables_ui.rs | 23 ++ crates/ui-egui/src/view_cmds.rs | 35 +++- docs/ui-dialogs.md | 219 ++++++++++++++++++++ 15 files changed, 646 insertions(+), 54 deletions(-) create mode 100644 crates/ui-egui/src/dialog_alt_reset.rs create mode 100644 docs/ui-dialogs.md diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 7c97835354..a9235881ea 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -134,19 +134,33 @@ fn dialog_mem(kind: &str) -> egui::Id { egui::Id::new(("adjust-dialog", kind)) } -/// Whether holding Alt turns the dialog's Cancel into Reset. Measured in Photoshop 25.4 for Color -/// Balance; other kinds keep Cancel until checked against Photoshop. +/// Whether holding Alt turns Cancel into Reset for this adjustment dialog (every kind). pub fn resets(fields: &Map) -> bool { - fields.get("__adjust").and_then(Value::as_str) == Some("colorBalance") + owns(fields) } -/// Alt+Cancel (Reset): back to the defaults Photoshop's Reset restores, dialog stays open. -pub fn reset(ctx: &egui::Context, fields: &mut Map) { - if !resets(fields) { +/// Alt+Cancel (Reset): engine defaults for the kind; Color Balance keeps Preserve Luminosity and +/// shows Midtones (Photoshop 25.4). Dialog stays open. +pub fn reset(app: &PhotocraftApp, ctx: &egui::Context, fields: &mut Map) { + if !owns(fields) { return; } + let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string(); + let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode); + // Color Balance: Photoshop keeps the user's Preserve Luminosity across Reset. + let preserve = if kind == "colorBalance" { fields.get("preserveLuminosity").cloned() } else { None }; + let Ok(defaults) = photocraft_engine::adjust_params::default_for(&kind, mode) else { return }; + let values = photocraft_engine::adjust_params::to_params(&defaults); + // Replace parameter keys; keep private `__` chrome (preview, command, …). + fields.retain(|k, _| k.starts_with("__")); + if let Value::Object(v) = values { + fields.extend(v); + } + if let Some(p) = preserve { + fields.insert("preserveLuminosity".into(), p); + } let mut values = crate::filter_dialog::params_of(fields); - adjust_editors::reset_color_balance(ctx, dialog_mem("colorBalance"), &mut values); + adjust_editors::reset_adjustment_editor(ctx, &kind, dialog_mem(&kind), &mut values); if let Value::Object(v) = values { fields.extend(v); } @@ -390,16 +404,29 @@ mod tests { assert!(h.state().ui.dialogs.is_empty(), "Cancel closes"); } - /// Alt+Cancel is Reset only where it was checked against Photoshop. + /// Every adjustment kind opts into Alt+Reset; Levels restores engine defaults and stays open. #[test] - fn other_adjustment_dialogs_keep_cancel_under_alt() { + fn levels_alt_reset_restores_defaults() { use egui_kittest::kittest::Queryable; let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image()); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); - open(h.state_mut(), "image.adjustments.levels").unwrap(); - h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); - h.run_steps(3); + let id = open(h.state_mut(), "image.adjustments.levels").unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("inBlack".into(), json!(30)); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("gamma".into(), json!(1.3)); + h.run_steps(2); assert!(h.query_by_label("Cancel").is_some() && h.query_by_label("Reset").is_none()); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + h.run_steps(2); + let reset = h.get_by_label("Reset").rect(); + assert!(h.query_by_label("Cancel").is_none()); + let click = + |pressed| egui::Event::PointerButton { pos: reset.center(), button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::ALT }; + input(&mut h, egui::Modifiers::ALT, &[egui::Event::PointerMoved(reset.center()), click(true), click(false)]); + assert!(h.state().ui.dialogs.iter().any(|d| d.id == id), "Reset keeps the dialog open"); + let defaults = + photocraft_engine::adjust_params::to_params(&photocraft_engine::adjust_params::default_for("levels", photocraft_doc::ColorMode::Rgb).unwrap()); + assert_eq!(field(&h, id, "inBlack"), defaults["inBlack"]); + assert_eq!(field(&h, id, "gamma"), defaults["gamma"]); } fn sample_params(kind: &str) -> Value { diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 4d25d822c6..84fab70b4b 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -1019,6 +1019,14 @@ pub fn reset_color_balance(ctx: &egui::Context, mem: egui::Id, v: &mut Value) { ctx.data_mut(|d| d.insert_temp(color_balance_tone_id(mem), 1usize)); } +/// Kind-specific editor memory after Alt+Reset (or Properties Reset to defaults). Color Balance +/// shows Midtones; other kinds have no dialog-local temp to clear yet. +pub fn reset_adjustment_editor(ctx: &egui::Context, kind: &str, mem: egui::Id, v: &mut Value) { + if kind == "colorBalance" { + reset_color_balance(ctx, mem, v); + } +} + fn color_balance(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { let tone_id = color_balance_tone_id(cx.mem); let mut tone_ix: usize = ui.data(|d| d.get_temp(tone_id)).unwrap_or(1); diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index cfa7183a59..e9729eab67 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -298,6 +298,15 @@ pub fn owns(f: &Map) -> bool { f.contains_key("__colorPicker") } +/// Alt+Cancel (Reset): restore the colour from when the picker opened (same as clicking Current). +pub fn reset(f: &mut Map) { + if !owns(f) { + return; + } + let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or([0.0; 3]); + set_rgb(f, orig, Keep::Nothing); +} + /// The Color Picker, when it is the top dialog (another dialog opened over it takes the input). pub fn top(app: &PhotocraftApp) -> Option { app.ui.dialogs.last().filter(|d| owns(&d.fields)).map(|d| d.id) @@ -833,6 +842,18 @@ mod tests { assert_eq!(hex([h.state().session.tools.foreground[0], h.state().session.tools.foreground[1], h.state().session.tools.foreground[2]]), "#ff8800"); } + #[test] + fn alt_reset_restores_orig_color() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [0.2, 0.4, 0.6, 1.0]; + let id = open(&mut app, "foreground"); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("color".into(), json!("#ff0000")); + reset(f); + assert_eq!(f["color"], json!(hex([0.2, 0.4, 0.6]))); + assert_eq!(f["__orig"], json!(hex([0.2, 0.4, 0.6]))); + } + #[test] fn lab_and_cmyk_fields_take_multi_digit_values() { let base = parse_hex("#336699").unwrap(); diff --git a/crates/ui-egui/src/dialog_alt_reset.rs b/crates/ui-egui/src/dialog_alt_reset.rs new file mode 100644 index 0000000000..71e52ac8f6 --- /dev/null +++ b/crates/ui-egui/src/dialog_alt_reset.rs @@ -0,0 +1,93 @@ +//! Alt+Cancel → Reset for command dialogs that opt in. +//! +//! Holding Alt in the dialog chrome relabels Cancel as Reset and, on click, restores the dialog's +//! fields without closing it (Photoshop). Each marker implements its own `reset`; this module only +//! dispatches. Esc stays Cancel (`Modifiers::NONE` in the frame). Opt out with `"__altReset": false`. + +use serde_json::{Map, Value}; + +use crate::PhotocraftApp; + +/// Whether holding Alt turns Cancel into Reset for this dialog's fields. +pub fn supported(fields: &Map) -> bool { + if fields.get("__altReset") == Some(&Value::Bool(false)) { + return false; + } + match_arm(fields).is_some() +} + +/// Apply Reset in place. No-op when the dialog has not opted in. +pub fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, fields: &mut Map) { + if fields.get("__altReset") == Some(&Value::Bool(false)) { + return; + } + // Dispatch order matches `dialogs::show` so `__adjust` wins over `__filter`. + if crate::guide_layout_ui::owns(fields) { + crate::guide_layout_ui::reset(app, fields); + } else if crate::fill_ui::owns(fields) { + crate::fill_ui::reset(app, fields); + } else if crate::variables_ui::owns(fields) { + crate::variables_ui::reset(fields); + } else if crate::color_picker_ui::owns(fields) { + crate::color_picker_ui::reset(fields); + } else if fields.contains_key("__sizing") { + crate::sizing::reset(fields); + } else if crate::adjust_dialog::owns(fields) { + crate::adjust_dialog::reset(app, ctx, fields); + } else if fields.contains_key("__filter") { + crate::filter_dialog::reset_fields(app, fields); + } else if fields.contains_key("__form") { + crate::view_cmds::reset_form(fields); + } +} + +/// First matching opt-in arm, if any (same order as [`apply`]). +fn match_arm(fields: &Map) -> Option<&'static str> { + if crate::guide_layout_ui::owns(fields) { + Some("guideLayout") + } else if crate::fill_ui::owns(fields) { + Some("fill") + } else if crate::variables_ui::owns(fields) && fields.contains_key("__variablesOrig") { + Some("variables") + } else if crate::color_picker_ui::owns(fields) { + Some("colorPicker") + } else if fields.contains_key("__sizing") { + Some("sizing") + } else if crate::adjust_dialog::owns(fields) { + Some("adjust") + } else if fields.contains_key("__filter") { + Some("filter") + } else if fields.contains_key("__form") && fields.contains_key("__formOrig") { + Some("form") + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn alt_reset_false_opts_out() { + let mut f = Map::new(); + f.insert("__adjust".into(), json!("levels")); + f.insert("__altReset".into(), json!(false)); + assert!(!supported(&f)); + } + + #[test] + fn adjust_marker_opts_in() { + let mut f = Map::new(); + f.insert("__adjust".into(), json!("levels")); + assert!(supported(&f)); + } + + #[test] + fn bare_confirm_does_not_opt_in() { + let mut f = Map::new(); + f.insert("__command".into(), json!("edit.purge")); + assert!(!supported(&f)); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2765d0e2a5..1573d5ec31 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -310,7 +310,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let can_ok = crate::crop_presets::can_confirm(app, &fields); let ok = DialogButton::new(ButtonRole::Default, ok_label, 84.0).enabled(can_ok); // Photoshop: holding Alt turns Cancel into Reset (the dialog stays open). - let reset = ui.input(|i| i.modifiers.alt) && crate::adjust_dialog::resets(&fields); + // Dialogs opt in via `dialog_alt_reset::supported` (per marker). + let reset = ui.input(|i| i.modifiers.alt) && crate::dialog_alt_reset::supported(&fields); let cancel_label = if reset { tl!("Reset") } else if d.kind == DialogKind::NewDocument { @@ -337,7 +338,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { dialog_buttons(ui, &[ok, cancel]) }; match clicked { - Some(ButtonRole::Cancel) if reset => crate::adjust_dialog::reset(ui.ctx(), &mut fields), + Some(ButtonRole::Cancel) if reset => crate::dialog_alt_reset::apply(app, ui.ctx(), &mut fields), Some(ButtonRole::Cancel) => outcome = Some(false), Some(ButtonRole::Apply) => apply_requested = true, Some(_) => outcome = Some(true), diff --git a/crates/ui-egui/src/fill_ui.rs b/crates/ui-egui/src/fill_ui.rs index c44c4b5774..faa917f039 100644 --- a/crates/ui-egui/src/fill_ui.rs +++ b/crates/ui-egui/src/fill_ui.rs @@ -53,8 +53,8 @@ fn valid(app: &PhotocraftApp, key: &str, v: &Value) -> bool { } } -/// The dialog's fields: Photoshop's defaults, overridden by the last choices. -pub fn fields(app: &PhotocraftApp) -> Map { +/// Photoshop's Fill defaults (no last-used prefs). Used by open and Alt+Reset. +pub fn default_fields(app: &PhotocraftApp) -> Map { let mut f = Map::new(); f.insert(MARK.into(), json!(true)); f.insert("__command".into(), json!(COMMAND)); @@ -68,6 +68,12 @@ pub fn fields(app: &PhotocraftApp) -> Map { f.insert("mode".into(), json!("normal")); f.insert("opacity".into(), json!(100.0)); f.insert("preserveTransparency".into(), json!(false)); + f +} + +/// The dialog's fields: Photoshop's defaults, overridden by the last choices. +pub fn fields(app: &PhotocraftApp) -> Map { + let mut f = default_fields(app); if let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) { for k in KEYS { if let Some(v) = saved.get(k).filter(|v| valid(app, k, v)) { @@ -78,6 +84,19 @@ pub fn fields(app: &PhotocraftApp) -> Map { f } +/// Alt+Cancel (Reset): Photoshop Fill defaults, not last-used prefs. Dialog stays open. +pub fn reset(app: &PhotocraftApp, fields: &mut Map) { + if !owns(fields) { + return; + } + let defaults = default_fields(app); + for k in KEYS { + if let Some(v) = defaults.get(k) { + fields.insert(k.into(), v.clone()); + } + } +} + /// Open the Fill dialog; returns its id. pub fn open(app: &mut PhotocraftApp) -> u64 { let f = fields(app); diff --git a/crates/ui-egui/src/fill_ui_tests.rs b/crates/ui-egui/src/fill_ui_tests.rs index aa5f2772eb..5d353127f8 100644 --- a/crates/ui-egui/src/fill_ui_tests.rs +++ b/crates/ui-egui/src/fill_ui_tests.rs @@ -74,6 +74,22 @@ fn mode_opacity_and_preserve_transparency() { assert!(close(px(&app, 5, 5), [0.5, 0.5, 0.5, 1.0])); } +#[test] +fn alt_reset_restores_photoshop_defaults_not_last_used() { + let mut app = app(); + // Remember a non-default choice, then open (which restores it) and Reset. + fill(&mut app, json!({"contents": "white", "opacity": 40})).unwrap(); + let id = open(&mut app); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["opacity"].as_f64(), Some(40.0)); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + reset(&app, &mut fields); + app.ui.dialog_mut(id).unwrap().fields = fields; + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["opacity"].as_f64(), Some(100.0)); + assert_eq!(f["contents"], json!("foreground")); + assert!(app.ui.dialogs.iter().any(|d| d.id == id)); +} + #[test] fn choices_persist_across_restarts() { let mut app = app(); diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 5f2f853fda..29fc436e0f 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -327,6 +327,70 @@ pub(crate) fn remember(app: &mut PhotocraftApp, command: &str, fields: &Map Option { + Some(match &p.kind { + Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32), + Kind::Range { default, .. } => json!(default), + Kind::Choice { options, default } => { + let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or(""); + json!(initial) + } + Kind::Bool(default) => json!(default), + Kind::Int { default } => json!(default), + Kind::Text => json!(""), + Kind::Document => json!(-1), + // Identity kernel: 1 in the centre. + Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), + Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)), + Kind::Color(None) => return None, + // Colour inputs come from the current swatches, so the proxy preview matches the result. + Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground), + Kind::Json if p.key == "background" => json!(app.session.tools.background), + Kind::Json => return None, + }) +} + +/// Command-specific open defaults (ruler straighten, warp from layer, …), not remembered prefs. +fn apply_command_defaults(app: &PhotocraftApp, command: &str, fields: &mut Map) { + if command == "image.rotation.arbitrary" { + straighten_defaults(app, fields); + } + if command == "type.warpText" { + warp_defaults(app, fields); + } + // Photoshop's Pattern Name dialog starts from the name the pattern would get anyway. + if command == "edit.definePattern" { + fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session))); + } + if command == "layer.layerStyle.globalLight" { + global_light_defaults(app, fields); + } + if command == "view.proofSetup" { + proof_setup_defaults(app, fields); + } +} + +/// Alt+Cancel (Reset): schema defaults + command hooks, without last-used remembered prefs. +pub fn reset_fields(app: &PhotocraftApp, fields: &mut Map) { + if !fields.contains_key("__filter") || fields.contains_key("__adjust") { + return; + } + let Some(command) = fields.get("__command").and_then(Value::as_str).map(str::to_owned) else { return }; + let spec = + fields.get("__spec").and_then(Value::as_str).map(str::to_owned).or_else(|| photocraft_engine::commands::find(&command).map(|c| c.params.to_owned())); + let Some(spec) = spec else { return }; + // Drop user params; keep private `__` chrome (and fixed keys outside the schema for `__spec`). + let schema_keys: std::collections::HashSet = parse_spec(&spec).into_iter().map(|p| p.key).collect(); + fields.retain(|k, _| k.starts_with("__") || !schema_keys.contains(k)); + for p in parse_spec(&spec) { + if let Some(v) = default_value(app, &command, &p) { + fields.insert(p.key, v); + } + } + apply_command_defaults(app, &command, fields); +} + pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let spec = photocraft_engine::commands::find(command)?; let mut fields = Map::new(); @@ -338,45 +402,12 @@ pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { fields.insert("__preview".into(), json!(true)); } for p in parse_spec(spec.params) { - let v = match &p.kind { - Kind::Range { default, .. } if command == "image.mode.indexedColor" && p.key == "colors" => json!(default.round().clamp(2.0, 256.0) as u32), - Kind::Range { default, .. } => json!(default), - Kind::Choice { options, default } => { - let initial = default.as_deref().or_else(|| options.first().map(String::as_str)).unwrap_or(""); - json!(initial) - } - Kind::Bool(default) => json!(default), - Kind::Int { default } => json!(default), - Kind::Text => json!(""), - Kind::Document => json!(-1), - // Identity kernel: 1 in the centre. - Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), - Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)), - Kind::Color(None) => continue, - // Colour inputs come from the current swatches, so the proxy preview matches the result. - Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground), - Kind::Json if p.key == "background" => json!(app.session.tools.background), - Kind::Json => continue, - }; - fields.insert(p.key, v); + if let Some(v) = default_value(app, command, &p) { + fields.insert(p.key, v); + } } restore_remembered(app, command, spec.params, &mut fields); - if command == "image.rotation.arbitrary" { - straighten_defaults(app, &mut fields); - } - if command == "type.warpText" { - warp_defaults(app, &mut fields); - } - // Photoshop's Pattern Name dialog starts from the name the pattern would get anyway. - if command == "edit.definePattern" { - fields.insert("name".into(), json!(photocraft_engine::pattern_cmds::default_name(&app.session))); - } - if command == "layer.layerStyle.globalLight" { - global_light_defaults(app, &mut fields); - } - if command == "view.proofSetup" { - proof_setup_defaults(app, &mut fields); - } + apply_command_defaults(app, command, &mut fields); if parse_spec(spec.params).iter().any(|p| p.kind == Kind::Document) { // The document picker lists every open document (params refer to them by index). let names: Vec = app.session.documents().iter().map(|d| d.doc.name.clone()).collect(); @@ -818,6 +849,24 @@ mod tests { assert_eq!(fields["radius"], json!(2.0)); } + #[test] + fn alt_reset_restores_schema_defaults_not_remembered() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.prefs.edit(|prefs| { + prefs.dialogs.insert("filter.blur.gaussianBlur".into(), json!({"radius": 11.0})); + }); + let id = open(&mut app, "filter.blur.gaussianBlur").unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["radius"], json!(11.0)); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + reset_fields(&app, &mut fields); + app.ui.dialog_mut(id).unwrap().fields = fields; + let f = &app.ui.dialog_mut(id).unwrap().fields; + // Registry default for gaussian blur radius is 1. + assert_eq!(f["radius"], json!(1.0)); + assert!(f.contains_key("__filter")); + assert!(app.ui.dialogs.iter().any(|d| d.id == id)); + } + #[test] fn remembering_dialogs_never_persists_paths_or_document_indices() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/guide_layout_ui.rs b/crates/ui-egui/src/guide_layout_ui.rs index d6c5c9fdbe..9117baa829 100644 --- a/crates/ui-egui/src/guide_layout_ui.rs +++ b/crates/ui-egui/src/guide_layout_ui.rs @@ -31,6 +31,33 @@ pub fn owns(f: &Map) -> bool { f.contains_key(MARK) } +/// Alt+Cancel (Reset): factory New Guide Layout values, not the last-used remembered layout. +pub fn reset(app: &PhotocraftApp, f: &mut Map) { + if !owns(f) { + return; + } + let Value::Object(defaults) = crate::view_cmds::default_guide_layout() else { return }; + // Drop layout params and interactive toggles; keep dialog chrome (`__command`, …). + f.retain(|k, _| { + k.starts_with("__") + && !matches!( + k.as_str(), + "__preset" | "__columnsEnabled" | "__rowsEnabled" | "__marginEnabled" | "__previewGuides" | "__previewParams" | "__previewSource" + ) + }); + f.extend(defaults); + f.insert("color".into(), json!(app.session.prefs().guides_grid_and_slices.guide_color)); + f.insert("target".into(), json!("document")); + f.insert("__preset".into(), json!("custom")); + f.insert("width".into(), Value::Null); + f.insert("height".into(), Value::Null); + let margin = f.get("margin").cloned().unwrap_or(json!(0)); + if !margin.is_array() { + f.insert("margin".into(), json!([margin.clone(), margin.clone(), margin.clone(), margin])); + } + f.insert("__preview".into(), json!(true)); +} + fn on(f: &Map, key: &str, fallback: bool) -> bool { f.get(key).and_then(Value::as_bool).unwrap_or(fallback) } diff --git a/crates/ui-egui/src/guide_layout_ui/tests.rs b/crates/ui-egui/src/guide_layout_ui/tests.rs index 17abf3e381..6ac36cb1d8 100644 --- a/crates/ui-egui/src/guide_layout_ui/tests.rs +++ b/crates/ui-egui/src/guide_layout_ui/tests.rs @@ -84,6 +84,23 @@ fn app() -> PhotocraftApp { PhotocraftApp::new(session, crate::Services::default()) } +#[test] +fn alt_reset_restores_factory_layout() { + let mut app = app(); + let id = open(&mut app); + let mut fields = app.ui.dialog_mut(id).unwrap().fields.clone(); + fields.insert("columns".into(), json!(3)); + fields.insert("gutter".into(), json!(99)); + fields.insert("__preset".into(), json!("twoColumn")); + reset(&app, &mut fields); + app.ui.dialog_mut(id).unwrap().fields = fields; + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["columns"], json!(8)); + assert_eq!(f["gutter"], json!(20)); + assert_eq!(f["__preset"], json!("custom")); + assert!(app.ui.dialogs.iter().any(|d| d.id == id)); +} + fn draw(app: &mut PhotocraftApp, id: u64) { let mut f = app.ui.dialog_mut(id).unwrap().fields.clone(); let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d6309afccc..08e3464dde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ pub mod crop_size; pub mod crop_straighten; pub mod crop_ui; pub mod delete_layer_prompt; +pub mod dialog_alt_reset; pub mod dialog_blend_ui; pub mod dialogs; pub mod direct_select; diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index a0fb7557b0..f4a9f54f06 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -46,6 +46,28 @@ pub fn is_sizing(command: &str) -> bool { matches!(command, "image.imageSize" | "image.canvasSize") } +/// Alt+Cancel (Reset): restore the dimensions and options from when the dialog opened. +pub fn reset(f: &mut Map) { + if !f.contains_key("__sizing") { + return; + } + let w = num(f, "__origW"); + let h = num(f, "__origH"); + f.insert("width".into(), json!(w)); + f.insert("height".into(), json!(h)); + if f.get("__command").and_then(Value::as_str) == Some("image.imageSize") { + f.insert("resolution".into(), json!(num(f, "__origRes"))); + f.insert("__constrain".into(), json!(true)); + if let Some(r) = f.get("__resample").cloned() { + f.insert("resample".into(), r); + } + } else { + f.insert("relative".into(), json!(false)); + f.insert("anchor".into(), json!("center")); + f.insert("extensionColor".into(), json!("background")); + } +} + pub fn open(app: &mut PhotocraftApp, command: &str) -> Option { let unit = pref_unit(app.session.prefs().units_and_rulers.rulers); let resample = pref_resample(app.session.prefs().general.image_interpolation); @@ -443,6 +465,22 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.size, photocraft_doc::Size::new(120, 50)); } + #[test] + fn alt_reset_restores_open_dimensions() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 100, "resolution": 72})).unwrap(); + let id = open(&mut app, "image.imageSize").unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + d.fields.insert("width".into(), json!(50)); + d.fields.insert("height".into(), json!(25)); + d.fields.insert("resolution".into(), json!(300)); + d.fields.insert("__constrain".into(), json!(false)); + reset(&mut d.fields); + assert_eq!((num(&d.fields, "width"), num(&d.fields, "height"), num(&d.fields, "resolution")), (200.0, 100.0, 72.0)); + assert_eq!(d.fields.get("__constrain"), Some(&json!(true))); + assert!(app.ui.dialogs.iter().any(|x| x.id == id), "reset leaves the dialog open"); + } + /// The dialog starts on Preferences › General › Image Interpolation: Bicubic Automatic opens /// as Automatic and the others as themselves (Bicubic Sharper used to open as Lanczos). #[test] diff --git a/crates/ui-egui/src/variables_ui.rs b/crates/ui-egui/src/variables_ui.rs index fc10df402f..a4700cddad 100644 --- a/crates/ui-egui/src/variables_ui.rs +++ b/crates/ui-egui/src/variables_ui.rs @@ -17,6 +17,16 @@ pub fn owns(fields: &Map) -> bool { fields.contains_key("__variables") } +/// Alt+Cancel (Reset): restore `__variables` from the open-time snapshot. +pub fn reset(fields: &mut Map) { + if !owns(fields) { + return; + } + if let Some(orig) = fields.get("__variablesOrig").cloned() { + fields.insert("__variables".into(), orig); + } +} + /// Layers available to bind, top to bottom: (id, display name, is-text). fn layer_options(app: &PhotocraftApp) -> Vec<(u64, String, bool)> { let Some(st) = app.session.active() else { return Vec::new() }; @@ -31,6 +41,8 @@ pub fn menu(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Option Value { +/// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions). +pub(crate) fn default_guide_layout() -> Value { json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false}) } @@ -933,9 +934,22 @@ fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choi if let Value::Object(m) = fields { f.extend(m); } + // Snapshot user-facing params at open so Alt+Reset can restore them. + let orig: Map = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect(); + f.insert("__formOrig".into(), Value::Object(orig)); app.ui.open_dialog(DialogKind::Command, f) } +/// Alt+Cancel (Reset): restore the `__form` params captured at open. +pub fn reset_form(f: &mut Map) { + if !f.contains_key("__form") { + return; + } + let Some(Value::Object(orig)) = f.get("__formOrig").cloned() else { return }; + f.retain(|k, _| k.starts_with("__")); + f.extend(orig); +} + fn label_of(key: &str) -> String { let mut out = String::new(); for (i, c) in key.chars().enumerate() { @@ -971,6 +985,25 @@ mod label_tests { } } +#[cfg(test)] +mod form_reset_tests { + use super::*; + use serde_json::json; + + #[test] + fn alt_reset_restores_form_orig() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let id = form(&mut app, "test.form", "Test", json!({"radius": 5.0, "on": true}), json!({})); + let f = &mut app.ui.dialog_mut(id).unwrap().fields; + f.insert("radius".into(), json!(99.0)); + f.insert("on".into(), json!(false)); + reset_form(f); + assert_eq!(f["radius"], json!(5.0)); + assert_eq!(f["on"], json!(true)); + assert!(f.contains_key("__formOrig")); + } +} + /// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns. pub fn form_body(ui: &mut egui::Ui, f: &mut Map) { let choices = f.get("__choices").cloned().unwrap_or(Value::Null); diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md new file mode 100644 index 0000000000..30db03fd1b --- /dev/null +++ b/docs/ui-dialogs.md @@ -0,0 +1,219 @@ +# Dialogs + +Modal and modeless parameter windows in the egui shell. They mirror Photoshop: no dimmed backdrop, +canvas pan/zoom under an open dialog, Tab cycles text and number fields only, Esc cancels, Enter +confirms when OK is enabled. Automation drives the same `fields` map as the UI (`ui.dialog.*` in +[`control-protocol.md`](control-protocol.md)). + +## Architecture + +| Piece | Location | Role | +|---|---|---| +| Frame, routing, OK/Cancel/Esc, stacking | `crates/ui-egui/src/dialogs.rs` | Single hub for every `UiState::dialogs` entry | +| Alt+Cancel → Reset (opt-in) | `crates/ui-egui/src/dialog_alt_reset.rs` | Relabels Cancel while Alt is held; dispatches per-marker `reset` | +| Tab-through-fields | `crates/ui-egui/src/field_tab.rs` | Photoshop-style Tab / ⇧Tab (tests in same file) | +| State | `crates/ui-egui/src/state.rs` | `Dialog`, `DialogKind`, `open_dialog` | +| Button row | `crates/ui-egui/src/widgets.rs` | `dialog_buttons`, `focus_first_field`; `number_edit` registers for Tab | +| Menu / command entry | `dialogs::open_command_dialog`, `menus.rs` | Picks a dedicated module, schema dialog, or bare confirm | +| Filters | `filter_dialog.rs` | Schema from engine params, live preview | +| Adjustments | `adjust_dialog.rs` | Shared editors with Properties; preview via `adjust_preview` | +| Generic forms | `view_cmds.rs` | `__form` grids for simple command params | +| Dedicated bodies | See **Command routing** below | `body` + usually `open` / `confirm` | + +**Routing.** Non-command kinds use `DialogKind` alone. `DialogKind::Command` bodies are chosen by +marker keys on `fields` (`owns()` or `contains_key("__export")`, etc.); **first match in +`dialogs::show` wins**, so new markers must not collide with an earlier arm. `show`, `confirm`, and +`cancel` share the same dispatch table—wire all three when adding a dialog. + +**Field contract.** `Dialog.fields` is a JSON map: command parameters agents read and write, plus +private keys prefixed with `__`. Never use `__` for user-facing or engine params. + +### `DialogKind` (top-level stack) + +| Kind | Body module | Typical entry | +|---|---|---| +| `NewDocument` | `new_doc_ui.rs` | File › New, `file.new` with no size params | +| `About` | `dialogs.rs` (About / Contributors / Models tabs) | Help › About PhotoCraft; `systemInfo: true` → System Info variant | +| `Command` | Routed—see below | Most menu commands with parameters | +| `LayerStyle` | `layer_style.rs` | Layer › Layer Style… | +| `Error` | inline in `dialogs.rs` | `message` field only (e.g. move lock, locked transform) | + +### Command routing (`DialogKind::Command`) + +Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows before adding a marker). + +| Match | Module | Role | +|---|---|---| +| `__guideLayout` | `guide_layout_ui.rs` | View › New Guide Layout… | +| `__fill` | `fill_ui.rs` | Edit › Fill… | +| `__patternFill` | `pattern_fill_ui.rs` | Pattern Fill layer / New Fill Layer › Pattern… | +| `__stroke` | `stroke_ui.rs` | Edit › Stroke… | +| `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) | +| `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation | +| `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N) | +| `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt | +| `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation | +| `__cropPresetNew` / `__cropPresetDelete` | `crop_presets.rs` | New / delete crop preset | +| `__variables` | `variables_ui.rs` | Image › Variables (Define, Data Sets, Apply) | +| `__web` / `__print` | `file_ui.rs` | Save for Web, Print | +| `__colorPicker` | `color_picker_ui.rs` | Foreground/background, nested field/command pickers (see nested keys below) | +| `__colorRange` | `color_range_ui.rs` | Select › Color Range… | +| `__prefsui` | `prefs_ui.rs` | Edit › Preferences… and related preset/import subdialogs | +| `__export` | `export_dialog.rs` | Export As (document or layer) | +| `__sizing` | `sizing.rs` | Image › Image Size… / Canvas Size… | +| `__adjust` | `adjust_dialog.rs` | Image › Adjustments… (also sets `__filter` + `__preview` for live preview) | +| `__filter` | `filter_dialog.rs` (+ `select_mask_view.rs` when `__command` is `select.refineEdge`) | Filter Gallery commands, Edit › Color Settings…, Select and Mask, and other schema dialogs | +| `__form` | `view_cmds.rs` | Simple param grids (e.g. slice options, many Image › Apply Image-style commands) | +| (none of the above) | — | **Bare confirm**: title + OK/Cancel only; OK runs `__command` with fields via generic `confirm` | + +**Menu shortcut.** `dialogs::open_command_dialog` opens guide layout, stroke, and color range +directly; then tries `adjust_dialog::open`, then `filter_dialog::open`; otherwise a bare confirm +with `__command` + `__label`. Other dedicated modules are opened from `menus.rs`, tools, or panel +actions with their marker already set. + +### Shared private keys + +| Key | Meaning | +|---|---| +| `__command` | Engine command id run on OK (bare confirm, filters, sizing, …) | +| `__label` | Menu/catalog label for the title | +| `__document` | Target document id (validate staleness) | +| `__dialog` / `__field` | Nested color picker: parent dialog id and field name to write on OK | +| `__params` | Extra command params for command-targeted color pickers | +| `__preview` | Live canvas preview enabled (filters and adjustments) | +| `__choices` | Dropdown options for `__form` dialogs | +| `__filter` | Filter-schema body (`filter_dialog`); also set on adjustments for preview plumbing | +| `__adjust` | Image › Adjustments editor kind (`adjust_dialog`) | +| `__form` | Generic `view_cmds::form_body` | +| `__formOrig` | Snapshot of non-`__` params at open; Alt+Reset restores it for `__form` dialogs | +| `__variablesOrig` | Snapshot of `__variables` at open; Alt+Reset restores it | +| `__altReset` | Set to `false` to opt a dialog out of Alt+Cancel → Reset | +| `__export` | Export As | +| `__sizing` | Image Size / Canvas Size | + +### Routing markers (dedicated `Command` bodies) + +| Key | Module | +|---|---| +| `__guideLayout` | `guide_layout_ui` | +| `__fill` | `fill_ui` | +| `__patternFill` | `pattern_fill_ui` | +| `__stroke` | `stroke_ui` | +| `__createShape` | `shape_dialog` | +| `__deleteLayer` | `delete_layer_prompt` | +| `__newLayer` | `new_layer_ui` | +| `__rasterize` | `rasterize_prompt` | +| `__resetAllTools` | `tool_reset` | +| `__cropPresetNew`, `__cropPresetDelete` | `crop_presets` | +| `__variables` | `variables_ui` | +| `__web`, `__print` | `file_ui` | +| `__colorPicker` | `color_picker_ui` | +| `__colorRange` | `color_range_ui` | +| `__prefsui` | `prefs_ui` (string sub-kind: `prefs`, `presetsIO`, …) | + +### Nested color pickers (`__colorPicker` on the stack) + +Same hub entry as standalone pickers; `color_picker_ui::confirm` branches on extra keys: + +| Extra key | Behaviour | +|---|---| +| `__layerStyleColor` | Layer Style child; OK writes effect colour on parent `LayerStyle` dialog | +| `__textStyleColor` | Character/paragraph style colour (type panels) | +| `__shapeFill` | Shape layer solid fill via `shape.edit` | +| `__command` = `layer.fill.solidColor.set` | Solid Color fill layer (no `__shapeFill`) | +| `__dialog` + `__field` | OK updates one field on a parent command dialog (e.g. Fill › Color…) | + +## Parallel modal systems (not the main stack) + +| System | Location | When | +|---|---|---| +| Unsaved changes | `discard_ui.rs` | Close, Revert, Close Others/All, Exit—Save / Don't Save / Cancel | +| Shell dialog | `control.rs`, `ui.inspect` id `"shell"` | Workspace, custom pixel aspect, show-extras options | +| Camera Raw | `camera_raw_ui.rs` | Separate OS window on desktop; see control-protocol § Camera Raw | +| Error message | `DialogKind::Error` | e.g. `move_lock`—message + OK | +| Nested pickers | `color_picker_ui` (+ markers in **Nested color pickers**) | Stack on parent; parent confirm refused while child open | + +## Rules + +### Architecture and commands + +1. **Thin UI, engine command on OK.** Run `app.run` or a dedicated `*_ui::confirm`; one history step unless the command itself batches. +2. **Register in the hub.** Add `owns()` (or a stable marker), `body()`, `open()` if needed, and matching arms in `dialogs::show`, `confirm`, and `cancel` (and `open_command_dialog` when menu-driven). +3. **Extend existing paths first.** Filters → `filter_dialog`; adjustments → `adjust_dialog`; simple param grids → `__form`; avoid a second modal stack unless product needs Camera Raw–level UX. +4. **`fields` is the automation contract.** Anything agents must drive lives in named fields, not only in local egui state. +5. **Remember last-used values** after successful OK where Photoshop does (`filter_dialog::remember`, prefs `dialogs["…"]`, module-specific helpers). + +### Chrome and layout + +6. **Use `dialog_buttons`** for OK / Cancel / Apply—platform order via `ButtonRole` (macOS default action in the corner). +7. **Respect width rules** in `dialogs::show` unless `file_ui::dialog_width`, color picker, or kind-specific mins apply (typical 380–440; Layer Style / export / picker up to 600; New Document 800). +8. **Scroll long bodies** inside the content (`DIALOG_CHROME` for filters); title and button row stay visible. +9. **Transparent backdrop**—no dimming; previews are judged at true contrast. +10. **Position:** first open centered; then persist offset (`place_id` per command or kind); title bar drag; clamp on screen. +11. **Stacking:** only the **top** dialog is interactive; non-top instances render disabled; raise the live dialog if an older modal steals focus. + +### Keyboard and focus + +12. **Tab / ⇧Tab:** text and number fields only. Frame calls `field_tab::begin` / `end`; each field calls `field_tab::register` (`number_edit` does this; custom `TextEdit` must register manually). +13. **Do not rely on egui default Tab** through sliders, dropdowns, checkboxes, or preset cards. +14. **Open on first value:** `first_field_focused` + `widgets::focus_first_field` for value-first dialogs (most filters; Image/Canvas Size; not Select and Mask view keys). +15. **Select-all on focus** when reached via Tab or field arrows (`field_tab::select_all`; see `value_field`, `new_doc_ui`). +16. **Enter** confirms when OK is enabled (`can_ok`); exceptions must be explicit (`guide_layout_ui`, color picker). +17. **Esc** on topmost dialog with no popup open → Cancel. **Click outside does not dismiss** (canvas may still pan/zoom via `free_pointer_over`, `pan_delta`, `free_press`). Esc ignores Alt (it does not become Reset). +18. **Alt** turns Cancel into **Reset** when the dialog opts in via `dialog_alt_reset::supported` (dialog stays open). Opt-in markers: `__guideLayout`, `__fill`, `__variables`, `__colorPicker`, `__sizing`, `__adjust` (all kinds), `__filter`, `__form`. Each module implements its own `reset`; set `"__altReset": false` to disable. + +### Canvas and preview + +19. **Live preview** for filters/adjustments uses `filter_preview` / `adjust_preview`; `cancel()` clears preview and related shell state. +20. **Under-dialog canvas** respects dialog rects and open popups; Hand / Space / middle-button pan on free canvas only. + +### Labels and i18n + +21. **Titles:** command dialogs use `__label` (catalogued; trailing `…` stripped in chrome); fixed kinds use `dialogs::title` + `tl!`. +22. **User-visible strings:** `tl!("…")`; command labels from the menu catalog where possible. + +### Confirm / cancel safety + +23. **Child dialogs first**—refuse parent confirm if a nested color picker is open; cancel parent closes children where implemented. +24. **Validate before close** when params can be inconsistent (`guide_layout_ui::validate` in `confirm`). +25. **Disable OK** when required input is missing (`crop_presets::can_confirm`, Preferences Apply when unchanged). +26. **Preferences over control:** whole-dialog OK/Apply may be refused; safe single prefs via `prefs.set` (see control-protocol). + +### Testing + +27. **egui_kittest:** open dialog, `run_steps`, key presses, assert `fields` or accessibility roles (`field_tab` tests are the reference harness). +28. **Tab-order regressions:** add tests when a dialog gains multiple text/number fields. +29. **Filter preview:** `dialogs.rs` tests for preview persistence across background jobs. + +### Anti-patterns + +30. No panics in `body` / `confirm` on bad input—errors to status or validation. +31. No hard-coded colours or radii—`theme::Tokens` and `widgets.rs`. +32. Do not skip `confirm` dispatch—Enter and `ui.dialog.confirm` use it. +33. Do not expect click-outside-to-close on `egui::Modal`. +34. Do not register sliders or dropdowns for `field_tab` unless product explicitly changes Photoshop parity. + +## Adding a command dialog (checklist) + +1. Choose path: see **Command routing** (`adjust_dialog`, `filter_dialog`, `__form`, `__sizing`, …, or new `*_ui` with a unique marker). +2. `open`: build `fields` (`__command`, `__label`, params, marker); `app.ui.open_dialog(DialogKind::Command, f)`. +3. `body(app, ui, &mut fields)`—mutate `fields` only; use shared widgets. +4. `confirm` / `cancel` arms in `dialogs.rs` (or delegate to module `confirm`). +5. Tab: register every text/number field; use `value_field` / `number_edit` where possible. +6. **Alt+Reset (optional):** implement `reset` on the module and add an arm in `dialog_alt_reset` (same dispatch order as `dialogs::show`). Snapshot open values under a `__*Orig` key when Reset should restore “as opened” rather than factory defaults. +7. Tests: at least open + confirm or Tab sequence if non-trivial; if Alt+Reset is supported, assert Reset restores fields and leaves the dialog open. +8. If menu-visible: command already in engine; menu id opens dialog via existing menu wiring. + +## Known gaps + +- **Tab registration** for plain `TextEdit` is manual; generic `__form` string fields may not join `field_tab` unless wired. +- **“Dialog module”** is any UI routed from `dialogs::show`, not only files named `*dialog*`. +- **Numeric field parity** (units in expressions, scrubby labels) is tracked in [`ui-parity.md`](ui-parity.md) (Navigation / numeric fields), not duplicated here. + +## Revision history + +| Date | Change | +|---|---| +| 2026-10-11 | Initial doc from dialog codebase audit | +| 2026-10-11 | Document all `DialogKind` values and `Command` routing markers | +| 2026-10-11 | Alt+Cancel → Reset hub (`dialog_alt_reset`); opt-in markers | From b3309e27d736a6de9f865390292ccfa2f86a3871 Mon Sep 17 00:00:00 2001 From: Ufuk Efe Date: Mon, 12 Oct 2026 18:01:46 +0300 Subject: [PATCH 149/523] i18n(tr): add Turkish UI translation catalog (#3809) Add Turkish (tr) translation catalog covering 100% of measured UI strings (1,727/1,727 keys, 3,731 catalog entries). - Register Turkish language in crates/ui-egui/src/i18n/mod.rs with complete_menus: true and plural_none - Add tr.tsv translation catalog - Add negotiation and coverage tests in system.rs, mod.rs and i18n_coverage.rs - Add MIT OR Apache-2.0 notice in LICENSE-translations.txt - Update docs/localization-parity.md Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + .../ui-egui/src/i18n/LICENSE-translations.txt | 5 +- crates/ui-egui/src/i18n/mod.rs | 7 +- crates/ui-egui/src/i18n/system.rs | 1 + crates/ui-egui/src/i18n/tr.tsv | 3784 +++++++++++++++++ docs/localization-parity.md | 4 +- xtask/src/i18n_coverage.rs | 1 + 7 files changed, 3800 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/i18n/tr.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9fc1fa2625..7aece035b7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,6 +20,7 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `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) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | 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 | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index bf2f8caaae..eae278e4dc 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,4 +1,4 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch and Czech UI translation catalogs are +PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech and Turkish UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. @@ -22,3 +22,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Czech translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Turkish translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index ecf21f46d0..be8cb5c15e 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 18] = [ +pub static LANGUAGES: [LangInfo; 19] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 18] = [ // Dutch; `nl-NL` and `nl-BE` locales both resolve here. LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here. + LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. LangInfo { code: "vi", name: "Tiếng Việt", source: catalog!("vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; @@ -418,6 +420,9 @@ mod tests { assert_eq!(lang_from_tag("it-CH"), Lang::from_code("it")); assert_eq!(lang_from_tag("nl_NL.UTF-8"), Lang::from_code("nl")); assert_eq!(lang_from_tag("nl-BE"), Lang::from_code("nl")); + assert_eq!(lang_from_tag("tr_TR.UTF-8"), Lang::from_code("tr")); + assert_eq!(lang_from_tag("tr-TR"), Lang::from_code("tr")); + assert_eq!(lang_from_tag("tr"), Lang::from_code("tr")); // Traditional Chinese: by region, by script, and with a region after the script. assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH())); assert_eq!(lang_from_tag("zh-TW"), Some(ZH())); diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 4fb0404f66..68afea8d85 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -96,6 +96,7 @@ mod tests { (vec!["pt-PT"], "pt-br"), (vec!["it-IT", "en-US"], "it"), (vec!["nl-BE", "fr-BE"], "nl"), + (vec!["tr-TR", "en-US"], "tr"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv new file mode 100644 index 0000000000..617ad81fd9 --- /dev/null +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -0,0 +1,3784 @@ +# Turkish (tr) catalog. Columns, separated by a TAB: context source translation +# (the format is described in ja.tsv). +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Turkish computing vocabulary (katman, seçim, maske, ayar katmanı, fırça, degrade ...). No +# proprietary localisation resource, string table or product UI was consulted or copied; add +# entries the same way. Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) +# stay as they are; the trailing "…" of dialog commands is kept. +# + Rows Satırlar + Number Sayı + Top: Üst: + Left: Sol: + Bottom: Alt: + Right: Doğru: + Target: Hedef: + Selected Artboards Seçilen Çalışma Yüzeyleri + Center Columns Merkezi Sütunlar + Clear Existing Guides Mevcut Kılavuzları Temizle + Invalid guide layout Geçersiz kılavuz düzeni + Leave empty for automatic size Otomatik boyut için boş bırakın + 5 Columns, 3 Rows 5 Sütun, 3 Satır + 8 Columns 8 Sütun + 12 Columns 12 Sütun + File Dosya + Edit Düzen + Image Görüntü + Layer Katman + Type Metin + Select Seç + Scope Kapsam + Top Left Sol Üst + Top Right Sağ Üst + Bottom Left Sol Alt + Bottom Right Sağ Alt + Filter Filtre + View Görünüm + Window Pencere + Help Yardım + (coming soon) (yakında) + New… Yeni… + New from Clipboard Pano'dan yeni + Open… Aç… + Open As… Farklı Aç… + Open EXR Parts… EXR Parçalarını açın… + Add beauty precomp from the AOVs AOV'lerden güzellik ön kompozisyonu ekleyin + Both Her ikisi de + Left Sol + Right Sağ + Open Recent Son Kullanılanları Aç + Clear Recent Files Son Dosyaları Temizle + Remove from Recent En Son'dan kaldır + Remove {name} from Recent {name} öğesini En Son'dan kaldır + Close Kapat + Minimize Küçült + Maximize Büyüt + Restore Geri yükle + Close All Tümünü Kapat + Close Others Diğerlerini Kapat + Save Kaydet + Save As… Farklı Kaydet… + Save a Copy… Bir Kopyasını Kaydet… + Revert Geri Döndür + Export Dışa Aktar + Quick Export as PNG PNG Olarak Hızlı Dışa Aktarma + Export As… Farklı Dışa Aktar… + Export Preferences… Dışa Aktarma Tercihleri… + Save for Web (Legacy)… Web İçin Kaydet (Eski)… + Artboards to Files… Çalışma Yüzeylerinden Dosyalara… + Artboards to PDF… Çalışma yüzeylerinden PDF'ye… + Layers to Files… Katmanlardan Dosyalara… + Layer Comps to Files… Dosyalara Katman Kompozisyonları… + Color Lookup Tables… Renk Arama Tabloları… + Data Sets as Files… Dosya Olarak Veri Kümeleri… + Paths to Illustrator… İllüstratöre Giden Yollar… + Render Video… Videoyu Oluştur… + Generate Oluştur + Image Assets Resim Varlıkları + Place Embedded… Gömülü Olarak Yerleştir… + Place Linked… Bağlantılı Olarak Yerleştir… + Package… Paketle… + Automate Otomatikleştir + Batch… Toplu… + Create Droplet… Damlacık oluştur… + Crop and Straighten Photos Fotoğrafları Kırp ve Düzelt + Contact Sheet II… İletişim Sayfası II… + Conditional Mode Change… Koşullu Mod Değişikliği… + Fit Image… Resmi Sığdır… + Lens Correction… Mercek Düzeltme… + Merge to HDR Pro… HDR Pro'ya Birleştir… + Photomerge… Fotoğraf Birleştir… + Scripts Komut dosyaları + Image Processor… Görüntü İşlemcisi… + Delete All Empty Layers Tüm Boş Katmanları Sil + Flatten All Layer Effects Tüm Katman Efektlerini Düzleştir + Flatten All Masks Tüm Maskeleri Düzleştir + Script Events Manager… Komut Dosyası Olay Yöneticisi… + Import İthalat + Browse… Göz at… + Reveal in Finder Finder'da Göster + Show in Explorer Explorer'da Göster + Show in Folder Klasörde Göster + File Info… Dosya Bilgileri… + Print… Yazdır… + Print One Copy Bir Kopya Yazdır + Exit Çıkış + Undo Geri Al + Redo Yinele + Toggle Last State Son Durumu Değiştir + Fade… Solmaya… + Cut Kes + Copy Kopyala + Copy Merged Birleştirilmiş Olarak Kopyala + Paste Yapıştır + Paste Special Özel Yapıştır + Paste in Place Yerine Yapıştır + Paste Into İçine Yapıştır + Paste Outside Dışına Yapıştır + Paste Lorem Ipsum Lorem Ipsum'u Yapıştır + Clear Temizle + Search… Ara… + Check Spelling… Yazımı Denetle… + Find and Replace Text… Metni Bul ve Değiştir… + Fill… Doldur… + Stroke… Kontur… + Content-Aware Fill… İçeriğe Uygun Dolgu… + Delete and Fill Selection Seçimi Sil ve Doldur + Content-Aware Scale İçeriğe Uygun Ölçek + Puppet Warp Kukla Çözgü + Perspective Warp Perspektif Çarpıtma + Free Transform Serbest Dönüştürme + Free Transform a Copy Bir Kopyayı Ücretsiz Dönüştürme + Transform Dönüştür + Again Yinele + Transform Again on a Copy Bir Kopyada Yeniden Dönüştür + Scale Ölçekle + Rotate Döndür + Skew Eğ + Distort Çarpıt + Perspective Perspektif + Warp Deforme Et + Flip Horizontal Yatay Çevir + Flip Vertical Dikey Çevir + Rotate 180° 180° Döndür + Rotate 90° Clockwise 90° Saat Yönünde Döndür + Rotate 90° Counter Clockwise 90° Saat Yönünün Tersine Döndür + 180° 180° + 90° Clockwise 90° Saat Yönünde + 90° Counter Clockwise 90° Saat Yönünün Tersine + Arbitrary… Keyfi… + Auto-Align Layers… Katmanları Otomatik Hizala… + Auto-Blend Layers… Katmanları Otomatik Karıştır… + Define Brush Preset… Fırça Ön Ayarını Tanımla… + Define Pattern… Deseni Tanımla… + Define Custom Shape… Özel Şekil Tanımlayın… + Purge Temizle + Clipboard Pano + Histories Geçmiş + Video Cache Video Önbelleği + All Tümü + Presets Ön ayarlar + Preset Manager… Ön Ayar Yöneticisi… + Migrate Presets Ön Ayarları Taşı + Export/Import Presets… Ön Ayarları Dışa/İçe Aktar… + Color Settings… Renk Ayarları… + Assign Profile… Profil Ata… + Convert to Profile… Profile Dönüştür… + Keyboard Shortcuts… Klavye Kısayolları… + Menus… Menüler… + Toolbar… Araç Çubuğu… + Preferences Tercihler + General… Genel… + Interface… Arayüz… + Workspace… Çalışma alanı… + Tools… Araçlar… + History Log… Geçmiş Günlüğü… + File Handling… Dosya İşleme… + Export… İhracat… + Performance… Performans… + Scratch Disks… Kazı Diskleri… + Cursors… İmleçler… + Transparency & Gamut… Şeffaflık ve Gamut… + Units & Rulers… Birimler ve Cetveller… + Guides, Grid & Slices… Kılavuzlar, Izgaralar ve Dilimler… + Plug-ins… Eklentiler… + Type… Tür… + Enhanced Controls… Gelişmiş Kontroller… + Camera Raw… Kamera Raw… + Raw Defaults… Ham Varsayılanlar… + Integrations… Entegrasyonlar… + AI Integrations… Yapay Zeka Entegrasyonları… + Install Plug-in… Eklentiyi Yükle… + Language Options Dil Seçenekleri + Settings… Ayarlar… + Mode Mod + Bitmap Bit eşlem + Grayscale Gri tonlamalı + Duotone Çift tonlu + Indexed Color İndekslenmiş Renk + RGB Color RGB Rengi + CMYK Color CMYK Rengi + Lab Color Laboratuvar Rengi + Multichannel Çok kanallı + 8 Bits/Channel 8 Bit/Kanal + 8 bit 8 bit + Integer Tamsayı + 16 Bits/Channel 16 Bit/Kanal + 16 bit 16 bit + 32 Bits/Channel 32 Bit/Kanal + 32 bit (float) 32 bit (kayan) + Floating point Kayan nokta + Color Table… Renk Tablosu… + Bitmap… Bit eşlem… + Duotone… Çift tonlu… + Indexed Color… İndekslenmiş Renk… + Adjustments Ayarlamalar + Brightness/Contrast… Parlaklık/Kontrast… + Levels… Düzeyler… + Curves… Eğriler… + Exposure… Pozlama… + Vibrance… Titreşim… + Hue/Saturation… Renk Tonu/Doygunluk… + Color Balance… Renk Dengesi… + Black & White… Siyah ve Beyaz… + Photo Filter… Fotoğraf Filtresi… + Channel Mixer… Kanal Karıştırıcı… + Color Lookup… Renk Arama… + Invert Tersine Çevir + Posterize… Posterleştir… + Threshold… Eşik… + Gradient Map… Degrade Eşlemesi… + Selective Color… Seçici Renk… + Shadows/Highlights… Gölgeler/Vurgular… + HDR Toning… HDR Tonlama… + Desaturate Doygunluğu Azalt + Match Color… Rengi Eşleştir… + Replace Color… Rengi Değiştir… + Equalize Eşitle + Auto Tone Otomatik Ton + Auto Contrast Otomatik Kontrast + Auto Color Otomatik Renk + Image Size… Görüntü Boyutu… + Canvas Size… Tuval Boyutu… + Image Rotation Görüntü Döndürme + Flip Canvas Horizontal Kanvası Yatay Çevir + Flip Canvas Vertical Kanvası Dikey Çevir + Crop Kırp + Trim… Kırp (Kenar)… + Reveal All Tümünü Göster + Duplicate… Çoğalt… + Apply Image… Resmi Uygula… + Calculations… Hesaplamalar… + Variables Değişkenler + Define… Tanımla… + Data Sets… Veri Setleri… + Apply Data Set… Veri Kümesini Uygula… + Variable Data Sets… Değişken Veri Setleri… + Trap… Tuzak… + Analysis Analiz + Set Measurement Scale… Ölçüm Ölçeğini Ayarla… + Select Data Points… Veri Noktalarını Seçin… + Record Measurements Ölçümleri Kaydet + Ruler Tool Cetvel Aracı + Count Tool Sayma Aracı + Place Scale Marker… Ölçek İşaretçisini Yerleştirin… + Custom… Özel… + New Yeni + Layer… Katman… + Group… Grup… + Group from Layers… Katmanlardan Grup… + Artboard… Çalışma yüzeyi… + Artboard from Group… Gruptan çalışma yüzeyi… + Artboard from Layers… Katmanlardan Çalışma Yüzeyi… + Layer via Copy Kopyalama yoluyla katman + Layer via Cut Kesim Yoluyla Katman + Layer From Background… Arka Plandan Katman… + Layer from Background… Arka Plandan Katman… + Background From Layer Katmandan Arka Plan + New Shape Layer Yeni Şekil Katmanı + New Type Layer Yeni Tip Katmanı + New Fill Layer Yeni Dolgu Katmanı + New Adjustment Layer Yeni Ayarlama Katmanı + New Layer Based Slice Yeni Katman Tabanlı Dilim + New Smart Object via Copy Kopyalama Yoluyla Yeni Akıllı Nesne + New Blank Video Layer Yeni Boş Video Katmanı + New Video Layer from File… Dosyadan Yeni Video Katmanı… + Duplicate Layer… Katmanı Çoğalt… + Delete Layer Katmanı Sil + Delete Sil + Rename Layer Katmanı Yeniden Adlandır + Create Layer Katman Oluştur + Layer Style Katman Stili + Blending Options… Karışım Seçenekleri… + Bevel & Emboss… Eğim ve Kabartma… + Use Global Light Global Light'ı kullanın + Inner Shadow… İç Gölge… + Inner Glow… İç Işıma… + Satin… Saten… + Color Overlay… Renk Kaplama… + Gradient Overlay… Degrade Kaplama… + Pattern Overlay… Desen Kaplama… + Outer Glow… Dış Işıma… + Drop Shadow… Alt Gölge… + Copy Layer Style Katman Stilini Kopyala + Paste Layer Style Katman Stilini Yapıştır + Clear Layer Style Katman Stilini Temizle + Global Light… Küresel Işık… + Hide All Effects Tüm Efektleri Gizle + Scale Effects… Ölçek Etkileri… + Smart Filter Akıllı Filtre + Convert for Smart Filters Akıllı Filtreler için Dönüştürme + Clear Smart Filters Akıllı Filtreleri Temizle + Disable Smart Filters Akıllı Filtreleri Devre Dışı Bırak + Delete Filter Mask Filtre Maskesini Sil + Disable Filter Mask Filtre Maskesini Devre Dışı Bırak + Solid Color… Düz Renk… + Gradient… Gradyan… + Pattern… Desen… + Layer Mask Katman Maskesi + Vector Mask vektör maskesi + Reveal Selection Seçimi Göster + Hide Selection Seçimi Gizle + Hide All Tümünü Gizle + From Transparency Şeffaflıktan + Delete Vector Mask Vektör Maskesini Sil + Enable Layer Mask Katman Maskesini Etkinleştir + Disable Layer Mask Katman Maskesini Devre Dışı Bırak + Enable Vector Mask Vektör Maskesini Etkinleştir + Link Layer Mask Bağlantı Katmanı Maskesi + Unlink Layer Mask Katman Maskesinin Bağlantısını Kaldır + Link Vector Mask Bağlantı Vektör Maskesi + Add Vector Mask Vektör Maskesi Ekle + Vector Mask from Current Path Geçerli Yoldan Vektör Maskesi + Create Clipping Mask Kırpma Maskesi Oluştur + Release Clipping Mask Kırpma Maskesini Serbest Bırakın + Smart Objects Akıllı Nesneler + Smart Object Akıllı Nesne + Convert to Smart Object Akıllı Nesneye Dönüştür + Convert to Embedded Gömülüye Dönüştür + Convert to Layers Katmanlara Dönüştür + Convert to Linked… Bağlantılıya Dönüştür… + Edit Contents İçeriği Düzenle + Replace Contents… İçeriği Değiştir… + Relink to File… Dosyaya Yeniden Bağla… + Export Contents… İçeriği Dışa Aktar… + Update Modified Content Değiştirilen İçeriği Güncelle + Update All Modified Content Değiştirilen Tüm İçeriği Güncelle + Stack Mode Yığın Modu + Entropy Entropi + Kurtosis Basıklık + Maximum Maksimum + Mean ortalama + Median Medyan + Minimum Asgari + Range Aralık + Skewness çarpıklık + Standard Deviation Standart Sapma + Summation Toplama + Variance Varyans + Video Layers Video Katmanları + Interpret Footage… Görüntüyü Yorumla… + Replace Footage… Görüntüyü Değiştir… + Reload Frame Çerçeveyi Yeniden Yükle + Show Altered Video Değiştirilen Videoyu Göster + Restore Frame Çerçeveyi Geri Yükle + Restore All Frames Tüm Çerçeveleri Geri Yükle + Insert Blank Frame Boş Çerçeve Ekle + Delete Frame Çerçeveyi Sil + Duplicate Frame Çerçeveyi Çoğalt + Frame from Layers Katmanlardan Çerçeve + Video Frames to Layers… Video Karelerinden Katmanlara… + Rasterize Rasterleştir + Rasterize Type Rasterleştirme Türü + Rasterize Type Layer Tip Katmanını Rasterleştir + Rasterize Shape Şekli Rasterleştir + New Guide Layout… Yeni Kılavuz Düzeni… + New Guides From Shape Shape'den Yeni Kılavuzlar + Lock Layers… Katmanları Kilitle… + Link Layers Katmanları Bağla + Select Linked Layers Bağlantılı Katmanları Seçin + Merge Layers Katmanları Birleştir + Merge Down Aşağı Birleştir + Merge Visible Görünürleri Birleştir + Stamp Visible Damga Görünür + Select Layer Above Yukarıdaki Katmanı Seçin + Select Layer Below Aşağıdaki Katmanı Seçin + Select Top Layer Üst Katmanı Seçin + Select Bottom Layer Alt Katmanı Seçin + Add Layer Above to Selection Seçime Yukarıdaki Katmanı Ekle + Add Layer Below to Selection Aşağıdaki Katmanı Seçime Ekle + Stamp Down Damgalama + Flatten Image Görüntüyü Düzleştir + Matting Paspas + Color Decontaminate… Renkli Dekontamine… + Remove Black Matte Siyah Matı Kaldır + Remove White Matte Beyaz Matı Kaldır + Defringe… Borcu kaldır… + Group Layers Grup Katmanları + Ungroup Layers Katmanların Grubunu Çöz + Hide Layers Katmanları Gizle + Arrange Sırala + Bring to Front En Öne Getir + Bring Forward Öne Getir + Send Backward Arkaya Gönder + Send to Back En Arkaya Gönder + Reverse Ters + Combine Shapes Şekilleri Birleştir + Unite Shapes Şekilleri Birleştir + Subtract Front Shape Ön Şekli Çıkart + Intersect Shape Areas Şekil Alanlarını Kesiş + Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut + Merge Shape Components Şekil Bileşenlerini Birleştir + Align Hizala + Top Edges Üst Kenarlar + Vertical Centers Dikey Merkezler + Bottom Edges Alt Kenarlar + Left Edges Sol Kenarlar + Horizontal Centers Yatay Merkezler + Right Edges Sağ Kenarlar + Distribute Dağıt + Vertically Dikey olarak + Horizontally Yatay olarak + Layer Edges Katman Kenarları + Isolate Layers Katmanları Yalıt + Layer Content Options… Katman İçerik Seçenekleri… + Fill Content İçeriği Doldur + Deselect Layers Katmanların Seçimini Kaldır + Find Layers Katmanları Bul + Layer Comps Katman Kompozisyonları + Layers Katmanlar + All Layers Tüm Katmanlar + Hidden Layers Gizli Katmanlar + Mask All Objects Tüm Nesneleri Maskele + Subject Konu + Sky Gökyüzü + Panels Paneller + Character Panel Karakter Paneli + Paragraph Panel Paragraf Paneli + Character Styles Panel Karakter Stilleri Paneli + Paragraph Styles Panel Paragraf Stilleri Paneli + Glyphs Panel Glifler Paneli + Anti-Alias Kenar Yumuşatma + Sharp Keskin + Crisp Çıtır + Strong Güçlü + Smooth Pürüzsüz + Windows LCD Windows LCD'si + Windows Windows + Orientation Oryantasyon + Horizontal Yatay + Vertical Dikey + OpenType OpenType + Standard Ligatures Standart Bitişik Harfler + Contextual Alternates Bağlamsal Alternatifler + Discretionary Ligatures İsteğe Bağlı Bitişik Harfler + Swash çalkalama + Oldstyle Eski tarz + Stylistic Alternates Stilistik Alternatifler + Titling Alternates Başlık Alternatifleri + Ornaments Süsler + Ordinals Sıra Sayıları + Fractions Kesirler + Create Work Path İş Yolu Oluştur + Convert to Shape Şekle Dönüştür + Warp Text… Metni Çözgü… + Convert to Point Text Nokta Metnine Dönüştür + Convert to Paragraph Text Paragraf Metnine Dönüştür + Default Features Varsayılan Özellikler + East Asian Features Doğu Asya Özellikleri + Middle Eastern Features Orta Doğu Özellikleri + Middle Eastern & South Asian Composer Orta Doğulu ve Güney Asyalı Besteci + Update All Text Layers Tüm Metin Katmanlarını Güncelle + Replace All Missing Fonts Tüm Eksik Yazı Tiplerini Değiştir + Resolve Missing Fonts… Eksik Yazı Tiplerini Çözün… + Font Preview Size Yazı Tipi Önizleme Boyutu + None Yok + Small Küçük + Medium Orta + Large Büyük + Extra Large Ekstra Büyük + Huge Devasa + Load Default Type Styles Varsayılan Yazım Stillerini Yükle + Save Default Type Styles Varsayılan Yazım Stillerini Kaydet + Deselect Seçimi Kaldır + Reselect Yeniden Seç + Inverse Ters Çevir + Inverse Selection Ters Seçim + Similar Benzer + Color Range… Renk Aralığı… + Focus Area… Odaklanma Alanı… + Select and Mask… Seç ve Maskele… + Smart Radius Akıllı Yarıçap + Shift Edge Kenarı Kaydır + Decontaminate Dekontaminasyon + Refine Edge Kenarı İyileştir + Modify Değiştir + Border… Kenarlık… + Smooth… Düzelt… + Expand… Genişlet… + Contract… Daralt… + Feather… Geçiş Yumuşatma… + Grow Büyüt + Transform Selection Seçimi Dönüştür + Edit in Quick Mask Mode Hızlı Maske Modunda Düzenleme + Load Selection… Seçimi Yükle… + Save Selection… Seçimi Kaydet… + Match All Tümünü Eşleştir + Last Filter Son Filtre + Filter Gallery… Galeriyi Filtrele… + Adaptive Wide Angle… Uyarlanabilir Geniş Açı… + Camera Raw Filter… Camera Raw Filtresi… + Liquify… Sıvılaştır… + Vanishing Point… Ufuk Noktası… + Blur Bulanıklaştırma + Blur Gallery Bulanıklaştırma Galerisi + Field Blur… Alan Bulanıklığı… + Iris Blur… İris Bulanıklığı… + Tilt-Shift… Tilt-Shift… + Path Blur… Yol Bulanıklığı… + Spin Blur… Döndürme Bulanıklığı… + Blur More Daha Fazla Bulanıklaştır + Box Blur… Kutu Bulanıklığı… + Gaussian Blur… Gauss Bulanıklığı… + Lens Blur… Mercek Bulanıklığı… + Motion Blur… Hareket Bulanıklığı… + Radial Blur… Radyal Bulanıklık… + Shape Blur… Şekil Bulanıklığı… + Smart Blur… Akıllı Bulanıklaştırma… + Surface Blur… Yüzey Bulanıklığı… + Average Ortalama + Displace… Yer değiştir… + Pinch… Tut… + Polar Coordinates… Kutupsal Koordinatlar… + Ripple… Dalgalanma… + Shear… Kesme… + Spherize… Küreselleştir… + Twirl… Döndür… + Wave… Dalga… + ZigZag… ZigZag… + Noise Gürültü + Add Noise… Gürültü Ekle… + Despeckle Benek giderme + Dust & Scratches… Toz ve Çizikler… + Median… Medyan… + Reduce Noise… Gürültüyü Azaltın… + Pixelate Pikselleştir + Color Halftone… Renk Yarım Tonu… + Crystallize… Kristalleşin… + Facet Faset + Fragment Parça + Mezzotint… Mezzotint… + Mosaic… Mozaik… + Pointillize… Noktalaştırın… + Render İşleme + Clouds Bulutlar + Difference Clouds Fark Bulutları + Fibers… Lifler… + Flame… Alev… + Lens Flare… Mercek Parlaması… + Lighting Effects… Aydınlatma Efektleri… + Relight… Yeniden aydınlat… + Picture Frame… Resim Çerçevesi… + Tree… Ağaç… + Sharpen Keskinleştir + Sharpen Edges Kenarları Keskinleştirin + Sharpen More Daha Fazla Keskinleştir + Smart Sharpen… Akıllı Keskinleştirme… + Unsharp Mask… Keskinliği Azaltma Maskesi… + High Pass… Yüksek Geçiş… + Stylize Stilize Et + Diffuse… Yaygın… + Emboss… Kabartma… + Extrude… Ekstrüzyon… + Find Edges Kenarları Bul + Oil Paint… Yağlı Boya… + Solarize Solarizasyon + Tiles… Fayans… + Trace Contour… Konturu İzle… + Wind… Rüzgar… + Video video + De-Interlace… Taramasızlaştırma… + NTSC Colors NTSC Renkleri + Other Diğer + Maximum… Maksimum… + Minimum… Asgari… + Offset… Ofset… + Plug-ins Eklentiler + Proof Setup Prova Kurulumu + Proof Colors Prova Renkleri + Gamut Warning Gamut Uyarısı + Working CMYK CMYK'de çalışıyor + Working Cyan Plate Çalışan Camgöbeği Plaka + Working Magenta Plate Çalışan Macenta Plaka + Working Yellow Plate Sarı Plaka Çalışması + Working Black Plate Siyah Plaka Çalışma + Working CMY Plate Çalışan CMY Plakası + Legacy Macintosh RGB Eski Macintosh RGB + Internet Standard RGB İnternet Standardı RGB + Monitor RGB RGB'yi izleyin + Color Blindness — Protanopia-type Renk Körlüğü — Protanopi tipi + Color Blindness — Deuteranopia-type Renk Körlüğü – Döteranopi tipi + Zoom In Yakınlaştır + Zoom Out Uzaklaştır + Fit on Screen Ekrana Sığdır + Fit Layer(s) on Screen Katmanları Ekrana Sığdır + Fit Artboard on Screen Çalışma Yüzeyini Ekrana Sığdır + 100% 100% + 200% 200% + Print Size Baskı Boyutu + Pixel Aspect Ratio Piksel En Boy Oranı + Pixel Aspect Ratio Correction Piksel En Boy Oranı Düzeltme + Custom Pixel Aspect Ratio… Özel Piksel En Boy Oranı… + Square Kare + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC Geniş Ekran (1.21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL Geniş Ekran (1,46) + Anamorphic 2:1 (2) Anamorfik 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + 32-bit Preview Options… 32-bit Önizleme Seçenekleri… + Screen Mode Ekran Modu + Standard Screen Mode Standart Ekran Modu + Full Screen Mode With Menu Bar Menü Çubuklu Tam Ekran Modu + Full Screen Mode Tam Ekran Modu + Cycle Screen Mode Döngü Ekranı Modu + Show Göster + Extras Ekstralar + Selection Edges Seçim Kenarları + Target Path Hedef Yolu + Current Path Geçerli Yol + Grid Izgara + Guides Kılavuzlar + Canvas Guides Kanvas Kılavuzları + Artboard Guides Çalışma Yüzeyi Kılavuzları + Count Sayım + Smart Guides Akıllı Kılavuzlar + Slices Dilimler + Notes Notlar + Pixel Grid Piksel Izgarası + Pixel Art Piksel Sanatı + Pixel Art Preview Piksel Sanat Önizlemesi + Pattern Preview Desen Önizlemesi + Brush Preview Fırça Önizlemesi + Mesh ağ + Edit Pins Pinleri Düzenle + Document Bounds Belge Sınırları + Show Extras Options… Ekstra Seçeneklerini Göster… + Rulers Cetveller + Snap Yapış + Snap To Şuna Yapış + Lock Guides Kılavuzları Kilitle + Clear Guides Kılavuzları Temizle + Clear Selected Artboard Guides Seçilen Çalışma Yüzeyi Kılavuzlarını Temizle + Clear Canvas Guides Şeffaf Kanvas Kılavuzları + New Guide… Yeni Kılavuz… + Lock Slices Dilimleri Kilitle + Clear Slices Dilimleri Temizle + Match Zoom Yakınlaştırmayı Eşleştir + Match Location Eşleşme Konumu + Match Rotation Maç Rotasyonu + Cascade Çağlayan + Tile Fayans + Tile All Horizontally Tümünü Yatay Olarak Döşe + Tile All Vertically Tümünü Dikey Olarak Döşe + 2-up Horizontal 2'li Yatay + 2-up Vertical 2'li Dikey + 3-up Horizontal 3'lü Yatay + 3-up Stacked 3'lü Yığılmış + 3-up Vertical 3'lü Dikey + 4-up 4'lü + 6-up 6'lı + Consolidate All to Tabs Tümünü Sekmelerde Birleştir + Float in Window Pencerede Kaydır + Float All in Windows Tümünü Windows'ta Kaydır + New Window for Document Belge için Yeni Pencere + Workspace Çalışma alanı + Essentials Temel bilgiler + Graphic and Web Grafik ve Web + Painting Boyama + Photography Fotoğrafçılık + New Workspace… Yeni Çalışma Alanı… + Delete Workspace… Çalışma Alanını Sil… + Reset Workspace Çalışma Alanını Sıfırla + Reset Tool Aracı Sıfırla + Reset All Tools Tüm Araçları Sıfırla + Reset all tools to their default settings? Tüm araçlar varsayılan ayarlarına sıfırlansın mı? + Lock Workspace Çalışma Alanını Kilitle + Theme Tema + Pro Theme Profesyonel Tema + Pro Medium Gray Theme Pro Orta Gri Tema + Pro Light Gray Theme Pro Açık Gri Tema + Pro Light Theme Profesyonel Işık Teması + Studio Theme Stüdyo Teması + Studio Light Theme Stüdyo Işık Teması + Classic Theme Klasik Tema + Solarized dark Solarize karanlık + Solarized Dark Theme Solarize Karanlık Tema + Breeze dark Esinti karanlık + Breeze Dark Theme Esinti Koyu Tema + Breeze light Esinti ışığı + Breeze Light Theme Esinti Işık Teması + Adwaita Light Theme Adwaita Işık Teması + Adwaita Dark Theme Adwaita Karanlık Tema + Adwaita Adwaita + Adwaita dark Adwaita karanlık + Next Theme Sonraki Tema + Actions Eylemler + Brush Settings Fırça Ayarları + Brushes Fırçalar + Channels Kanallar + Character Karakter + Character Styles Karakter Stilleri + Clone Source Klon Kaynağı + Color Renk + Glyphs Glifler + Gradients Degradeler + Histogram Histogram + History Tarih + Info Bilgi + Measurement Log Ölçüm Günlüğü + Modifier Keys Değiştirici Tuşlar + Navigator Gezgin + Options Seçenekler + Paragraph Paragraf + Paragraph Styles Paragraf Stilleri + Paths Yollar + Patterns Desenler + Properties Özellikler + Shapes Şekiller + Styles Stiller + Swatches Renk Örnekleri + Timeline Zaman çizelgesi + Tool Presets Araç Ön Ayarları + Tools Araçlar + About PhotoCraft PhotoCraft Hakkında + PhotoCraft Website PhotoCraft Web Sitesi + PhotoCraft on GitHub GitHub'da PhotoCraft + ArtCraft Website ArtCraft Web Sitesi + Join the ArtCraft Discord… ArtCraft Discord'una katılın… + Report an Issue… Bir Sorun Bildirin… + WIA Support… WIA Desteği… + Apply Uygula + HSB/HSL HSB/HSL + Invert… Ters çevir… + Load Files into Stack… Dosyaları Yığına Yükle… + Motion Hareket + Notes… Notlar… + Other… Diğer… + Remove Warp Split Warp Split'i Kaldır + Shape Şekil + Split Warp Crosswise Çözgüyü Çapraz Olarak Böl + Split Warp Horizontally Çözgüyü Yatay Olarak Böl + Split Warp Vertically Çözgüyü Dikey Olarak Böl + Statistics… İstatistikler… + Refine Strokes Konturları İyileştir +@plural {n} item|{n} items {n} öğe + Auto Otomatik + (print to the printer) (yazıcıya yazdırın) + + Add variable + Değişken ekle + 30 frames @ 30 fps 30 kare @ 30 fps + 3D LUT File 3D LUT Dosyası + Absolute Mutlak + Absolute Colorimetric Mutlak Kolorimetrik + Action Eylem + Adaptive Uyarlanabilir + Adaptive Wide Angle isn't open Uyarlanabilir Geniş Açı açık değil + Add Ekle + Add Layer Mask Katman Maskesi Ekle + Add Stop Durak Ekle + Add a layer style Katman stili ekleme + Add a mask (from the selection; {key} inverts) Bir maske ekleyin (seçimden; {key} tersine çevirir) + Add an adjustment Düzenleme ekle + Add disk Disk ekle + Add space after paragraph Paragraftan sonra boşluk ekle + Add space before paragraph Paragraftan önce boşluk ekleyin + Add to Sample Örneğe Ekle + Add to selection ({key}) Seçime ekle ({key}) + Adjust for Zoom Yakınlaştırma için Ayarlama + Adjustment visible Ayar görünür + Adobe RGB (1998) compatible Adobe RGB (1998) uyumlu + Align and Distribute Hizala ve Dağıt + Align and distribute Hizala ve dağıt + Align horizontal centers Yatay merkezleri hizalayın + Align left edges Sol kenarları hizala + Align right edges Sağ kenarları hizalayın + Aligned Hizalı + All Caps Tamamı Büyük Harf + All Slices Tüm Dilimler + All User Slices Tüm Kullanıcı Dilimleri + All shortcuts reset to PhotoCraft defaults. Tüm kısayollar PhotoCraft varsayılanlarına sıfırlanır. + Alt Alternatif + Always use pressure for opacity Opaklık için her zaman baskıyı kullanın + Always use pressure for size Her zaman boyuta göre baskı kullanın + Amount Tutar + Anchor: Çapa: + Angle Açı + Angle Gradient Açı Gradyanı + Angle Jitter Açı Değişimi + Answer the unsaved-changes prompt first Önce kaydedilmemiş değişiklikler istemini yanıtlayın + Anti-alias Kenar yumuşatma + Appearance Görünüm + Appearance (Layer Style) Görünüm (Katman Stili) + Apply Layer Mask Katman Maskesini Uygula + Apply Per Tip İpucu Başına Uygula + Apply next selected layer comp Sonraki seçilen katman kompozisyonunu uygula + Apply previous selected layer comp Önceki seçilen katman kompozisyonunu uygula + Artboard Çalışma yüzeyi + Artboard Background Color Çalışma Yüzeyi Arka Plan Rengi + Assign working space Çalışma alanı atayın + Author: Yazar: + Auto Hide Otomatik Gizle + Auto-Select: Otomatik Seç: + Automatically level near-horizontal line segments Yataya yakın çizgi bölümlerini otomatik olarak hizalayın + Automatically straighten and level Otomatik olarak düzeltin ve seviyelendirin + Automatically straighten near-vertical line segments Dikeye yakın çizgi bölümlerini otomatik olarak düzelt + Background Arka plan + Background Color Arka Plan Rengi + Background Contents Arka Plan İçeriği + Balance Bakiye + Baseline Temel + Baseline shift Taban çizgisi kayması + Basic Temel + Before (Y) Önce (Y) + Begin recording Kaydı başlat + Bend: Bükülme: + Bicubic Bikübik + Bicubic (smooth gradients) Bikübik (düzgün degradeler) + Bicubic Smoother (enlargement) Bikübik Pürüzsüzleştirici (büyütme) + Bicubic Sharper (reduction) Bikübik Daha Keskin (azaltma) + Bilinear Çift doğrusal + Black Siyah + Black & White Siyah Beyaz + Black Point Compensation Kara Nokta Telafisi + Black, White Siyah, Beyaz + Blacks Siyahlar + Blending Karıştırma + Blending Options Karıştırma Seçenekleri + Blue Mavi + Blue, Red, Yellow Mavi, Kırmızı, Sarı + Both Axes Her İki Eksen + Bottom Alt + Brightness Parlaklık + Brightness Jitter Parlaklık Değişimi + Brightness/Contrast Parlaklık/Kontrast + Bring the selected pin forward Seçilen pini öne getir + Brush Preset picker Fırça Ön Ayarı seçici + Brush Reconstruct Options Fırçayla Yeniden Yapılandırma Seçenekleri + Brush Tool Options Fırça Aracı Seçenekleri + Cache Level: Önbellek Düzeyi: + Camera Raw isn't open Camera Raw açık değil + Cancel İptal + Reset Sıfırla + Cancel (Esc) İptal (Esc) + Cancel any current edits (Esc) Mevcut düzenlemeleri iptal et (Esc) + Cancel current crop operation (Esc) Geçerli kırpma işlemini iptal et (Esc) + Cancel transform (Esc) Dönüşümü iptal et (Esc) + Cancel warp (Esc) Çözgüyü iptal et (Esc) + Canvas Kanvas + Canvas extension color: Kanvas uzatma rengi: + Capture Yakala + Capture the visibility, position and style of every layer with + below, then switch between versions. Aşağıdaki + ile her katmanın görünürlüğünü, konumunu ve stilini yakalayın, ardından sürümler arasında geçiş yapın. + Catch-up on Stroke End Strok Sonunda Yakalama + Center Merkez + Center Crop Marks Orta Kırpma İşaretleri + Center text Metni ortala + Change Değiştir + Change All Tümünü Değiştir + Change Screen Mode (F) Ekran Modunu Değiştir (F) + Change To: Şuna Değiştir: + Channel Mixer Kanal Mikseri + Channel: Kanal: + Check All Layers Tüm Katmanları Kontrol Et + Check Spelling Yazımı Kontrol Et + Clarity Netlik + Clear All Tümünü Temizle + Clear Override Geçersiz Kılmayı Temizle + Clear Samples Örnekleri Temizle + Click sets foreground · right-click sets background Ön planı ayarlar'a tıklayın · arka planı sağ tıklayın + Click the first point or press {key} to close · Esc cancels Kapatmak için ilk noktayı tıklayın veya {key} tuşuna basın · Esc iptal eder + Click to add a point · drag off or {key}-click or Delete to remove Nokta eklemek için tıklayın · sürükleyin veya {key} tuşuna basıp tıklayın veya kaldırmak için Sil + Click to apply, {key}-click to clear overrides, double-click for Style Options Uygulamak için tıklayın, geçersiz kılmaları temizlemek için {key}-tıklayın, Stil Seçenekleri için çift tıklayın + Click to edit the gradient Degradeyi düzenlemek için tıklayın + Click to restore the current colour Mevcut rengi geri yüklemek için tıklayın + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Yakınlaştırmak için tıklayın · {key}-uzaklaştırmak için tıklayın · yakınlaştırmak/uzaklaştırmak için sağa/sola sürükleyin + Click with the Note tool to add a note. Not eklemek için Not aracıyla tıklayın. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · İlk noktaya tıklayın: kapat · {key} bitiş · Esc iptal + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · {alt}-sürükleyin: tutamacı kırın · İlk noktayı tıklayın veya sürükleyin: kapat · {key} bitiş · Esc iptal + Clip to layer below Aşağıdaki katmana klip + Clipped Kırpılmış + Close Style Options Stil Seçeneklerini Kapat + Cmd Komut + Coated CMYK Kaplamalı CMYK + Color & Swatches Renk ve Renk Örnekleri + Color Balance Renk Dengesi + Color Detail Renk Detayı + Color Grading Renk Derecelendirmesi + Color Lookup Renk Arama + Color Management Renk Yönetimi + Color Mixer Renk Karıştırıcı + Color Mode Renk Modu + Color Priority Renk Önceliği + Color Range preview Renk Aralığı önizlemesi + Color: Renk: + Colorize Renklendir + Colors Renkler + Commit Perspective Warp ({key}) Perspektif Çarpıtma İşlemi ({key}) + Commit Puppet Warp ({key}) Kukla Çarpıtma İşlemi ({key}) + Commit any current edits ({key}) Mevcut düzenlemeleri tamamlayın ({key}) + Commit current crop operation ({key}) Geçerli kırpma işlemini gerçekleştir ({key}) + Commit transform ({key}) Dönüşümü kaydet ({key}) + Commit warp ({key}) Çözgü gerçekleştir ({key}) + Constant Sabit + Constrain proportions Oranları sınırla + Contact Sheet II İletişim Sayfası II + Content-Aware İçeriğe Duyarlı + Contiguous Bitişik + Contrast Kontrast + Control: Kontrol: + Convert document's colors to the working space Belgenin renklerini çalışma alanına dönüştürün + Convert to sRGB sRGB'ye dönüştür + Copies Kopyalar + Copper Bakır + Copyright Telif hakkı + Copyright and Contact Info Telif Hakkı ve İletişim Bilgileri + Corner Crop Marks Köşe Kırpma İşaretleri + Corner radius Köşe yarıçapı + Correction Düzeltme + Count Jitter Titreşimi say + Create Oluştur + Create Droplet Damlacık Oluştur + Create Texture Doku Oluştur + Create Video Timeline Video Zaman Çizelgesi Oluşturun + Create a new count group Yeni bir sayım grubu oluşturun + Create a new document or open an existing file. Yeni bir belge oluşturun veya mevcut bir dosyayı açın. + Create a new group Yeni bir grup oluştur + Create a new layer Yeni bir katman oluştur + Create new action Yeni eylem oluştur + Create new channel Yeni kanal oluştur + Create new document from current state Mevcut durumdan yeni belge oluştur + Create new fill or adjustment layer Yeni dolgu veya ayarlama katmanı oluştur + Create new gradient Yeni degrade oluştur + Create new group Yeni grup oluştur + Create new layer comp Yeni katman kompozisyonu oluştur + Create new pattern from the selection Seçimden yeni desen oluştur + Create new shape from the current path Geçerli yoldan yeni şekil oluştur + Create new snapshot Yeni anlık görüntü oluştur + Create new style (from the selected type) Yeni stil oluştur (seçilen türden) + Create new style from the selected layer Seçilen katmandan yeni stil oluştur + Create new tool preset Yeni araç ön ayarı oluştur + Crop Factor Mahsul Faktörü + Ctrl Ctrl + Current & Below Güncel ve Aşağıda + Current Layer Geçerli Katman + Current Size Mevcut Boyut + Current Tool Only Yalnızca Geçerli Araç + Curve Eğri + Curves Eğriler + Custom Özel + Custom Shapes Özel Şekiller + Cyan Camgöbeği + Cyan · Red Camgöbeği · Kırmızı + Darken Koyulaştır + Darks Karanlıklar + Data Sets Veri Setleri + Default Varsayılan + Default colours (D) Varsayılan renkler (D) + Default printer Varsayılan yazıcı + Default restored. Varsayılan geri yüklendi. + Define Tanımla + Define at least one variable first. İlk önce en az bir değişken tanımlayın. + Dehaze Buğu giderme + Delete Cropped Pixels Kırpılmış Pikselleri Sil + Delete Group Grubu Sil + Delete Layer Mask Katman Maskesini Sil + Delete Layers Katmanları Sil + Delete Shortcut Kısayolu Sil + Delete Timeline Zaman Çizelgesini Sil + Delete Tool Preset Araç Ön Ayarını Sil + Delete count group Sayım grubunu sil + Delete current channel Mevcut kanalı sil + Delete current state Mevcut durumu sil + Delete effect layer Efekt katmanını sil + Delete layer ‘{name}’? '{name}' katmanı silinsin mi? + Delete {count} layers? {names} {count} katman silinsin mi? {names} + Delete layer? Katman silinsin mi? + The document is closed Belge kapatıldı + The document or selected layers changed. Please request deletion again. Belge veya seçilen katmanlar değişti. Lütfen tekrar silme talebinde bulunun. + Missing delete command Silme komutu eksik + Invalid delete command Geçersiz silme komutu + Layer deleted, but confirmation preference could not be saved: {error} Katman silindi ancak onay tercihi kaydedilemedi: {error} + Delete layer Katmanı sil + Delete layer comp Katman kompozisyonunu sil + Delete note Notu sil + Delete selected measurements Seçilen ölçümleri sil + Delete style Stili sil + Delete tool preset Araç ön ayarını sil + Density Yoğunluk + Density: Yoğunluk: + Depth Derinlik + Depth Jitter Derinlik Değişimi + Description Açıklama + Deselect All Tümünün Seçimini Kaldır + Detail Detay + Diamond Gradient Elmas Degrade + Difference Fark + Diffusion Difüzyon + Discard the embedded profile (don't color manage) Gömülü profili atın (renk yönetimi yapmayın) + Discontiguous bitişik değil + Discord Discord + Dismiss Reddet + Display P3 Display P3 + Display Text Metni Görüntüle + Dither Titreşim + Divide… Böl… + Document Belge + Don't Save Kaydetme + No Hayır + Yes Evet + Done Bitti + Dots Noktalar + Drag a rectangle around the object Nesnenin çevresine bir dikdörtgen sürükleyin + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Kısıtlama eklemek için resmin üzerine sürükleyin; Yatay/dikey için kaydırma; silmek için sağ tıklayın. + Drag to move the active shape's path or the Work Path Etkin şeklin yolunu veya Çalışma Yolunu taşımak için sürükleyin + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Tıklayın: çapayı seçin · Sürükleyin: çapayı, tutamacı veya parçayı taşıyın · {shift}-tıklayın: ekleyin · {alt}-tıklayın: alt yolun tamamı + Draw with the Pen tool (P) or make a work path from a selection. Kalem aracıyla (P) çizim yapın veya seçimden bir çalışma yolu oluşturun. + Drop an image or PSD anywhere to open it. Açmak için bir görüntüyü veya PSD'yi herhangi bir yere bırakın. + Native file drag-and-drop is unavailable Yerel dosya sürükle ve bırak özelliği kullanılamıyor + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosyayı Kullan › Aç veya resmi dosya yöneticinize kopyalayıp {paste} ile yapıştırın. + To drop files, start PhotoCraft under XWayland: `{command}` Dosyaları bırakmak için XWayland altında PhotoCraft'ı başlatın: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Veya Tercihler › Performans › Linux görüntüleme sunucusunu X11: PhotoCraft olarak ayarlayın, ardından her zaman XWayland altında başlar. + Edit › Paste Düzenle › Yapıştır + Duplicate Layers… Katmanları Çoğalt… + Duplicate style Yinelenen stil + Edit Toolbar… Araç Çubuğunu Düzenle… + Use File › Open, or paste a copied image with {paste}. Dosya › Aç'ı kullanın veya kopyalanan görüntüyü {paste} ile yapıştırın. + Edit in Quick Mask Mode (Q) Hızlı Maske Modunda Düzenleme (Q) + Edit in Standard Mode (Q) Standart Modda Düzenleme (Q) + Effects Efektler + Embed Color Profile Renk Profilini Göm + Embedded Profile Mismatch Gömülü Profil Uyuşmazlığı + Enable airbrush-style build-up effects Airbrush stili oluşturma efektlerini etkinleştirin + Enhance Edge Kenarı Geliştir + Entire Font Yazı Tipinin Tamamı + Exact tam + Expansion: Genişleme: + Export Presets Ön Ayarları Dışa Aktarma + Export selected measurements (CSV) Seçilen ölçümleri dışa aktar (CSV) + Exposure Pozlama + Exposure and Gamma Pozlama ve Gama + Exposure: Pozlama: + Eyedropper Damlalık + Factor: Faktör: + Faux Bold Sahte Kalın + Faux Italic Sahte İtalik + Feather Tüy + Fewer Points Daha Az Puan + File Settings Dosya Ayarları + Fill Doldur + Fill: Doldurun: + Filter for adjustment layers Ayarlama katmanları için filtre + Filter for pixel layers Piksel katmanları için filtre + Filter for shape layers Şekil katmanları için filtre + Filter for smart objects Akıllı nesneler için filtre + Filter for type layers Yazım katmanları için filtre + Filter: Filtre: + Finger Painting Parmak Boyama + Fisheye Balıkgözü + Fit Uygun + Fit Screen Ekrana Sığdır + Fill Screen Doldurma Ekranı + Fit in View Görünüme Sığdır + Fixed Sabit + Fixed Ratio Sabit Oran + Fixed Size Sabit Boyut + Flip Çevir + Flip X X'i Çevir + Flip X Jitter X Değişimini Çevir + Flip Y Y Çevirme + Flip Y Jitter Çevir Y Titreşimi + Flow Akış + Flow Jitter Akış Değişimi + Focal Length Odak Uzunluğu + Font Yazı tipi + Font Size: Yazı Tipi Boyutu: + Font size Yazı tipi boyutu + Foreground Ön plan + Foreground to Background Ön Plandan Arka Plana + Foreground/Background Jitter Ön Plan/Arka Plan Değişimi + Format Biçim + Frame from Layers… Katmanlardan Çerçeve… + Free Ücretsiz + Full Spherical Tam Küresel + Fuzziness: Bulanıklık: + GPU GPU + Gamma Correction Gama Düzeltmesi + Gamma: Gama: + Global Küresel + Glow Color Parıltılı Renk + Gradient Map Gradyan Haritası + Grain Tahıl + Gray Gri + Gray Gamma 2.2 Gri Gama 2.2 + Green Yeşil + Group Grup + Hardness Sertlik + Heart Kalp + Height Yükseklik + Height: Yükseklik: + Hide Auto Slices Otomatik Dilimleri Gizle + Hide Properties Özellikleri Gizle + Highlight Compression Sıkıştırmayı Vurgula + Highlight Priority Önceliği Vurgula + Highlights Öne Çıkanlar + Home Ana sayfa + Horizontal scale Yatay ölçek + Hue Renk Tonu + Hue Jitter Ton Değişimi + Hue/Saturation Ton/Doygunluk + Hyphenate Tireleme + Ignore Yoksay + Ignore All Tümünü Yoksay + Image Size Resim Boyutu + Import Brushes… Fırçaları İçe Aktar… + Import brushes (.abr) Fırçaları içe aktar (.abr) + Import Presets Ön Ayarları İçe Aktar + Imported İthal + Indent First Önce Girintiyi Girin + Indent Left Sola Girinti + Indent Right Sağa Girinti + Indent first line İlk satıra girinti koy + Indent left margin Sol kenar boşluğunu girintile + Indent right margin Sağ kenar boşluğuna girinti + Input: Giriş: + Inside İçeride + Interlaced Taramalı + Interpolation: Enterpolasyon: + Intersect with selection ({key}) Seçimle kesiştir ({key}) + Invert All Tümünü Ters Çevir + Invert has no settings. Tersine çevirmenin hiçbir ayarı yoktur. + Italic italik + Join us on Discord Discord'da bize katılın + Justify all Hepsini gerekçelendir + Justify last centered Son ortayı iki yana yasla + Justify last left Son sola yasla + Justify last right Son sağa yasla + Kerning Karakter Aralığı + Keyboard Shortcuts Klavye Kısayolları + Keyboard Shortcuts and Menus Klavye Kısayolları ve Menüler + Kind tür + Label Size Etiket Boyutu + Labels Etiketler + Lanczos (sharp) Lanczos (keskin) + Landscape Manzara + Last Document State Son Belge Durumu + Layout Düzen + Leading Lider + Leading (set to the font size × auto-leading when Auto) Satır Aralığı (Otomatik olduğunda yazı tipi boyutu × otomatik satır aralığına ayarlanır) + Leave as is (don't color manage) Olduğu gibi bırakın (renk yönetimi yapmayın) + Left align text Metni sola hizala + Length: Uzunluk: + Level Seviye + Levels Seviyeler + Lighten Açıklaştır + Lightness hafiflik + Lights Işıklar + Limits: Sınırlar: + Linear Gradient Doğrusal Degrade + Link layers Katmanları bağlayın + Link width and height Bağlantı genişliği ve yüksekliği + Liquify is not open Sıvılaştırma açık değil + Load Yükle + Load 3D LUT… 3D LUT'u yükleyin… + Load channel as selection Kanalı seçim olarak yükle + Load… Yükle… + Localized Color Clusters Yerelleştirilmiş Renk Kümeleri + Lock Kilit + Lock all Tümünü kilitle + Lock image pixels Resim piksellerini kilitle + Lock layer Katmanı kilitle + Lock position Kilit konumu + Lock transparent pixels Şeffaf pikselleri kilitle + Lock: Kilit: + Logical Length: Mantıksal Uzunluk: + Logical Units: Mantıksal Birimler: + Luminance parlaklık + Luminance Detail Parlaklık Detayı + Magenta Macenta + Magenta · Green Macenta · Yeşil + Maintain aspect ratio En boy oranını koru + Marker Size İşaretleyici Boyutu + Mask All Tümünü Maskele + Mask Options Maske Seçenekleri + Masking Maskeleme + Master Usta + Matte Mat + Median: Medyan: + Menus Menüler + Mesh Size Örgü Boyutu + Metadata Meta veriler + Metrics Metrikler + Midpoint Orta nokta + Midtones Orta tonlar + Midtones from: Orta tonlar: + Midtones to: Orta tonlar: + Minimum Depth Minimum Derinlik + Minimum Diameter Minimum Çap + Minimum Roundness Minimum Yuvarlaklık + Mode: Mod: + Monochrome Tek renkli + More Points Daha Fazla Puan + Move effect layer down Efekt katmanını aşağı taşı + Move effect layer up Efekt katmanını yukarı taşı + Move to Şuraya taşı: + Name Ad + Name: Ad: + Nearest Neighbor En Yakın Komşu + Nearest Neighbor (hard edges) En Yakın Komşu (sert kenarlar) + New Size Yeni Boyut + New document… Yeni belge… + New effect layer Yeni efekt katmanı + New selection Yeni seçim + Next frame Sonraki kare + Next note Sonraki not + No Color Renk Yok + No Color Management Renk Yönetimi Yok + No Dither Titreşim Yok + No author Yazar yok + No character style Karakter stili yok + No document Belge yok + No matching commands Eşleşen komut yok + No presets for the current tool. Geçerli araç için ön ayar yok. + No presets of this type. Bu türden ön ayar yok. + No properties Mülk yok + No tool presets. Araç ön ayarı yok. + Noise Reduction Gürültü Azaltma + Normal Normal + Not in Dictionary: Sözlükte Yok: + OK Tamam + Offset Ofset + Only Web Colors Yalnızca Web Renkleri + Opacity Opaklık + Opacity Jitter Opaklık Değişimi + Opacity: Opaklık: + Open a document to define variables. Değişkenleri tanımlamak için bir belge açın. + Open a document to select a colour range. Bir renk aralığı seçmek için bir belge açın. + Open a document to use the timeline. Zaman çizelgesini kullanmak için bir belge açın. + Optical Optik + Optimized Optimize edilmiş + Option-click to define a source point to clone from Kopyalanacak bir kaynak noktası tanımlamak için Option tuşunu basılı tutarak tıklayın + Original Orijinal + Output Channel: Çıkış Kanalı: + Output Levels: Çıkış Seviyeleri: + Output: Çıkış: + Outside Dışarıda + Overlay Color Kaplama Rengi + Overlay Opacity Kaplama Opaklığı + Overlay: Rule of Thirds Kaplama: Üçte Bir Kuralı + Override Pressure Basıncı Geçersiz Kıl + Override Rotation Döndürmeyi Geçersiz Kıl + Override Tilt Eğimi Geçersiz Kıl + PRESET DETAILS ÖN AYAR DETAYLARI + PSD PSD + Paint Overlay Boya Kaplaması + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Panel konumları çalışma alanına kaydedilir. Klavye kısayolları, menüler ve araç çubuğu isteğe bağlıdır. + Paper Kağıt + Path Yol + Pattern Desen + Pattern Size Desen Boyutu + Pause Duraklat + Percent Yüzde + Perceptual algısal + Perspective Warp is not active Perspektif Çarpıtma etkin değil + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektif Çarpıtma: görüntünün perspektifi boyunca düzlemler çizin, ardından Çarpıtma'ya geçin + Photo Filter Fotoğraf Filtresi + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft Renkleri Yönetir + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — Rust'ta yazılmış açık kaynaklı, yerel bir resim düzenleyici. + Pin Depth: Pim Derinliği: + Pixel Length: Piksel Uzunluğu: + Pixels/Centimeter Piksel/Santimetre + Pixels/Inch Piksel/İnç + Pixels: Piksel: + Play Oyna + Play selection Seçimi oynat + Portrait Portre + Position Pozisyon + Position and Size Konum ve Boyut + Posterize Posterleştir + Preserve Details Ayrıntıları Koru + Preserve Luminosity Parlaklığı Koru + Preset Ön ayar + Preset Type Ön Ayar Türü + Preset: Ön ayar: + Presets: Ön ayarlar: + Press keys… Tuşlara basın… + Pressure Basınç + Pressure for Opacity Opaklık Basıncı + Pressure for Size Boyuta Göre Basınç + Prevent auto-nesting in and out of Artboards and Frames Çalışma Yüzeyleri ve Çerçevelerin içine ve dışına otomatik yerleştirmeyi önleyin + Preview Önizleme + Previous frame Önceki çerçeve + Previous note Önceki not + Print Yazdır + Printer Yazıcı + Printer Manages Colors Yazıcı Renkleri Yönetir + Printer Setup Yazıcı Kurulumu + Printing Marks Baskı İşaretleri + Progressive Aşamalı + Promote Tanıt + Protect Detail Detayı Koruyun + Protect Foreground Color Ön Plan Rengini Koruyun + Protect Tones Tonları Koruyun + Proximity Match Yakınlık Eşleşmesi + Pulled String Mode Çekilmiş Dize Modu + Puppet Warp is not active Kukla Çarpıtma etkin değil + Purity saflık + Quality Kalite + Quick Actions Hızlı Eylemler + Quick Export As PNG PNG Olarak Hızlı Dışa Aktarma + Radial Gradient Radyal Gradyan + Radius Yarıçap + Radius: Yarıçap: + Range: Aralık: + Rate Oran + Recently Used Son Kullanılanlar + Reconstruct Yeniden inşa et + Record ● a sequence of edits, then play ▶ it on any document. Bir dizi düzenlemeyi kaydedin ● ve ardından herhangi bir belgede oynatın. + Red Kırmızı + Redefine style by current selection Stili geçerli seçime göre yeniden tanımlayın + Refine Hassaslaştır + Reflected Gradient Yansıyan Degrade + Registration Marks Tescil İşaretleri + Regular Düzenli + Relative akraba + Relative Colorimetric Bağıl Kolorimetrik + Remove Kaldır + Remove All Pins Tüm Pinleri Kaldır + Remove Background Arka Planı Kaldır + Remove Planes Uçakları Kaldır + Remove stop Durağı kaldır + Remove variable Değişkeni kaldır + Rename Yeniden adlandır + Rename Group… Grubu Yeniden Adlandır… + Rename Layer… Katmanı Yeniden Adlandır… + Rename Tool Preset… Araç Ön Ayarını Yeniden Adlandır… + Rename… Yeniden adlandır… + Resample Yeniden örnekle + Reset All to Defaults Tümünü Varsayılanlara Sıfırla + Import Shortcuts… Kısayolları İçe Aktar… + Load a keyboard shortcut set saved as .kys .kys olarak kaydedilen bir klavye kısayolu kümesi yükleyin + Imported {n} shortcuts from {file}. Click OK to keep them. {n} kısayolları {file} adresinden içe aktarıldı. Bunları saklamak için Tamam'ı tıklayın. + {n} commands are not in PhotoCraft. {n} komutları PhotoCraft'ta değil. + Keyboard shortcuts imported İçe aktarılan klavye kısayolları + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. {set} klavye kısayol grubunuz burada kullanılıyor: {n} kısayolları varsayılanlardan farklı. Düzenle › Klavye Kısayolları bunları gösterir; Tümünü Varsayılanlara Sıfırla bu işlemi geri alır. + Import keyboard shortcuts? Klavye kısayolları içe aktarılsın mı? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft, {set} klavye kısayol setinizi bu bilgisayardaki başka bir resim düzenleyicide buldu: {n} kısayolları PhotoCraft'ınkilerden farklı. İçe aktarılsın mı? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Düzenle › Klavye Kısayolları › Tümünü Varsayılanlara Sıfırla, içe aktarma işlemini geri alır. + Don't Import İçe Aktarma + The keyboard shortcut set was not imported: {error} Klavye kısayol seti içe aktarılmadı: {error} + Reset Section Bölümü Sıfırla + Reset Transform Dönüşümü Sıfırla + Reset to defaults Varsayılanlara sıfırla + Resolution Çözünürlük + Resolution: Çözünürlük: + Restore All Tümünü Geri Yükle + Restore Defaults Varsayılanları Geri Yükle + Restrictive (Web) Kısıtlayıcı (Web) + Right align text Metni sağa hizala + Rigid Sert + Rotate: Döndür: + Rotation Döndürme + Roughness Pürüzlülük + Roundness Yuvarlaklık + Roundness Jitter Yuvarlaklık Değişimi + Rulers & Grids Cetveller ve Izgaralar + Sample All Layers Tüm Katmanları Örnekle + Sample: Örnek: + Sampling: Background Swatch Örnekleme: Arka Plan Renk Örneği + Sampling: Continuous Örnekleme: Sürekli + Sampling: Once Örnekleme: Bir kez + Saturate Doygunluk + Saturation Doygunluk + Saturation Jitter Doygunluk Değişimi + Save HTML and Images HTML ve Görselleri Kaydet + Save as PDF PDF olarak kaydet + Save selection as channel Seçimi kanal olarak kaydet + Save… Kaydet… + Scale to Fit Media Medyaya Sığacak Şekilde Ölçeklendirin + Scatter Dağılım + Scratch disks Kazıma diskleri + Search Ara + Search commands Arama komutları + Search commands, tools and panels… Komutları, araçları ve panelleri arayın… + Search fonts Yazı tiplerini arayın + Select All Tümünü Seç + Select Subject Konuyu Seçin + Select a type layer or click into text to insert glyphs Bir yazım katmanı seçin veya glif eklemek için metne tıklayın + Select text to apply a character style Karakter stili uygulamak için metni seçin + Select: Seçin: + Selections Seçimler + Selective seçici + Selective Color Seçici Renk + Send the selected pin backward Seçilen pini geriye gönder + Set additional smoothing options Ek yumuşatma seçeneklerini ayarlayın + Set background color Arka plan rengini ayarla + Set foreground color Ön plan rengini ayarla + Set painting symmetry options Boyama simetrisi seçeneklerini ayarlama + Symmetry Off Simetri Kapalı + Set shape fill type Şekil doldurma türünü ayarla + Set shape stroke type Şekil kontur türünü ayarlama + Set the text color Metin rengini ayarlama + Shadows Gölgeler + Shape: Şekil: + Sharpening Bileme + Shift Shift + Shortcut removed. Kısayol kaldırıldı. + Show All Menu Items Tüm Menü Öğelerini Göster + Show Backdrop Arka Planı Göster + Show Guides Kılavuzları Göster + Show Mask Maskeyi Göster + Show Mesh Mesh'i Göster + Show Overlay Kaplamayı Göster + Show Transform Controls Dönüştürme Kontrollerini Göster + Show or hide the Notes panel Notlar panelini gösterme veya gizleme + Show: Göster: + Sides Taraflar + Sides: Taraflar: + Size Boyut + Size Jitter Boyut Değişimi + Size: Boyut: + Slice Options… Dilim Seçenekleri… + Slices From Guides Kılavuzlardan Dilimler + Slices are locked (View › Lock Slices) Dilimler kilitlidir (Görünüm › Dilimleri Kilitle) + Small Caps Küçük Büyük Harfler + Smoothing Pürüzsüzleştirme + Space After Sonraki Boşluk + Space Before Önceki Boşluk + Spacing Aralık + Spell check complete. Yazım denetimi tamamlandı. + Split: Bölünmüş: + Star ratio Yıldız oranı + Stop playing/recording Oynatmayı/kaydetmeyi durdur + Straighten Düzelt + Straighten Layer Katmanı Düzleştir + Strength: Güç: + Strikethrough Üstü çizili + Stroke Kontur + Stroke Catch-up İnme Yakalama + Stroke: İnme: + Style Stil + Style Options Stil Seçenekleri + Style Options… Stil Seçenekleri… + Style: Normal Tarz: Normal + Subtract from Sample Örnekten Çıkart + Subtract from selection ({key}) Seçimden çıkar ({key}) + Suggestions: Öneriler: + Swap colours (X) Renkleri değiştir (X) + Swaps height and width Yüksekliği ve genişliği değiştirir + Switch between free transform and warp modes Serbest dönüştürme ve çarpıtma modları arasında geçiş yapın + Switch theme Temayı değiştir + Temperature Sıcaklık + Tetrahedral dörtyüzlü + Text Position: Metin Konumu: + Texture Doku + Texture Each Tip Her Ucu Doku + Threshold Eşik + Threshold Level Eşik Seviyesi + Thumbnail size (smallest: list) Küçük resim boyutu (en küçük: liste) + Tilt X X'i yatır + Tilt Y Y Eğimi + Tint Renk tonu + Toggle count group visibility Sayım grubu görünürlüğünü değiştir + Toggle text orientation Metin yönünü değiştir + Tolerance Tolerans + Tolerance: Tolerans: + Tone: Ton: + Toolbar Araç Çubuğu + Top Üst + Total: Toplam: + Tracking Takip + Tracking (1/1000 em) Takip (1/1000 em) + Transparency Şeffaflık + Transparent Şeffaf + Trilinear Üç çizgili + Type Styles Tip Stilleri + Type: Tür: + Underline Altı çizili + Untitled İsimsiz + Untitled-1 İsimsiz-1 + Update layer comp (visibility, position and appearance) Katman kompozisyonunu güncelleyin (görünürlük, konum ve görünüm) + Use Default Varsayılanı Kullan + Use Legacy Eskiyi Kullan + Use Measurement Scale Ölçüm Ölçeğini Kullan + Use the embedded profile (instead of the working space) Gömülü profili kullanın (çalışma alanı yerine) + Variables bind a layer's visibility, text or pixels to a named data slot. Değişkenler bir katmanın görünürlüğünü, metnini veya piksellerini adlandırılmış bir veri yuvasına bağlar. + Vertical scale Dikey ölçek + Vibrance Canlılık + View Options Seçenekleri Görüntüle + Vignetting Kenar ayrıntısı kaybı + Violet, Orange Menekşe, Turuncu + Visibility Görünürlük + Visible Görünür + Warp: Çözgü: + Web Snap Web Snap + Weight Ağırlık + Weight: Ağırlık: + White Beyaz + White Balance: As Shot Beyaz Dengesi: Çekildiği Gibi + Whites Beyazlar + Width Genişlik + Slant Eğimli + Width: Genişlik: + Work Path Çalışma Yolu + Yellow Sarı + Yellow · Blue Sarı · Mavi + Zoom Yakınlaştır + Zoom in Yakınlaştır + Zoom out Uzaklaştır + command or shortcut komut veya kısayol + current akım + image file path resim dosyası yolu + modified değiştirilmiş + name isim + new yeni + {name} options {name} seçenekler + ↑↓ navigate {key} run esc close ↑↓ gezin {key} çalıştır esc kapat + Version {version} Sürüm {version} + New Document Yeni Belge + New Document With Mask Maskeli Yeni Belge + New Layer With Mask Maskeli Yeni Katman + Save Preset… Ön Ayarı Kaydet… + Preset Name Ön Ayar Adı + No saved presets yet. Henüz kayıtlı ön ayar yok. + Error Hata + Command Komut + Adjustment layers have nothing to transform Ayarlama katmanlarının dönüştürülecek hiçbir şeyi yok + Altitude Rakım + Android 1080p Android1080p + Apple Watch 45mm Apple Watch 45mm + Aqua Su + Art & Illustration Sanat ve İllüstrasyon + ArtCraft ArtCraft + Background Eraser Tool Arka Plan Silgisi Aracı + Bevel & Emboss Eğim Ver ve Kabart + Blend Mode Karışım Modu + Blues Blues + Blur Tool Bulanıklık Aracı + Bold Kalın + Brush Fırça + Block Blok + Brush Pose Fırça Duruşu + Brush Tip Shape Fırça Ucu Şekli + Brush Tool Fırça Aracı + Build-up İnşaat + Burn Tool Koyulaştırma Aracı + Centimeters Santimetre + Choke Şok + Classic Klasik + Click to add text Metin eklemek için tıklayın + Click to fill similar colours Benzer renkleri doldurmak için tıklayın + Clone Stamp Tool Klonlama Damgası Aracı + Color Burn Renk Yanması + Color Dodge Renk Soldurma + Color Dynamics Renk Dinamiği + Color Overlay Renk Kaplaması + Comic Book Çizgi Roman + Conform Uygun + Content-Aware Fill needs a selection on a pixel layer İçeriğe Uygun Dolgu, piksel katmanında seçim yapılmasını gerektirir + Could not transform: the layer is empty Dönüştürülemedi: katman boş + Could not use Perspective Warp: the layer is empty Perspektif Çarpıtma kullanılamadı: katman boş + Could not use Puppet Warp: no opaque pixels Puppet Warp kullanılamadı: opak piksel yok + Could not use Puppet Warp: the layer is empty Puppet Warp kullanılamadı: katman boş + Crop Tool Kırpma Aracı + Crosswise Çapraz olarak + Current Tool Güncel Araç + Custom Shape Tool Özel Şekil Aracı + Cyans Camgöbeği + Default PhotoCraft Size Varsayılan PhotoCraft Boyutu + Delete Workspace Çalışma Alanını Sil + Diamond Elmas + Dimensions: Boyutlar: + Direction Yön + Distance Uzaklık + Document Dimensions Belge Boyutları + Document Profile Belge Profili + Document Sizes Belge Boyutları + Dodge Tool Açıklaştırma Aracı + Down Aşağı + Drag to draw a gradient Degrade çizmek için sürükleyin + Drag to move the active layer Etkin katmanı taşımak için sürükleyin + Drag to pan · hold Space with any tool Kaydırmak için sürükleyin · herhangi bir araçla Space tuşunu basılı tutun + Drop Shadow Alt Gölge + Dual Brush Çift Fırça + Edge Kenar + Efficiency Verimlilik + Efficiency: 100% Verimlilik: %100 + Ellipse Tool Elips Aracı + Elliptical Marquee Tool Eliptik İşaretleme Aracı + Single Row Marquee Tool Tek Satır İşaretleme Aracı + Single Column Marquee Tool Tek Sütun İşaretleme Aracı + Emboss Kabartma + Eraser Silgi + Eraser Tool Silgi Aracı + ExtraBold Ekstra Kalın + ExtraLight Ekstra Hafif + Eyedropper Tool Damlalık Aracı + Fade Solmaya + Fill Opacity Dolgu Opaklığı + Film & Video Film ve Video + From Gönderen + General Genel + GitHub GitHub + Gradient Overlay Degrade Yerleşimi + Gradient Tool Degrade Aracı + Greens Yeşiller + Hand Tool El Aracı + Hard Sert + Hard Mix Sert Karışım + Healing Brush Tool Düzeltme Fırçası Aracı + Highlights from: Öne çıkanlar: + History Brush Tool Geçmiş Fırçası Aracı + Horizontal Type Tool Yatay Metin Aracı + Horizontal Type Mask Tool Yatay Metin Maskesi Aracı + Vertical Type Mask Tool Dikey Metin Maskesi Aracı + Image Size: Resim Boyutu: + Inches İnç + Indexed Dizine eklendi + Initial Direction Başlangıç Yönü + Inner Bevel İç Eğim + Inner Glow İç Işıma + Inner Shadow İç Gölge + Lab Lab + Landscape, 10 x 8 Yatay, 10 x 8 + Landscape, 6 x 4 Yatay, 6 x 4 + Landscape, 7 x 5 Yatay, 7 x 5 + Lasso Kement + Lasso Tool Kement Aracı + Layer Count Katman Sayısı + Layer Knocks Out Drop Shadow Katman Alt Gölgeyi Ortadan Kaldırır + Legal Yasal + Letter Mektup + Light Işık + Line Tool Çizgi Aracı + Linear Doğrusal + Linear Burn Doğrusal Yanma + Linear Height Doğrusal Yükseklik + Link with Layer Katmanla Bağlantı + Lorem Ipsum Lorem İpsum + Magentas Macentalar + Magic Eraser Tool Sihirli Silgi Aracı + Magic Wand Tool Sihirli Değnek Aracı + Marquee Seçim Çerçevesi + Mean: Anlamı: + Measurement Scale Ölçüm Ölçeği + Measurement Scale Marker Ölçüm Ölçeği İşaretleyicisi + Millimeters Milimetre + Mobile Mobil + Move Tool Taşıma Aracı + Could not use the move tool because the layer is locked. Katman kilitli olduğundan taşıma aracı kullanılamadı. + Could not complete the Free Transform command because the layer is locked. Katman kilitli olduğundan Serbest Dönüştürme komutu tamamlanamadı. + Multiply Çoğalt + My Brushes Fırçalarım + Neutrals Nötrler + New Workspace Yeni Çalışma Alanı + Note Tool Not Aracı + Object Selection Tool Nesne Seçme Aracı + Off Kapalı + Open Aç + Orange Turuncu + Original Ratio Orijinal Oran + Out Of Gamut Gamut Dışı + Outer Bevel Dış Eğim + Outer Glow Dış Parıltı + Overlay Kaplama + Paint Bucket Tool Boya Kovası Aracı + Paints from the document's opening state Belgenin açılış durumundaki boyalar + Path Selection Tool Yol Seçimi Aracı + Direct Selection Tool Doğrudan Seçim Aracı + Add Anchor Point Tool Bağlantı Noktası Ekleme Aracı + Delete Anchor Point Tool Bağlantı Noktası Silme Aracı + Click a segment to add an anchor point · {alt}-click an anchor to delete it Bağlantı noktası eklemek için bir segmenti tıklayın · {alt}-silmek için bir bağlantıyı tıklayın + Click an anchor point to delete it · {alt}-click a segment to add one Silmek için bir bağlantı noktasını tıklayın · {alt}-bir parça eklemek için bir parçayı tıklayın + Pattern Overlay Desen Yerleşimi + Pen Pressure Kalem Basıncı + Pen Tilt Kalem Eğimi + Pen Tool Kalem Aracı + Convert Point Tool Nokta Dönüştürme Aracı + Photo Fotoğraf + PhotoCraft website PhotoCraft web sitesi + Picas Pikalar + Pillow Emboss Yastık Kabartma + Pixel Replacement Piksel Değiştirme + Pixels Piksel + Points Puanlar + Polygon Tool Çokgen Aracı + Polygonal Lasso Tool Çokgen Kement Aracı + Portrait, 4 x 6 Portre, 4 x 6 + Portrait, 5 x 7 Portre, 5 x 7 + PostScript (72 points/inch) PostScript (72 nokta/inç) + Postcard Kartpostal + Poster Poster + Pro (Dark) Pro (Koyu) + Pro (Light) Pro (Hafif) + Pro (Light Gray) Pro (Açık Gri) + Pro (Medium Gray) Pro (Orta Gri) + Protect Texture Dokuyu Koruyun + Purple Mor + Quick Mask Hızlı Maske + Quick Selection Tool Hızlı Seçim Aracı + Radial Radyal + Rasterize Layer Katmanı Rasterleştir + Rasterize Layers Katmanları Rasterleştir + Ratio Oran + Recent Son + Rectangle Tool Dikdörtgen Aracı + Rectangular Marquee Tool Dikdörtgen İşaretleme Aracı + Reds Kırmızılar + Reflected Yansıyan + Sample: foreground colour Örnek: ön plan rengi + Sampled Colors Örneklenen Renkler + Satin Saten + Save Pixel Aspect Ratio Piksel En Boy Oranını Kaydet + Scattering Saçılma + Scratch Sizes Çizik Boyutları + Select Data Points Veri Noktalarını Seçin + Selection Seçim + SemiBold Yarı Kalın + Shadows up to: Şuna kadar gölgeler: + Shape Dynamics Şekil Dinamiği + Sharpen Tool Keskinleştirme Aracı + Show Extras Options Ekstra Seçeneklerini Göster + Slice Select Tool Dilim Seçme Aracı + Slice Tool Dilim Aracı + Smart Filters Akıllı Filtreler + Smudge Tool Leke Aracı + Soft Yumuşak + Soften Yumuşat + Source Kaynak + Source: set Kaynak: ayarlandı + Sponge Tool Sünger Aracı + Spot Healing Brush Tool Nokta Düzeltme Fırçası Aracı + Spread Yayılma + Square, 12 x 12 Kare, 12x12 + Square, 5 x 5 Kare, 5x5 + Std Dev: Standart Geliştirme: + Studio (Dark) Stüdyo (Karanlık) + Studio (Light) Stüdyo (Işık) + Stylus Wheel Kalem Tekerleği + Subtract Çıkar + Tabloid Tabloid + Text Replacement Metin Değiştirme + Thin İnce + Traditional (72.27 points/inch) Geleneksel (72,27 nokta/inç) + Transfer Aktarım + Triangle Tool Üçgen Aracı + Unsaved changes Kaydedilmemiş değişiklikler + Uses the foreground and background colours. Ön plan ve arka plan renklerini kullanır. + Web ağ + Web Large Web Büyük + Web Minimum Web Minimumu + Web Most Common Web En Yaygın + WebP (lossless) WebP (kayıpsız) + Wet Edges Islak Kenarlar + Yellows Sarılar + Zoom Tool Yakınlaştırma Aracı + {name} Properties {name} Özellikler + Revert “{name}” to the last saved version? Your changes will be lost. “{name}” son kaydedilen sürüme döndürülsün mü? Değişiklikleriniz kaybolacak. + Do you want to save the changes you made to “{name}” before quitting? Çıkmadan önce “{name}” öğesinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? + Do you want to save the changes you made to “{name}” before closing? Kapatmadan önce “{name}” üzerinde yaptığınız değişiklikleri kaydetmek istiyor musunuz? + Doc: {flat}/{layered} Doküman: {flat}/{layered} + Scratch: {size} Çizik: {size} + Untagged {mode} Etiketlenmemiş {mode} + Invalid {mode} profile Geçersiz {mode} profili + Unnamed {mode} profile Adsız {mode} profili + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Çözünürlük: {dpi} piksel/inç + Doc: {w} × {h} px Doküman: {w} × {h} piksel + Frame {current} / {total} Çerçeve {current} / {total} + Count: {n} Sayım: {n} + BLANK DOCUMENT PRESETS ({n}) BOŞ BELGE ÖN AYARLARI ({n}) + Constraints ({n}) Kısıtlamalar ({n}) + Align {what} {what} hizala +@plural {n} Layer|{n} Layers {n} Katman +@plural {n} layer|{n} layers {n} katman +@plural Group · {n} layer|Group · {n} layers Grup · {n} katman + Additional plugins folder Ek eklentiler klasörü + Adobe Adobe + Adobe rgb Adobe rgb + Allow agent control Aracı kontrolüne izin ver + Allow scripts to connect Komut dosyalarının bağlanmasına izin ver + Always Her zaman + Always create smart objects when placing Yerleştirirken daima akıllı nesneler yaratın + Animated zoom Animasyonlu yakınlaştırma + Apply auto tone Otomatik tonu uygula + Ask Sor + Ask before saving layered tiff Katmanlı tiff'i kaydetmeden önce sor + Auto collapse icon panels Simge panellerini otomatik daralt + Auto show hidden panels Gizli panelleri otomatik göster + Auto show home screen Ana ekranı otomatik göster + Autosave Otomatik kaydetme + Autosave minutes Dakikaları otomatik kaydet + Beep when done Bittiğinde bip sesi + Bicubic automatic Bikübik otomatik + Bicubic sharper Bikübik daha keskin + Bicubic smoother Bikübik daha pürüzsüz + Bit depth Bit derinliği + Brush preview color Fırça önizleme rengi + Cache levels Önbellek düzeyleri + Cache tile size Önbellek kutucuğu boyutu + Camera Raw Defaults Camera Raw Varsayılanları + Canvas border Kanvas kenarlığı + Show canvas dots Kanvas noktalarını göster + Canvas color Kanvas rengi + Canvas custom color Kanvas özel rengi + Color picker Renk seçici + Color space Renk alanı + Column width Sütun genişliği + Concise Kısa + Control port Kontrol portu + Convert to srgb srgb'ye dönüştür + Cursors İmleçler + Custom dark Özel koyu + Custom light Özel ışık + Dark Karanlık + Dark gray Koyu gri + Dashed lines Kesikli çizgiler + Destination Hedef + Detailed Detaylı + Disks Diskler + Display p3 Ekran p3 + Double click layer mask launches select and mask Çift tıklama katman maskesi seçme ve maskelemeyi başlatır + Drop shadow Alt gölge + Dynamic color sliders Dinamik renk kaydırıcıları + East asian Doğu Asya + Effect cache mb Efekt önbelleği mb + Enable flick panning Hızlı kaydırmayı etkinleştir + Enable floating document window docking Kayan belge penceresi yerleştirmeyi etkinleştir + Enable narrow options bar Dar seçenekler çubuğunu etkinleştir + Enabled Etkin + Enhanced Controls Gelişmiş Kontroller + Export clipboard Panoyu dışa aktar + Extra large Ekstra büyük + File Handling Dosya İşleme + File path Dosya yolu + Fill new type layers with placeholder Yeni yazı katmanlarını yer tutucuyla doldurma + Font preview Yazı tipi önizlemesi + Full size tip Tam boyutlu uç + Gamut warning color Gamut uyarı rengi + Gamut warning opacity Gamut uyarı opaklığı + Generator enabled Jeneratör etkin + Gif Gif + Glossy parlak + Grid color Izgara rengi + Grid colors Izgara renkleri + Grid size Izgara boyutu + Grid style Izgara stili + Grid unit Izgara ünitesi + Gridline every Kılavuz çizgisi her + Guide color Kılavuz rengi + Guide style Kılavuz stili + Guides, Grid & Slices Kılavuzlar, Izgaralar ve Dilimler + Gutter Oluk + History Log Geçmiş Günlüğü + History states Tarih durumları + Ignore exif profile tag Exif profil etiketini yoksay + Image interpolation Görüntü enterpolasyonu + Image previews Resim önizlemeleri + Integrations Entegrasyonlar + Interface Arayüz + Jpeg quality JPEG kalitesi + Jpg Jpg + Language Dil + Large tabs Büyük sekmeler + Legacy compositing Eski birleştirme + Light gray Açık gri + Line Hat + Lines çizgiler + Lossless Kayıpsız + Lowercase extension Küçük harf uzantısı + Maximize psd compatibility PSD uyumluluğunu en üst düzeye çıkarın + Medium gray Orta gri + Memory usage mb Bellek kullanımı mb + Low resolution previews Düşük çözünürlüklü önizlemeler + Missing glyph protection Glif koruması eksik + Modern Çağdaş + Nearest neighbor En yakın komşu + Never Asla + Normal tip Normal uç + Open as smart object Akıllı nesne olarak aç + Open in camera raw Camera raw'da aç + Open documents as tabs Belgeleri sekmeler halinde aç + Overscroll Aşırı kaydırma + Performance Performans + Png png + Point size Nokta boyutu + Precise Hassas + Preserve details Ayrıntıları koru + Print resolution Yazdırma çözünürlüğü + Pro Profesyonel + Pro light Profesyonel ışık + Pro light gray Pro açık gri + Pro medium Profesyonel orta + Pro photo Profesyonel fotoğraf + Quick export format Hızlı dışa aktarma formatı + Quick export location Hızlı dışa aktarma konumu + Recent file count Son dosya sayısı + Recent files Son dosyalar + Recent fonts En yeni yazı tipleri + Recover on launch Başlatıldığında kurtar + Remember workspace changes Çalışma alanı değişikliklerini hatırla + Resize image during place Yerleştirme sırasında resmi yeniden boyutlandır + Rotate view with trackpad İzleme dörtgeniyle görünümü döndürme + Same folder Aynı klasör + Save in background Arka planda kaydet + Scratch Disks Kazı Diskleri + Screen Ekran + Screen resolution Ekran çözünürlüğü + Scrubby slider acceleration Temiz kaydırıcı hızlandırması + Sessions only Yalnızca oturumlar + Sharpen for Şunun için keskinleştir: + Show channels in color Kanalları renkli göster + Show crosshair in brush tip Fırça ucunda artı işaretini göster + Show extension panels Uzatma panellerini göster + Show font names in english İngilizce yazı tipi adlarını göster + Show menu colors Menü renklerini göster + Show only crosshair while painting Boyama sırasında yalnızca artı işaretini göster + Show slice numbers Dilim numaralarını göster + Show tooltips Araç ipuçlarını göster + Show highlight on rollover Rollover sırasında vurgulamayı göster + Show bounding box when dragging layer Katmanı sürüklerken sınırlayıcı kutuyu göster + System title bar Sistem başlık çubuğu + Show transformation values Dönüşüm değerlerini göster + Slice color Dilim rengi + Smart guide color Akıllı kılavuz rengi + Smart quotes Akıllı tırnaklar + Snap vector tools and transforms to pixel grid Vektör araçlarını tutturun ve piksel ızgarasına dönüştürün + Srgb Srgb + Standard Standart + Studio Stüdyo + Studio light Stüdyo ışığı + Subdivisions Alt bölümler + System Sistem + Text engine Metin motoru + Text file Metin dosyası + Tiny minik + Touch gestures Dokunma hareketleri + Transparency & Gamut Şeffaflık ve Gamut + Type units Tip birimleri + UI font size kullanıcı arayüzü yazı tipi boyutu + UI scale Kullanıcı arayüzü ölçeği + Units & Rulers Birimler ve Cetveller + Use additional plugins folder Ek eklentiler klasörünü kullan + Use esc to commit Taahhüt etmek için esc kullanın + Use gpu GPU'yu kullan + Use legacy free transform Eski ücretsiz dönüşümü kullanın + Use shift key for tool switch Araç anahtarı için Shift tuşunu kullanın + Vary round brush hardness on hud Hud üzerinde yuvarlak fırça sertliğini değiştirin + Webp Webp + World ready Dünya hazır + Zoom clicked point to center Tıklanan noktayı merkeze yakınlaştır + Zoom resizes windows Yakınlaştırma pencereleri yeniden boyutlandırır + Zoom with scroll wheel Kaydırma tekerleği ile yakınlaştırma + Zoom with trackpad pinch İzleme dörtgeni tutamıyla yakınlaştırma + Pass Through Geçirgen + Dissolve Eritme + Darker Color Daha Koyu Renk + Linear Dodge (Add) Doğrusal Soldurma (Ekle) + Lighter Color Daha Açık Renk + Soft Light Yumuşak Işık + Hard Light Sert Işık + Vivid Light Canlı Işık + Linear Light Doğrusal Işık + Pin Light Nokta Işığı + Exclusion Dışlama + Divide Böl + Luminosity Parlaklık +@plural {n} sample|{n} samples {n} örnek + {added}, {removed} subtracted {added}, {removed} çıkarıldı + Adjustment Preview Ayar Önizlemesi + Airbrush: keeps depositing paint while the pointer rests. Airbrush: İşaretçi dururken boyayı bırakmaya devam eder. + Angle: Açı: + Blend If Eğer karıştır + Close Tab Group Sekme Grubunu Kapat + Collapse Daralt + Collapse All Groups Tüm Grupları Daralt + Collapse Panel Group Panel Grubunu Daralt + Collapse group ({key}-click: all groups) Grubu daralt ({key}-tıklama: tüm gruplar) + Create new brush from the current settings Mevcut ayarlardan yeni fırça oluştur + Create new brush from these settings Bu ayarlardan yeni fırça oluşturun + Update the current brush with these settings Mevcut fırçayı bu ayarlarla güncelleyin + Delete brush Fırçayı sil + Drag the arrow to set the angle, the dots to set the roundness Açıyı ayarlamak için oku, yuvarlaklığı ayarlamak için noktaları sürükleyin + Expand Genişlet + Expand Panel Group Panel Grubunu Genişlet + Expand group ({key}-click: all groups) Grubu genişlet ({key}-tıklayın: tüm gruplar) + Fade steps Adımları soldur + Hide the layer's effects ({key}-click: all layers) Katmanın efektlerini gizle ({key}-tıklama: tüm katmanlar) + Import Brushes… (.abr) Fırçaları İçe Aktar… (.abr) + Move Group Down Grubu Aşağı Taşı + Move Group Up Grubu Yukarı Taşı + Reset the brush to the defaults Fırçayı varsayılanlara sıfırlayın + Restore original size Orijinal boyutu geri yükle + Roundness: Yuvarlaklık: + Search Brushes Fırçalarda Ara + Show the layer's effects ({key}-click: all layers) Katmanın efektlerini göster ({key}-tıklama: tüm katmanlar) + Brush picker Fırça seçici + Erase Sil + Right click with painting tools Boyama araçlarıyla sağ tıklayın + System Info… Sistem Bilgisi… + System Info Sistem Bilgisi + More panels Daha fazla panel + More documents Daha fazla belge + These settings aren't available in PhotoCraft yet. Bu ayarlar henüz PhotoCraft'ta mevcut değildir. + Graphics Grafik + Reset GPU Backend GPU Arka Uçunu Sıfırla + Applies at next launch. Bir sonraki açılışta geçerlidir. + Click to sample the foreground colour · {key}-click for background Ön plan rengini örneklemek için tıklayın · {key}-arka plan için tıklayın + {key}-click to set the source {key}-kaynağı ayarlamak için tıklayın + {key} to subtract {key} çıkarmak için + Clone source {n} ({key}-click with Clone Stamp to set) Kaynağı kopyala {n} ({key}-ayarlamak için Klonlama Damgası ile tıklayın) + Source: not set ({key}-click with Clone Stamp) Kaynak: ayarlanmadı ({key}-Klon Damgası ile tıklayın) + Pixel Layer Piksel Katmanı + Type Layer Tip Katmanı + Shape Layer Şekil Katmanı + Group Layer Grup Katmanı + Fill Layer Dolgu Katmanı + Smart Object Layer Akıllı Nesne Katmanı + Create Warped Text Çarpık Metin Oluştur + Rasterize Smart Object Akıllı Nesneyi Rasterleştir + Rasterize Fill Content Dolgu İçeriğini Rasterleştir + Rasterize Layer Style Katman Stilini Rasterleştir + Unlock: picking a preset replaces these settings Kilidi aç: bir ön ayarın seçilmesi bu ayarların yerini alır + Lock: keep these settings when picking another preset Kilitle: başka bir ön ayar seçerken bu ayarları koruyun + Diameter: Çap: + Hardness: Sertlik: + No brush presets Fırça ön ayarı yok + Toggle the Brush Settings panel (F5) Fırça Ayarları panelini açın/kapatın (F5) + Create new brush preset from the current settings Geçerli ayarlardan yeni fırça hazır ayarı oluşturun + Brush Settings… (F5): every option of this brush Fırça Ayarları… (F5): bu fırçanın tüm seçenekleri + Rename Brush… Fırçayı Yeniden Adlandır… + Delete Brush Fırçayı Sil + New Brush Preset… Yeni Fırça Ön Ayarı… + Brush Preset Options Fırça Ön Ayarı Seçenekleri + Group name Grup adı + Brush name Fırça adı + Brush Name Fırça Adı + Off: the speed of the pointer sets the spacing Kapalı: işaretçinin hızı aralığı belirler + Tilt Scale Eğim Ölçeği + Brush Projection Fırça Projeksiyonu + Apply the pen's tilt and rotation to the tip shape Kalemin eğimini ve dönüşünü uç şekline uygulayın + Mixer Brush Karıştırıcı Fırça + Mixer Brush Tool Karıştırıcı Fırça Aracı + Color Replacement Tool Renk Değiştirme Aracı + Wet Islaklık + Mix Karışım + Wetness Jitter Islaklık Titreme + Mix Jitter Karıştırma Titreşimi + Show the layer mask as an overlay Katman maskesini kaplama olarak göster + Toggle visibility Görünürlüğü aç/kapat + Unlink the mask from the layer Maskenin katmanla bağlantısını kaldırın + Link the mask to the layer Maskeyi katmana bağlama + Gradient Gradyan + Gradient Options Degrade Seçenekleri + Align with layer Katmanla hizala + Reset Alignment Hizalamayı Sıfırla + Centre the gradient (offset 0, 0) Degradeyi ortalayın (kaydırma 0, 0) + Gradient Editor Degrade Düzenleyici + Gradient picker Degrade seçici + Gradient stops Gradyan durakları + Save As Preset Ön Ayar Olarak Kaydet + The gradient the Gradient tool paints with Degrade aracının boyadığı degrade + Adds the current gradient to the Gradients panel Geçerli degradeyi Degradeler paneline ekler + The current gradient (pick one in Window › Gradients) Geçerli degrade (Pencere › Degradeler'den birini seçin) + Select a type layer to edit its character and paragraph settings. Karakter ve paragraf ayarlarını düzenlemek için bir yazı katmanı seçin. + Text color Metin rengi + Type Options Tip Seçenekleri + Anti-aliasing method Kenar yumuşatma yöntemi + Horizontal type Yatay tip + Vertical type Dikey tip + Fill path with foreground color Yolu ön plan rengiyle doldur + Stroke path with brush Fırçayla kontur yolu + Load path as a selection Yolu seçim olarak yükle + Make work path from selection Seçimden iş yolunu yap + Add vector mask Vektör maskesi ekle + Create new path Yeni yol oluştur + Delete path Yolu sil + Delete vector mask Vektör maskesini sil + CPU (no GPU acceleration) CPU (GPU hızlandırması yok) + DirectX 12 DirectX 12 + GPU backend GPU arka ucu + Linux display server Linux görüntü sunucusu + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Tablet basıncını kullan + Vulkan Vulkan + Alt-click to define a source point to clone from Kopyalanacak kaynak noktayı tanımlamak için Alt tuşuna basarak tıklayın + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Seçim yapılabilecek yol yok: Kalem aracıyla bir yol çizin veya Yollar panelinden birini seçin. + Cancel opening Açılışı iptal et + Cancelled opening {name} {name} açılışı iptal edildi + Developing {name} {name} geliştiriliyor + Cancelled {label} {label} iptal edildi + Esc to cancel İptal etmek için Esc + Opened {name} {name} tarihinde açıldı + Opening {name} {name} açılıyor + Saved {name} {name} kaydedildi + Saving {name} {name} kaydediliyor + A save to {name} is still running; try again when it finishes {name} konumuna kaydetme işlemi hâlâ devam ediyor; bittiğinde tekrar dene + {label} is writing the file and can no longer be cancelled {label} dosyayı yazıyor ve artık iptal edilemez + Opening {name}… {name} açılıyor… + Opening… Açılıyor… + Reading… Okunuyor… + Working… Çalışıyor… + Color Range Renk Aralığı + Adaptive Wide Angle Uyarlanabilir Geniş Açı + Chalk & Charcoal Tebeşir ve Kömür + Charcoal Kömür + Chrome Krom + Conté Crayon Conte Crayon + Graphic Pen Grafik Kalemi + Halftone Pattern Noktalı Resim Deseni + Note Paper Not Kağıdı + Photocopy Fotokopi + Plaster Alçı + Reticulation Retikülasyon + Stamp Damga + Torn Edges Yırtık Kenarlar + Water Paper Su Kağıdı + Move Taşı + Pencil Kalem + Clone Stamp Klon Damgası + Dodge Dodge + Burn Yanık + Quick Selection Hızlı Seçim + History Brush Tarih Fırçası + Color Replacement Renk Değiştirme + New Group Yeni Grup + Layer Comp Katman Kompozisyonu + Alpha Alfa + Make Yap + Model Modeli + F F + Artist Sanatçı + Spot Color Nokta Rengi + Roman Roma + Bold Italic Kalın İtalik + Justification Gerekçe + Symbols Semboller + Punctuation Noktalama işaretleri + Make Work Path İş Yolu Yap + Make Selection Seçim Yap + Fill Path Yolu Doldur + Stroke Path Kontur Yolu + Delete Path Yolu Sil + W W + H H + Ruler Cetvel + New Layer Comp Yeni Katman Kompozisyonu + Duplicate Layer Comp Yinelenen Katman Kompozisyonu + Rename Layer Comp Katman Kompozisyonunu Yeniden Adlandırın + Apply Layer Comp Katman Kompozisyonunu Uygula + Update Layer Comp Katman Kompozisyonunu Güncelle + Method Yöntem + Author Yazar + Software Yazılım + Pencil Tool Kurşun Kalem Aracı + Pixel Piksel + Adjustment Ayarlama + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Noktaları sürükleyin (kement) veya tıklayın (çokgen) · {add} ekle · {sub} çıkar + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Kırpma kutusunu sürükleyin · taşımak için içeriye sürükleyin · kenarları yeniden boyutlandırın ({ratio} oranı, {centre} merkez) · Çizim sırasında boşluk hareket eder · oklar iter · X yönünü değiştirir · {commit} gerçekleştirir · Esc iptal eder + {mode} Color · {bits} bit {mode} Renkli · {bits} bit + Scrubby Zoom Süpürge Yakınlaştırma + Auto Erase Otomatik Silme + Color Adaptation Renk Uyarlaması + Preserve Transparency Şeffaflığı Koruyun + New Style… Yeni Stil… + shortcut kısayol + Saved Kaydedildi + Arc Yay + Arch Kemer + Bulge çıkıntı + Shell Lower Kabuk Alt Kısmı + Shell Upper Kabuk Üstü + Flag Bayrak + Fish Balık + Wave Dalga + Rise Yüksel + Inflate Şişir + Twist Büküm + Squeeze Sıkıştır + Mask Maske + Channel Kanal + Rectangle Dikdörtgen + New Channel Yeni Kanal + Save Selection Seçimi Kaydet + Load Selection Yük Seçimi +@id edit.purge.undo Geri Al + Rendering Mode İşleme Modu + Automatic (recommended) Otomatik (önerilen) + CPU / Compatibility CPU / Uyumluluk + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Otomatik, mümkün olduğunda GPU hızlandırmayı kullanır ve hatalarda CPU oluşturmaya geri döner. + Switched to CPU rendering CPU oluşturmaya geçildi + GPU acceleration could not continue. Your documents are unchanged. GPU hızlandırması devam edemedi. Belgeleriniz değişmedi. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft, CPU görüntü düzenleyicisini kullanıyor. Pencere hala grafik bağdaştırıcınızı kullanıyor olabilir. + Retry GPU requires a restart. Save your work first. GPU'yu yeniden dene, yeniden başlatma gerektirir. Öncelikle çalışmanızı kaydedin. + Keep Using CPU CPU'yu Kullanmaya Devam Edin + Retry GPU GPU'yu yeniden dene + Advanced Gelişmiş + GPU Backend GPU Arka Ucu + Details Ayrıntılar + Rendering mode Oluşturma modu + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Kementi Dondur: Bir alanı dondurmak için sürükleyin, buzunu çözmek için Alt tuşuna basarak sürükleyin (L) + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Camera Raw Filter ({layer}) Camera Raw Filtresi ({layer}) + Camera Raw ({name}) Camera Raw ({name}) + Edit Type Türü Düzenle + Grid: Izgara: + Highlight Clipping Warning (O) Kırpılma Uyarısını Vurgula (O) + Lasso around an area, then drag the selection Bir alanı kementle çevreleyin, ardından seçimi sürükleyin + Patch: Yama: + Shadow Clipping Warning (U) Gölge Kırpılması Uyarısı (U) + Show Lab Color Readouts Laboratuvar Renk Okumalarını Göster + Show Red at 3 o'clock Kırmızıyı saat 3 yönünde göster + Show Selected Region Seçilen Bölgeyi Göster + Show Skin Tone Indicator Cilt Tonu Göstergesini Göster + Show Vectorscope Vectorscope'u göster + Tone Histogram Ton Histogramı + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + No patterns Desen yok + Enable {name} {name}'yi etkinleştir + Hold Alt while drawing for straight segments Düz parçalar çizerken Alt tuşunu basılı tutun + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Çokgen noktalarını sürükleyin veya tıklayın · düz parçalar için kement sırasında Alt tuşunu basılı tutun · {add} ekleyin · çizim çıkarmadan önce {sub} + Extend Uzat + Structure: Yapı: + Content-Aware Move Tool İçeriğe Uygun Taşıma Aracı + TIFF Options TIFF Seçenekleri + Targa Options Targa Seçenekleri + 24 bits/pixel 24 bit/piksel + 32 bits/pixel 32 bit/piksel + Including layers will increase file size. Katmanların eklenmesi dosya boyutunu artıracaktır. + Discard Layers and Save a Copy Katmanları Atın ve Bir Kopyasını Kaydedin + Don't show again Bir daha gösterme + Save failed: {error} Kaydetme başarısız oldu: {error} + Save cancelled Kaydetme iptal edildi + Style {name} saved {name} stili kaydedildi + Make Default Varsayılan Yap + Reset to Default Varsayılana Sıfırla + Couldn't place {name}: {error} {name} yerleştirilemedi: {error} + Full-resolution preview unavailable Tam çözünürlüklü önizleme kullanılamıyor + Magnetic Lasso Tool Manyetik Kement Aracı + Frequency Frekans + Use tablet pressure to change pen width Kalem genişliğini değiştirmek için tablet basıncını kullanın + About Hakkında + Contributors Katkıda Bulunanlar + Models Modeller + Sort Sırala + Username Kullanıcı adı + Display name Görünen ad + Real name Gerçek isim + Reverse the order Sırayı tersine çevir + Grab bag Çantayı al + Table Tablo + Name (A–Z) Ad (A–Z) + Merged PRs Birleştirilmiş PR'ler + Commits Taahhütler + Lines added Satırlar eklendi + Lines deleted Satırlar silindi + Line delta Çizgi deltası + Binary assets added İkili varlıklar eklendi + Binary assets removed İkili varlıklar kaldırıldı + First commit İlk taahhüt + First İlk + Last commit Son taahhüt + Last Son + Contributors: {contributors} · Commits: {commits} Katkıda Bulunanlar: {contributors} · Taahhütler: {commits} + No contributor data was built into this copy. Bu kopyaya hiçbir katkıda bulunan verisi eklenmedi. + No model credits were built into this copy. Bu kopyaya hiçbir model kredisi eklenmemiştir. + Company Şirket + Version Sürüm + % of all commits Tüm taahhütlerin yüzdesi + Lines +/− Çizgiler +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR'ler, {commits} taahhütler, +{added} / −{deleted} satırlar (Δ {delta}), +{binAdded} / −{binDeleted} ikili varlıklar, {first} – {last} + Create new swatch Yeni renk örneği oluştur + New Swatch… Yeni Swatch… + New Swatch Group… Yeni Swatch Grubu… + Import Swatches… Renk Örneklerini İçe Aktar… + Replace Swatches… Renk Örneklerini Değiştirin… + Export Swatches… Renk Örneklerini Dışa Aktar… + Export Swatches for Exchange… Değişim için Renk Örneklerini Dışa Aktar… + Reset Swatches Renk Örneklerini Sıfırla + Add to Swatches Renk Örneklerine Ekle + Blend If: Eğer Karışım: + Channels: Kanallar: + Knockout Nakavt + Apply At Canvas Bounds Kanvas Sınırlarında Uygula + Color Picker (Text Color) Renk Seçici (Metin Rengi) + Color Picker ({name}) Renk Seçici ({name}) + Color Picker (Fill Color) Renk Seçici (Dolgu Rengi) + Fill color Rengi doldur + Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) + Stroke color Kontur rengi + Pick screen color Ekran rengini seç + Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin + Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc + Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın + Scroll to zoom Yakınlaştırmak için kaydırın + Show/Hide All Panels Tüm Panelleri Göster/Gizle + Show/Hide Panels Panelleri Göster/Gizle + Could not open Açılamadı + {path}: the path is not valid Unicode; rename the file and open it again. {path}: yol geçerli bir Unicode değil; dosyayı yeniden adlandırın ve tekrar açın. + Contents: İçerik: + Custom Pattern: Özel Desen: + Foreground Color Ön Plan Rengi + Color… Renk… + 50% Gray %50 Gri + {size} (was {old}) {size} ({old} idi) + Cone Koni + Cone (Inverted) Koni (Ters Çevrilmiş) + Domed Kubbeli + Domed (Inverted) Kubbeli (Ters) + Diagonal (Descending) Çapraz (Azalan) + Contour Editor Kontur Düzenleyici + Anti-aliased Kenar yumuşatma + Corner Köşe + Gloss Contour Parlak Kontur + Contour Kontur + Cone - Inverted Koni - Ters + Cove - Deep Koy - Derin + Cove - Shallow Koy - Sığ + Half Round Yarım Yuvarlak + Ring - Double Yüzük - Çift Kişilik + Rolling Slope - Descending Yuvarlanan Eğim - Azalan + Rounded Steps Yuvarlak Adımlar + Sawtooth 1 Testere dişi 1 + Red Eye Tool Kırmızı Göz Aracı + Red Eye Kırmızı Göz + Pupil Size Öğrenci Boyutu + Darken Amount Koyulaştırma Miktarı + Click a red pupil to neutralize it Nötrleştirmek için kırmızı bir gözbebeğine tıklayın + open source açık kaynak + …and {n} more …ve {n} daha + Eyedroppers: Damlalıklar: + Set Black Point Siyah Noktayı Ayarla + Set Neutral Gray Point Nötr Gri Noktayı Ayarla + Set White Point Beyaz Noktayı Ayarla + Unavailable in this color mode Bu renk modunda kullanılamaz + Selection Preview: Seçim Önizlemesi: + Black Matte Siyah Mat + White Matte Beyaz Mat +layerLabel No Color Renk Yok + Seafoam Deniz köpüğü + Indigo Çivit mavisi + Fuchsia Fuşya + Violet Menekşe + Large document: redrawing on the CPU Büyük belge: CPU'da yeniden çizim + This redraw took {seconds} s on the CPU. Bu yeniden çizim CPU'da {seconds} saniye sürdü. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Bir katmanın karışım modu, opaklığı veya görünürlüğü gibi belgenin tamamını değiştiren düzenlemeler, belgenin tamamını yeniden çizer. + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Katmanları GPU bellek bütçesine uymuyor. Tercihler'de Bellek kullanımını artırma › Performans, bütçeyi bu bilgisayarın belleğinin dörtte birine kadar artırır. + The GPU compositor wasn't used: {reason} GPU dizgicisi kullanılmadı: {reason} +cameraRaw Light Açık Tonlar +fontWeight Thin İnce +fontWeight ExtraLight Ekstra İnce +fontWeight Light Hafif +fontWeight Regular Normal +fontWeight Medium Orta +fontWeight SemiBold Yarı Kalın +fontWeight Bold Kalın +fontWeight ExtraBold Ekstra Kalın +fontWeight Black En Kalın +fontWeight Italic İtalik + Pattern Stamp Tool Desen Damgası Aracı + Pattern Stamp Desen Damgası + Impressionist Empresyonist + Rotate View Görünümü Döndür + Rotate View Tool Görünümü Döndürme Aracı + Reset View Görünümü Sıfırla + Drag around the centre to rotate the view · Shift constrains to 15° Görünümü döndürmek için merkezin etrafında sürükleyin · Kaydırma 15° ile sınırlandırılır +@id select.all Tümünü Seç +@id edit.stroke Kontur… +@id edit.transform.scale Ölçekle +cameraRaw Red Kırmızı +cameraRaw Orange Turuncu +cameraRaw Yellow Sarı +cameraRaw Green Yeşil +cameraRaw Aqua Camgöbeği +cameraRaw Blue Mavi +cameraRaw Purple Mor +cameraRaw Magenta Macenta +cameraRaw Blending Karışım +cameraRaw Luminance Parlaklık +cameraRaw Temperature Sıcaklık +cameraRaw Color Renk + Patch Tool Yama Aracı + Preferences… Tercihler… + Settings Ayarlar + Duplicate Çoğalt + Actual Pixels Gerçek Pikseller + Hide Gizle + New Layer… Yeni Katman… + Clipping Mask Kırpma Maskesi + Tool Options Araç Seçenekleri + Color Picker Renk Seçici + Color Swatches Renk Örnekleri + Linear Dodge Doğrusal Soldurma + Preview Image Resmin Önizlemesi + Automatic Otomatik + Save As Farklı Kaydet + Import… İçe aktar… + Remove All Tümünü Kaldır + Unlock Kilidi aç + Enable Etkinleştir + Disable Devre dışı bırak + On Açık + Selected Seçilen + Clear Selection Seçimi Temizle + Invert Selection Seçimi Ters Çevir + Select Inverse Tersini Seç + Contract Sözleşme + Sample Size Örnek Boyutu + Strength Güç + Gamma Gama + Input Giriş + Output Çıkış + Input Levels Giriş Seviyeleri + Output Levels Çıkış Seviyeleri + Layer Styles Katman Stilleri + Blending Mode Karıştırma Modu + Fill Color Dolgu Rengi + Stroke Color Kontur Rengi + Stroke Width Kontur Genişliği + Shadow Gölge + Adjustment Layer Ayarlama Katmanı + Adjustment Layers Ayarlama Katmanları + Text Layer Metin Katmanı + Documents Belgeler + File Name Dosya Adı + File Format Dosya Formatı + Compression Sıkıştırma + Artboards Çalışma yüzeyleri + Snap to Grid Izgaraya Yasla + Full Screen Tam Ekran + Fullscreen Tam ekran + Workspaces Çalışma alanları + Alignment Hizalama + Step Backward Geri Adım + Step Forward İleri Adım + Show All Tümünü Göster + Hidden Gizli + New Layer Yeni Katman + Duplicate Layer Katmanı Çoğalt + Ungroup Grubu çöz + Lock Transparent Pixels Şeffaf Pikselleri Kilitle + Lock Image Pixels Görüntü Piksellerini Kilitle + Lock Position Kilit Konumu + Lock All Tümünü Kilitle + Show Rulers Cetvelleri Göster + Show Grid Izgarayı Göster + Guide Layout… Kılavuz Düzeni… + Page Setup… Sayfa Yapısı… + Reset Preferences Tercihleri Sıfırla + Documentation Dokümantasyon + Check for Updates Güncellemeleri Kontrol Et + Loading… Yükleniyor… + Processing… İşleniyor… + Saving… Kaydediliyor… + Exporting… Dışa aktarılıyor… + Please wait… Lütfen bekleyin… + No document open Hiçbir belge açık değil + No selection Seçim yok + No layers Katman yok + No recent files Yeni dosya yok + Unsupported file format Desteklenmeyen dosya biçimi + Failed to open file Dosya açılamadı + Failed to save file Dosya kaydedilemedi + Operation canceled İşlem iptal edildi + Changes have not been saved. Değişiklikler kaydedilmedi. + Are you sure? Emin misin? + Discard Changes Değişiklikleri İptal Et + Recent Files Son Dosyalar + Open File Dosyayı Aç + Save File Dosyayı Kaydet + Save Changes Değişiklikleri Kaydet + Rendering… Oluşturuluyor… + Importing… İçe aktarılıyor… + Ready Hazır + Failed Başarısız + Warning Uyarı + Information Bilgi + Errors Hatalar + Retry Yeniden dene + Continue Devam et + Skip Atla + Stop Durdur + Resume Devam et + Reset All Tümünü Sıfırla + Defaults Varsayılanlar + Apply to All Tümüne Uygula + Confirm Onayla + Finish Bitiş + Back Geri + Next Sonraki + Previous Önceki + More Daha Fazla + Less Daha az + Manual Manuel + Disabled Devre dışı + Locked Kilitli + Unlocked Kilitli değil + Delete… Sil… + Create… Oluştur… + Edit… Düzenle… + Change… Değiştir… + Transformation Dönüşüm + Scaling Ölçeklendirme + Canvas Size Kanvas Boyutu + Percentage Yüzde + Degrees Dereceler + Middle Orta + Location Konum + Location: Konum: + Diameter Çap + Intensity Yoğunluk + Sensitivity Hassasiyet + Softness Yumuşaklık + Feather Radius Tüy Yarıçapı + Color Profile Renk Profili + Library Kütüphane + Libraries Kütüphaneler + Layer Properties Katman Özellikleri + Groups Gruplar + Masks Maskeler + Add Mask Maske Ekle + Delete Mask Maskeyi Sil + Invert Mask Maskeyi Ters Çevir + Enable Mask Maskeyi Etkinleştir + Disable Mask Maskeyi Devre Dışı Bırak + Apply Mask Maske Uygula + Import Settings Ayarları İçe Aktarma + Export Settings Ayarları Dışa Aktarma + Shortcut Kısayol + Shortcuts Kısayollar + Keyboard Klavye + Mouse Fare + Tablet tablet + Display Ekran + Panel Paneli + Hide Grid Izgarayı Gizle + Fit to Window Pencereye Sığdır + Dark Theme Koyu Tema + Light Theme Işık Teması + Live Preview Canlı Önizleme + Before önce + After Sonra + Compare Karşılaştır + File Size Dosya Boyutu + Filename Dosya adı + Folder Klasör + Open Folder Klasörü Aç + Choose Folder Klasör Seçin + Browse Göz at + Find Bul + Replace Değiştir + Category Kategori + Date Tarih + Time Zaman + Learn More Daha Fazla Bilgi Edinin + Report a Bug Hata Bildir + Updates Güncellemeler + Update Güncelleme + Restart Required Yeniden Başlatma Gerekli + Restart Yeniden başlat + Quit Çıkış + Quit PhotoCraft PhotoCraft'tan Çık + Close Window Pencereyi Kapat + New Window Yeni Pencere + Open Image Resmi Aç + Save Image Resmi Kaydet + Export Image Görüntüyü Dışa Aktar + Import Image Görüntüyü İçe Aktar + Export As Farklı Dışa Aktar + Export to Şuraya aktar: + Export Options Dışa Aktarma Seçenekleri + Import Options İçe Aktarma Seçenekleri + No Selection Seçim Yok + No Document Belge Yok + No Image Resim Yok + No Layers Katman Yok + No Results Sonuç Yok + No Results Found Sonuç Bulunamadı + Nothing to Undo Geri Alınacak Bir Şey Yok + Nothing to Redo Tekrarlanacak bir şey yok + Changes Saved Değişiklikler Kaydedildi + Unsaved Changes Kaydedilmemiş Değişiklikler + Save before closing? Kapatmadan önce kaydet? + Are you sure you want to quit? Vazgeçmek istediğinden emin misin? + The file could not be opened. Dosya açılamadı. + The file could not be saved. Dosya kaydedilemedi. + Unsupported format Desteklenmeyen biçim + Out of memory Bellek yetersiz + Permission denied İzin reddedildi + File not found Dosya bulunamadı + Operation failed İşlem başarısız oldu + Operation completed İşlem tamamlandı + Operation cancelled İşlem iptal edildi + Camera Calibration Kamera Kalibrasyonu + Snapshots Anlık görüntüler + Recovery Kurtarma + Fill Light Işık Doldur + Convert to Grayscale Gri Tonlamaya Dönüştür + Sharpness Keskinlik + Color Noise Reduction Renk Gürültü Azaltma + Luminance Smoothness Parlaklık Pürüzsüzlüğü + Focal Length35mm Odak Uzunluğu35mm + Aspect Ratio En Boy Oranı + Enter description… Açıklamayı girin… + Industry Standard Endüstri Standardı + Uncoated CMYK Kaplamasız CMYK + Working Spaces Çalışma Alanları + Color Management Policies Renk Yönetimi Politikaları + Detect Faces Yüzleri Algıla + Localize Color Clusters Renk Kümelerini Yerelleştirin + Duplicated selection name Yinelenen seçim adı + New channel only Yalnızca yeni kanal + Masked Areas Maskelenmiş Alanlar + Selected Areas Seçilen Alanlar + Color indicates Renk şunu gösterir: + Create Snapshot Anlık Görüntü Oluştur + Delete Snapshot Anlık Görüntüyü Sil + New Snapshot Yeni Anlık Görüntü + Snapshot 1 Anlık Görüntü 1 + Nonlinear History Doğrusal Olmayan Geçmiş + Set New Snapshot as Default Yeni Anlık Görüntüyü Varsayılan Olarak Ayarla + Automatically Create First Snapshot İlk Anlık Görüntüyü Otomatik Olarak Oluştur + Automatically Create New Snapshot When Saving Kaydederken Otomatik Olarak Yeni Anlık Görüntü Oluştur + Show New Snapshot Dialog by Default Yeni Anlık Görüntü İletişim Kutusunu Varsayılan Olarak Göster + Clear History Geçmişi Temizle + Panel Options… Panel Seçenekleri… + View menu Menüyü görüntüle + Keep view in sync Görünümü senkronize tutun + Mouse Controls Fare Kontrolleri + Initial Direction of Stroke Başlangıç Strok Yönü + Direction of Stroke İnme Yönü + Humidity Nem + Auto Clean Brush Otomatik Temizleme Fırçası + Brush Tip Fırça Ucu + New Layer from Brush Brush'tan Yeni Katman + New Brush Yeni Fırça + New Tool Preset Yeni Araç Ön Ayarı + Auto-Leading Otomatik Satır Aralığı + Scale Horizontally Yatay Olarak Ölçeklendir + Scale Vertically Dikey Ölçeklendir + Baseline Shift Taban Çizgisi Kayması + Superscript Üst simge + Subscript Abonelik + Ligature Ligatür + Alternative Alternatif + Extra Light Ekstra Işık + Extra Bold Ekstra Kalın + Sharpen Amount Keskinleştirme Tutarı + Sharpen Radius Yarıçapı Keskinleştir + Sharpen Detail Detayı Keskinleştir + Sharpen Masking Maskelemeyi Keskinleştir + Noise Luminance Gürültü Parlaklığı + Noise Luminance Detail Gürültü Parlaklığı Ayrıntısı + Noise Color Gürültü Rengi + Noise Color Detail Gürültü Rengi Ayrıntısı + Grain Amount Tane Miktarı + Grain Size Tane Boyutu + Grain Roughness Tahıl Pürüzlülüğü + Vignette Roundness Vinyet Yuvarlaklığı + Vignette Feather Vinyet Tüyü + Vignette Highlights Vinyet Öne Çıkanlar + Vignette Style Vinyet Stili + Planes Uçaklar + Corners Köşeler + Plane Uçak + Shadow Amount Gölge Miktarı + Shadow Tone Gölge Tonu + Shadow Radius Gölge Yarıçapı + Highlight Amount Tutarı Vurgula + Highlight Tone Vurgu Tonu + Highlight Radius Yarıçapı Vurgula + Midtone Orta ton + Black Clip Siyah Klip + White Clip Beyaz Klip + Fuzziness bulanıklık + Source Layer Kaynak Katmanı + Neutralize Nötrleştir + Use Selection In Source Kaynaktaki Seçimi Kullan + Use Selection In Target Hedefteki Seçimi Kullan + Flame Type Alev Tipi + Randomize Length Uzunluğu Rastgeleleştir + Interval Aralık + Adjust Interval For Loops Döngüler İçin Aralığı Ayarla + Use Custom Color Özel Renk Kullan + Flame Lines Alev Hatları + Flame Bottom Alignment Alev Alt Hizalaması + Flame Style Alev Stili + Flame Shape Alev Şekli + Randomize Shapes Şekilleri Rastgeleleştir + Frame Çerçeve + Margin Marj + Arrangement Düzenleme + Thickness Kalınlık + Vine Color Asma Rengi + Flower Color Çiçek Rengi + Leaf Color Yaprak Rengi + Base Tree Type Temel Ağaç Türü + Light Direction Işık Yönü + Leaves Amount Yaprak Tutarı + Leaves Size Yaprak Boyutu + Branches Height Dal Yüksekliği + Branches Thickness Dal Kalınlığı + Default Leaves Varsayılan İzinler + Leaves Color Yaprak Rengi + Custom Branch Color Özel Şube Rengi + Branches Color Dal Rengi + Flat Shading Düz Gölgeleme + Enable Timeline Quick Config Zaman Çizelgesi Hızlı Yapılandırmasını Etkinleştir + Set Start Timecode… Başlangıç Zaman Kodunu Ayarla… + Timecode Zaman kodu + Current Time Güncel Saat + Duration Süre + Repeat Tekrarla + Play Once Bir Kez Oyna + Info Options… Bilgi Seçenekleri… + First Color Readout İlk Renk Okuması + Second Color Readout İkinci Renk Okuması + Actual Color Gerçek Renk + Grayscale Slider Gri Tonlama Kaydırıcısı + Web Color Web Rengi + Total Ink Toplam Mürekkep + Opacity Mask Opaklık Maskesi + Document Size Belge Boyutu + Timing Zamanlama + Status Information Durum Bilgileri + Mouse Coordinates Fare Koordinatları + Ruler Units Cetvel Birimleri + Restore last viewed comp Son görüntülenen kompozisyonu geri yükle + Select All Layers Tüm Katmanları Seç + Keep proportions Oranları koruyun + Interpolation Enterpolasyon + Caption Başlık + Caption Credits Altyazı Kredisi + Keywords Anahtar Kelimeler + Copyright Info URL Telif Hakkı Bilgisi URL'si + Copyright Status Telif Hakkı Durumu + Copyrighted Telif hakkıyla korunuyor + Public Domain Kamu Alanı + Unknown Bilinmiyor + Credit Kredi + Transmission Reference İletim Referansı + Urgency Aciliyet + High Yüksek + Low Düşük + Supplemental Categories Tamamlayıcı Kategoriler + Object Name / Title Nesne Adı / Başlığı + Date Created Oluşturulma Tarihi + City Şehir + Province / State İl / Eyalet + Country Ülke + Byline Yazar yazısı + Byline Title Yazar Başlığı + Phone Telefon + Email E-posta + Last Modified Son Değiştirilme + Camera Data Kamera Verileri + Camera Model Kamera Modeli + Camera Make Kamera Markası + EXIF Version EXIF Sürümü + Exposure Program Maruziyet Programı + Aperture Diyafram + Shutter Speed Enstantane Hızı + Metering Mode Ölçüm Modu + Light Source Işık Kaynağı + Flash Flaş + Create Multiple Picture Packages Çoklu Resim Paketleri Oluşturun + Choose… Seç… + Select Folder… Klasör Seç… + Override Action "Save" Commands Eylem "Kaydet" Komutlarını Geçersiz Kıl + File Naming Dosya Adlandırma + Compatibility Uyumluluk + Include ICC Profile ICC Profilini Ekle + Error File Hata Dosyası + Stop For Errors Hatalar İçin Durdur + Log Errors To File Hataları Dosyaya Günlüğe Kaydet + Settings: Ayarlar: + Working Space Çalışma Alanı + Advanced Controls Gelişmiş Kontroller + Desaturate Monitor Colors By Monitör Renklerinin Doygunluğunu Azaltma + Blend RGB Colors Using Gamma 1.0 Gama 1.0 Kullanarak RGB Renklerini Karıştırın + Profile Mismatches: Ask When Opening Profil Uyuşmazlıkları: Açarken Sor + Ask When Pasting Yapıştırırken Sor + Missing Profiles: Ask When Opening Eksik Profiller: Açarken Sor + Embed profiles Profilleri yerleştir + Coerce to sRGB sRGB'ye baskı yapın + Graphic and web Grafik ve web + Minimalistic Minimalist + Expand All Groups Tüm Grupları Genişlet + Recent Items Son Öğeler + Delete Preset… Ön Ayarı Sil… + Preset Name: Ön Ayar Adı: + Width and Height Genişlik ve Yükseklik + Units Birimler + Columns Sütunlar + Create new count group Yeni sayım grubu oluştur + Drag to move the layer Katmanı taşımak için sürükleyin + Go to Layer Katmana Git + Current Mevcut + Reference point location Referans noktası konumu + Toggle reference point Referans noktasını değiştir + Heal Off İyileşme + Heal On İyileşme + Heal Auto Otomatik İyileştirme + Brush Size Fırça Boyutu + Heal Color Rengi İyileştir + Result Sonuç + Fast Hızlı + Accurate Doğru + Add to Ekle + Subtract from Şundan çıkar: + Intersect with İle kesiştir + Capture Comp Comp'ı Yakala + No Layer Comps Katman Kompozisyonu Yok + Add to selection (⇧) Seçime ekle (⇧) + Click to zoom in · ⌥-click to zoom out Yakınlaştırmak için tıklayın · Uzaklaştırmak için ⌥-tıklayın + Commit Perspective Warp (↩) Perspektif Çarpıtma İşlemi (↩) + Commit Puppet Warp (↩) Kukla Çarpıtma İşlemi (↩) + Commit any current edits (⌘↩) Mevcut düzenlemeleri taahhüt et (⌘↩) + Commit current crop operation (↵) Mevcut kırpma işlemini gerçekleştir (↵) + Commit transform (↩) Dönüşümü kaydet (↩) + Commit warp (↩) Çözgü gerçekleştir (↩) + Create a new layer (⇧⌘N) Yeni bir katman oluştur (⇧⌘N) + Custom pattern Özel desen + General Brushes Genel Fırçalar + New document… ⌘N Yeni belge… ⌘N + Open… ⌘O Aç… ⌘O + Search commands (⌘K) Arama komutları (⌘K) + Uses the foreground and background colors. Ön plan ve arka plan renklerini kullanır. + Collapse group (⌥-click: all groups) Grubu daralt (⌥ tuşuna basarak tıklayın: tüm gruplar) + Expand group (⌥-click: all groups) Grubu genişlet (⌥ tuşuna basarak tıklayın: tüm gruplar) + Date and Time Tarih ve Saat + Scale Units Ölçek Birimleri + Scale Factor Ölçek Faktörü + Area Alan + Perimeter Çevre + Circularity Dairesellik + Gray Value (Minimum) Gri Değeri (Minimum) + Gray Value (Maximum) Gri Değeri (Maksimum) + Gray Value (Mean) Gri Değeri (Ortalama) + Gray Value (Median) Gri Değeri (Medyan) + Integrated Density Entegre Yoğunluk + Length Uzunluk + In Black Siyah + In White Beyaz + Out Black Siyah + Out White Beyaz Dışı + Tint Color Ton Rengi + Stops Duraklar + Center X Merkez X + Center Y Merkez Y + Distribution Dağıtım + Monochromatic Tek renkli + Cell Size Hücre Boyutu + Generators Jeneratörler + Wavelength Min Dalga Boyu Min + Wavelength Max Maksimum Dalga Boyu + Amplitude Min Genlik Min + Amplitude Max Genlik Maksimum + Undefined Areas Tanımsız Alanlar + Preserve Koru + Max Radius Maksimum Yarıçap + Depth Mode Derinlik Modu + Solid Front Faces Katı Ön Yüzler + Mask Incomplete Blocks Tamamlanmamış Blokları Maskele + Stylization Stilizasyon + Cleanliness Temizlik + Bristle Detail Kıl Detayı + Lighting Aydınlatma + Shine Parıltı + Max Offset Maksimum Ofset + Map Document Harita Belgesi + Map Path Harita Yolu + Map Layer Harita Katmanı + Ridges Sırtlar + Light Type Işık Tipi + Light X Işık X + Light Y Işık Y + Light Z Açık Z + Target X Hedef X + Target Y Hedef Y + Hotspot Sıcak nokta + Elevation Yükseklik + Ambient Ortam + Warmth sıcaklık + Gloss parlaklık + Metallic metalik + Ambience ortam + White Is High Beyaz Yüksek + Reduce Color Noise Renk Gürültüsünü Azaltın + Sharpen Details Ayrıntıları Keskinleştir + Remove Jpeg Artifact Jpeg Yapısını Kaldır + Remove Tool Aracı Kaldır + Blade Curvature Bıçak Eğrisi + Depth Map Derinlik Haritası + Focal Distance Odak Mesafesi + Focus Odaklanma + Transition Geçiş + Radius X Yarıçap X + Radius Y Y Yarıçapı + Pins Pimler + Blur Angle Bulanıklık Açısı + Speed Hız + Taper Konik + Start X X'i başlat + Start Y Y'yi başlat + End X X'in sonu + End Y Y Sonu + Kernel çekirdek + Input Mode Giriş Modu + Row Order Satır Sırası + Eliminate Ortadan kaldır + Create By Oluşturan + List Liste + Pencil Width Kalem Genişliği + Stroke Pressure Strok Basıncı + Paper Brightness Kağıt Parlaklığı + Number Of Levels Seviye Sayısı + Edge Simplicity Kenar Sadeliği + Edge Fidelity Kenar Doğruluğu + Brush Detail Fırça Detayı + Highlight Area Alanı Vurgula + Glow Size Parıltı Boyutu + Glow Brightness Parıltı Parlaklığı + Brush Type Fırça Tipi + Stroke Size Kontur Boyutu + Stroke Detail Kontur Detayı + Highlight Strength Gücü Vurgula + Smoothness Pürüzsüzlük + Edge Thickness Kenar Kalınlığı + Edge Intensity Kenar Yoğunluğu + Posterization Posterleştirme + Stroke Length Strok Uzunluğu + Relief Rahatlama + Definition Tanım + Texture Coverage Doku Kapsamı + Shadow Intensity Gölge Yoğunluğu + Edge Width Kenar Genişliği + Edge Brightness Kenar Parlaklığı + Direction Balance Yön Dengesi + Black Intensity Siyah Yoğunluğu + White Intensity Beyaz Yoğunluğu + Dark Intensity Karanlık Yoğunluk + Custom Pixel Aspect Ratio Özel Piksel En Boy Oranı + 32-bit Preview Options 32 bit Önizleme Seçenekleri + New Guide Yeni Kılavuz + Join the ArtCraft Discord ArtCraft Discord'una katılın + Report an Issue Sorun Bildir + Load Files into Stack Dosyaları Yığına Yükle + Statistics İstatistikler + Duplicate Layers Katmanları Çoğalt + Edit Toolbar Araç Çubuğunu Düzenle + Import Brushes Fırçaları İçe Aktar + Load 3D LUT 3D LUT'u yükleyin + New document Yeni belge + Press keys Tuşlara basın + Rename Group Grubu Yeniden Adlandır + Rename Tool Preset Araç Ön Ayarını Yeniden Adlandır + Search commands, tools and panels Komutları, araçları ve panelleri arayın + Slice Options Dilim Seçenekleri + Rename Brush Fırçayı Yeniden Adlandır + Opening Açılış + Reading Okuma + Working Çalışma + Accented Edges Vurgulu Kenarlar + Angled Strokes Açılı Vuruşlar + Anisotropic Anizotropik + Around Center Merkez Çevresi + Artistic Sanatsal + Bamboo Bambu + Bas Relief Bas Rölyef + Berries Meyveler + Blast Patlama + Bleach Bypass Ağartma Baypası + Blocks Bloklar + Brick Tuğla + Brush Strokes Fırça Darbeleri + Burlap Çuval bezi + Candle Light Mum Işığı + Circle daire + Clumped Topaklanmış + Coarse Dots Kaba Noktalar + Colored Pencil Renkli Kalem + Contrasty Kontrastlı + Cool Harika + Craquelure Küçük çatlak + Cross Çapraz + Cross Process Çapraz Süreç + Crosshatch Çapraz tarama + Cutout Kesim + Daisies Papatyalar + Dark Rough Karanlık Sert + Dark Strokes Karanlık Vuruşlar + Darken Only Yalnızca Koyulaştır + Day For Night Gündüz Gece + Deep Blue Derin Mavi + Deep Emerald Derin Zümrüt + Deep Red Koyu Kırmızı + Deep Yellow Koyu Sarı + Dot Nokta + Double Line Çift Hat + Draft Taslak + Dry Brush Kuru Fırça + Duplication Çoğaltma + Edge Extension Kenar Uzantısı + Edge Only Yalnızca Kenar + Enlarged Büyütülmüş + Even Fields Çift Alanlar + Faded Film Solmuş Film + Film Grain Film Tahıl + Fine iyi + Fine Dots İnce Noktalar + Flat Düz + Fresco Fresk + From Left Soldan + From Right Sağdan + Frosted Buzlu + Generic jenerik + Glass Cam + Glowing Edges Parlayan Kenarlar + Grainy Dots Grenli Noktalar + Hearts Kalpler + Heptagon Yedigen + Hexagon Altıgen + Horizontal Only Yalnızca Yatay + Infinite Sonsuz + Ink Outlines Mürekkep Ana Hatları + Ivy sarmaşık + Left Diagonal Sol Çapraz + Level Based Seviyeye Dayalı + Light Rough Hafif Kaba + Lighten Only Yalnızca Açıklaştır + Long Lines Uzun Çizgiler + Long Strokes Uzun Vuruşlar + Lower Daha düşük + Medium Dots Orta Noktalar + Medium Lines Orta Çizgiler + Medium Strokes Orta Vuruşlar + Mono Contrast Mono Kontrast + Mosaic Tiles Mozaik Fayans + Multiple Flames Along Path Yol Boyunca Çoklu Alevler + Multiple Flames One Direction Çoklu Alevler Tek Yön + Multiple Flames Path Directions Çoklu Alev Yolu Yönleri + Multiple Flames Various Length Çoklu Alevler Çeşitli Uzunluklar + Octagon sekizgen + Odd Fields Tek Alanlar + One Flame Along Path Yol Boyunca Bir Alev + Out From Center Merkezden Dışarı + Oval oval + Overlay Edge Kaplama Kenarı + Palette Knife Palet Bıçağı + Parallel paralel + Patchwork yama işi + Pentagon Pentagon + Point Nokta + Pointed Sivri + Pond Ripples Gölet Dalgaları + Pyramids Piramitler + Random Rastgele + Right Diagonal Sağ Diyagonal + Ring Yüzük + Rope Halat + Roses Güller + Sandstone Kumtaşı + Scallops tarak + Sepia Sepya + Short Lines Kısa Çizgiler + Short Strokes Kısa Vuruşlar + Simple Basit + Light Intensity Işık Yoğunluğu + Spray Radius Püskürtme Yarıçapı + Stroke Direction Strok Yönü + Graininess Taneciklilik + Glow Amount Işıma Miktarı + Clear Amount Tutarı Temizle + Distortion Bozulma + Ripple Size Dalgalanma Boyutu + Ripple Magnitude Dalgalanma Büyüklüğü + Charcoal Area Kömür Alanı + Chalk Area Tebeşir Alanı + Charcoal Thickness Kömür Kalınlığı + Light Dark Balance Açık Karanlık Dengesi + Foreground Level Ön Plan Seviyesi + Background Level Arka Plan Düzeyi + Pattern Type Desen Türü + Image Balance Görüntü Dengesi + Darkness Karanlık + Fiber Length Elyaf Uzunluğu + Crack Spacing Çatlak Aralığı + Crack Depth Çatlak Derinliği + Crack Brightness Çatlak Parlaklığı + Grain Type Tahıl Türü + Tile Size Döşeme Boyutu + Grout Width Derz Genişliği + Lighten Grout Harcı hafifletmek + Square Size Kare Boyutu + Border Thickness Kenar Kalınlığı + Correct Distortion Bozulmanın Düzeltilmesi + Correct Vignette Doğru Vinyet + Correct C A Doğru CA + Auto Scale Otomatik Ölçeklendirme + Red Cyan Kırmızı Camgöbeği + Blue Yellow Mavi Sarı + Vignette Amount Vinyet Tutarı + Vignette Midpoint Vinyet Orta Noktası + Constraints Kısıtlamalar + Curve Highlights Eğrinin Öne Çıkan Noktaları + Curve Lights Eğri Işıkları + Curve Darks Eğri Koyuları + Curve Shadows Eğri Gölgeleri + Curve Splits Eğri Bölmeleri + Point Curve Nokta Eğrisi + Point Curve Red Nokta Eğrisi Kırmızı + Point Curve Green Nokta Eğrisi Yeşil + Point Curve Blue Nokta Eğrisi Mavi + Hsl Hue Hsl Ton + Hsl Sat HSL Cumartesi + Hsl Lum Hsl Lum + Grade Shadows Sınıf Gölgeleri + Grade Midtones Derece Orta Tonları + Grade Highlights Notun Öne Çıkan Noktaları + Grade Global Küresel Sınıf + Grade Blending Sınıf Harmanlama + Grade Balance Not Dengesi + Sine sinüs + Sketch Eskiz + Snowflakes Kar taneleri + Sparkle ışıltı + Spatter Sıçrama + Speckle Benek + Spin Döndürme + Sprinkles serpintiler + Squareness Karelik + Stagger Sendeleme + Stained Glass Vitray + Star Yıldız + Stars Yıldızlar + Stippled noktalanmış + Stretch Streç + Teal Orange turkuaz turuncu + Tiny Lens Minik Mercek + To Center Merkeze + Triangle Üçgen + Unaltered değiştirilmemiş + Underpainting Alt boyama + Underwater Sualtı + Uniform Üniforma + Upper Üst + Vertical Only Yalnızca Dikey + Vine With Flowers Çiçekli Asma + Vine With Leaves Yapraklı Asma + Violent Şiddet içeren + Warm Sıcak + Watercolor Suluboya + Waves Dalgalar + Wide Blurry Geniş Bulanık + Wide Sharp Geniş Keskin + Wrap Sarma + Zigzag Zikzak + Wetness ıslaklık + Delete Smart Filters Akıllı Filtreleri Sil + Enable Smart Filters Akıllı Filtreleri Etkinleştir + Delete Layer… Katmanı Sil… + Select Similar Colors Benzer Renkleri Seçin + New Selection Yeni Seçim + Click the first point or press ↵ to close · Esc cancels Kapatmak için ilk noktayı tıklayın veya ↵ tuşuna basın · Esc iptal eder + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Nokta eklemek için tıklayın · sürükleyin veya kaldırmak için ⌘/Ctrl tuşuna basarak tıklayın veya Sil + Click to apply, ⌥-click to clear overrides, double-click for Style Options Uygulamak için tıklayın, geçersiz kılmaları temizlemek için ⌥-tıklayın, Stil Seçenekleri için çift tıklayın + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Tıklayın: köşe · Sürükleyin: pürüzsüz · İlk noktaya tıklayın: kapat · ↩ bitir · Esc iptal + Rulers (⌘R) Cetveller (⌘R) + Show Guides (⌘;) Kılavuzları Göster (⌘;) + Grid (⌘') Izgara (⌘') + PDF Presentation… PDF Sunumu… + Picture Package… Resim Paketi… + Web Photo Gallery… Web Fotoğraf Galerisi… + Gray: Gri: + Spot: Nokta: + Technology Previews… Teknoloji Önizlemeleri… + 3D… 3 boyutlu… + AI Integrations Yapay Zeka Entegrasyonları + Add Noise Gürültü Ekle + Add Pins Pin Ekle + Add a mask (from the selection; ⌥ inverts) Bir maske ekleyin (seçimden; ⌥ tersine çevirir) + Adds grain to the soft edges of the brush tip. Fırça ucunun yumuşak kenarlarına gren ekler. + Adobe PDF Presets… Adobe PDF Hazır Ayarları… + Advanced Blending Gelişmiş Harmanlama + After (X) (X)'ten sonra + Airbrush: keeps painting while the pointer rests. Airbrush: İşaretçi dururken boyamaya devam eder. + Alternates for Current Selection Mevcut Seçimin Alternatifleri + Apply Data Set Veri Kümesini Uygula + Apply Image Resmi Uygula + Arbitrary keyfi + Artboard from Group Gruptan çalışma yüzeyi + Artboard from Layers Katmanlardan Çalışma Yüzeyi + Artboards to Files Çalışma Yüzeylerinden Dosyalara + Artboards to PDF Çalışma yüzeylerini PDF'ye dönüştürme + Ask Before Saving Saved Embedded Kaydedilen Gömülü Kaydetmeden Önce Sor + Assign Profile Profil Ata + At Şu tarihte: + Audio Ses + Auto-Align Layers Katmanları Otomatik Hizala + Auto-Blend Layers Katmanları Otomatik Karıştır + Auto-Hide Otomatik Gizle + Auto-leading % Otomatik satır aralığı % + Avoid Widows/Orphans Dul/Yetimlerden Kaçının + Background from Layer Katmandan Arka Plan + Batch Toplu + Blend Karışım + Border Sınır + Box Blur Kutu Bulanıklığı + Calculations Hesaplamalar + Camera Raw Camera Raw + Camera Raw Filter Camera Raw Filtresi + Canvas rotation Kanvas döndürme + Channel1 Kanal1 + Channel2 Kanal2 + Channel3 Kanal3 + Channel4 Kanal4 + Clipping Path Kırpma Yolu + Clone Klon + Color Decontaminate Renkli Dekontaminasyon + Color Halftone Renk Noktalı Resim + Color Lookup Tables Renk Arama Tabloları + Color Settings Renk Ayarları + Color Table Renk Tablosu + Compose Oluştur + Condensed Yoğunlaştırılmış + Conditional Mode Change Koşullu Mod Değişikliği + Content-Aware Fill İçeriğe Uygun Dolgu + Convert Frames > Video Layers Çerçeveleri Dönüştür > Video Katmanları + Convert to Linked Bağlantılıya Dönüştür + Convert to Profile Profile Dönüştür + Create Date Tarih Oluştur + Create Plane Düzlem Oluştur + Create new layer Yeni katman oluştur + Create new set Yeni grup oluştur + Crystallize Kristalleş + Data Veri + Data Sets as Files Dosya Olarak Veri Kümeleri + DateTime Original TarihSaat Orijinal + De-Interlace Taramasızlaştırma + Defaults restored. Varsayılanlar geri yüklendi. + Define Brush Preset Fırça Ön Ayarını Tanımla + Define Custom Shape Özel Şekli Tanımla + Define Pattern Deseni Tanımla + Defringe Bordürleri kaldır + Delete Pins Pinleri Sil + Diffuse Yaygın + Diffuse Glow Yaygın Parıltı + Digits Rakamlar + Displace Yer değiştir + Do you want to save you made to “{name}” before quitting? Çıkmadan önce yaptığınız işi “{name}” konumuna kaydetmek istiyor musunuz? + Dodge/Burn Soldurma/Yakma + Duplicate Path Yinelenen Yol + Dust & Scratches Toz ve Çizikler + Dynamics Dinamik + Edit Plane Düzlemi Düzenle + Export Contents İçeriği Dışa Aktar + Export Preferences Dışa Aktarma Tercihleri + Export/Import Presets Ön Ayarları Dışa Aktarma/İçe Aktarma + Extensions Uzantılar + Extrude Ekstrüzyon + Fibers Lifler + Field Blur Alan Bulanıklığı + File Info Dosya Bilgileri + Filter Gallery Filtre Galerisi + Find and Replace Text Metni Bul ve Değiştir + First Line Indent İlk Satır Girintisi + Fit Image Resmi Sığdır + Flame Alev + Flatten Frames Into Clips Çerçeveleri Klipsler halinde Düzleştirin + Flatten Frames Into Layers Çerçeveleri Katmanlara Düzleştirin + Focus Area Odak Alanı + Follows only edges this close to the pointer ([ and ] change it) Yalnızca işaretçiye bu kadar yakın olan kenarları takip eder ([ ve ] bunu değiştirir) + Frame Duration Kare Süresi + Frame Sync Çerçeve Senkronizasyonu + Gaussian Gaussian + Gaussian Blur Gauss Bulanıklığı + General Blend Genel Karışım + Global Light Küresel Işık + Glyph Glif + Glyph Scaling Glif Ölçekleme + Group from Layers Katmanlardan Gruplandır + Group · {n} layer Grup · {n} katman + HDR Toning HDR Tonlama + HSB Color HSB Rengi + HSL/Grayscale HSL/Gri tonlamalı + Hanging Punctuation Asılı Noktalama İşaretleri + Heal İyileşmek + Healing Şifa + Hide the layer's effects (⌥-click: all layers) Katmanın efektlerini gizleyin (⌥ tuşuna basarak tıklayın: tüm katmanlar) + High Pass Yüksek Geçiş + Higher values follow only edges that contrast sharply with their surroundings Daha yüksek değerler yalnızca çevreleriyle keskin bir kontrast oluşturan kenarları takip eder + Highlight Vurgula + How often fastening points are placed by themselves Sabitleme noktalarının kendi başlarına ne sıklıkla yerleştirildiği + ISO Speed ISO Hızı + Image Processor Görüntü İşlemcisi + In içinde + Install Plug-in Eklentiyi Yükle + Interpret Footage Görüntüyü Yorumlama + Intersect with selection (⇧⌥) Seçimle kesiştir (⇧⌥) + Iris Blur İris Bulanıklığı + Jag Jag + Justify Gerekçelendir + Keeps the current texture when switching presets. Ön ayarları değiştirirken mevcut dokuyu korur. + Label Etiket + Layer Comps to Files Dosyalara Katman Kompozisyonları + Layer Content Options Katman İçeriği Seçenekleri + Layer From Background Arka Plandan Katman + Layer from Background Arka Plandan Katman + Layers to Files Dosyalara Katmanlar + Legacy Eski + Lens Objektif + Lens Blur Mercek Bulanıklığı + Lens Correction Mercek Düzeltme + Lens Corrections Mercek Düzeltmeleri + Lens Flare Mercek Parlaması + Lens Info Objektif Bilgisi + Letter Spacing Harf Aralığı + Lighting Effects Aydınlatma Efektleri + Limit Playback Oynatmayı Sınırla + Liquify Sıvılaştır + Lock Layers Katmanları Kilitle + Loop Döngü + Lum Lum + Match Color Rengi Eşleştir + Measure Ölçü + Merge to HDR Pro HDR Pro'ya Birleştir + Mezzotint Mezotint + Modify Date Tarihi Değiştir + Mosaic Mozaik + Motion Blur Hareket Bulanıklığı + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Yerel dosya sürükle ve bırak özelliği henüz Wayland'de desteklenmiyor. Dosya › Aç'ı kullanın veya PhotoCraft'ı XWayland altında `WAYLAND_DISPLAY= photocraft` ile çalıştırın. + Neon Glow Neon Parıltı + New Guide Layout Yeni Kılavuz Düzeni + New Path Yeni Yol + New Video Group Yeni Video Grubu + New Video Layer Yeni Video Katmanı + New Video Layer from File Dosyadan Yeni Video Katmanı + Newspaper Gazete + Ocean Ripple Okyanus Dalgalanması + Oil Paint Yağlı Boya + Open As Farklı Aç + Operation Operasyon + Out Dışarı + Package Paket + Paint Daubs Boya Lekeleri + Path Blur Yol Bulanıklığı + Path to Illustrator… İllüstratöre Giden Yol… + Path: Yol: + Paths to Illustrator Illustrator'a Giden Yollar + Photomerge Fotoğraf Birleştir + Picture Frame Resim Çerçevesi + Pinch Tutam + Place Embedded Gömülü Yer + Place Linked Bağlantılı Yer + Place Scale Marker Ölçek İşaretçisini Yerleştirin + Plastic Wrap Plastik Sarma + Pointillize Noktalaştır + Polar Coordinates Kutupsal Koordinatlar + Polar To Rectangular Polar'dan Dikdörtgen'e + Poster Edges Poster Kenarları + Preset Manager Ön Ayar Yöneticisi + Profile Profil + Quick Sync Hızlı Senkronizasyon + Radial Blur Radyal Bulanıklık + Raw Data Ham Veri + Raw Defaults Ham Varsayılanlar + Rectangular To Polar Dikdörtgenden Polar'a + Reduce Noise Gürültüyü Azaltın + Relink to File Dosyaya Yeniden Bağlan + Render Video Videoyu Oluştur + Replace Color Rengi Değiştir + Replace Contents İçeriği Değiştir + Replace Footage Görüntüyü Değiştir + Resolve Missing Fonts Eksik Yazı Tiplerini Çözün + Ripple Dalgalanma + Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Rotate Auto Otomatik Döndür + Rotate Fixed Sabit Döndür + Rough Pastels Kaba Pastel + Sat Cumartesi + Save a Copy Bir Kopyasını Kaydet + Save for Web (Legacy) Web için Kaydet (Eski) + Saved Multichannel Çok Kanallı Kaydedildi + Scale Effects Ölçek Efektleri + Script Events Manager Komut Dosyası Olay Yöneticisi + Search menus Arama menüleri + Seed tohum + Select Plane Düzlem Seç + Select and Mask Seç ve Maskele + Semi Bold Yarı Kalın + Set Measurement Scale Ölçüm Ölçeğini Ayarla + Shadows/Highlights Gölgeler/Vurgular + Shape Blur Şekil Bulanıklığı + Shear Kesme + Show the layer's effects (⌥-click: all layers) Katmanın efektlerini göster (⌥ tuşuna basarak tıklayın: tüm katmanlar) + Single Word Justification Tek Kelime Gerekçesi + Smart Blur Akıllı Bulanıklaştırma + Smart Sharpen Akıllı Keskinleştirme + Smudge Stick Leke Çubuğu + Solid Color Düz Renk + Spacing: Aralık: + Spherize Küreselleştir + Spin Blur Döndürme Bulanıklığı + Split Toning Bölünmüş Tonlama + Split at Playhead Oynatma Kafasında Böl + Spot nokta + Spot Channel Spot Kanal + Sprayed Strokes Püskürtülmüş Vuruşlar + Strokes Vuruşlar + Subject Distance Konu Mesafesi + Subtract from selection (⌥) Seçimden çıkar (⌥) + Sumi-e Sumi-e + Surface Blur Yüzey Bulanıklığı + Technology Previews Teknoloji Önizlemeleri + Texturizer Dokulaştırıcı + Tiles Fayans + Tilt Eğim + Tilt-Shift Tilt-Shift + Tone Curve Ton Eğrisi + Tool Araç + Toolbars Araç Çubukları + Trace Contour Konturu İzle + Trap Tuzak + Tree Ağaç + Trim Kırpma + Turbulent Çalkantılı + Twirl Burgu + Ultra Bold Ultra Kalın + Ultra Light Ultra Hafif + Unsharp Mask Keskinliği Azaltma Maskesi + Use File › Open to open an image. Bir resmi açmak için Dosya › Aç'ı kullanın. + Vanishing Point Ufuk Noktası + Variable Data Sets Değişken Veri Setleri + Video Frames to Layers Video Karelerinden Katmanlara + WIA Support WIA Desteği + Warp Text Metni Çözgü + Watercolour look: lighter interior, darker rims. Suluboya görünümü: daha açık iç kısım, daha koyu jantlar. + Wind Rüzgâr + Word Spacing Kelime Aralığı + Working Gray Plate Gri Çalışma Plakası + ZigZag ZigZag + ↑↓ navigate ↵ run esc close ↑↓ gezin ↵ çalıştır esc kapat +cameraRaw Amount Miktar +cameraRaw Blacks Siyahlar +cameraRaw Color Detail Renk Detayı +cameraRaw Color Grading Renk Derecelendirme +cameraRaw Darks Koyular +cameraRaw Global Genel +cameraRaw Hue Ton +cameraRaw Lights Açıklar +cameraRaw Luminance Detail Parlaklık Detayı +cameraRaw Masking Maskeleme +cameraRaw Roughness Pürüzlülük +cameraRaw Tint Renk Tonu +cameraRaw Vibrance Titreşim +cameraRaw Whites Beyazlar + Wand Değnek + Note tool Not aracı + Magnetic Lasso Manyetik Kement + Art History Brush Sanat Tarihi Fırçası + Spot Healing Nokta Şifası + Content-Aware Move İçeriğe Uygun Taşıma + Vertical Type Tool Dikey Metin Aracı + Click the first point or press {key} to close · {del} removes a point · Esc cancels İlk noktayı tıklayın veya kapatmak için {key} tuşuna basın · {del} bir noktayı kaldırır · Esc iptal eder + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Tıklayın, ardından bir kenar boyunca hareket edin · bir noktayı sabitlemek için tıklayın · düz bir parça için {alt}-tıklayın · kapatmak için çift tıklayın + About {app} {app} Hakkında + Hide {app} {app}'ı gizle + Quit {app} {app}'dan çık + Services Hizmetler + Hide Others Diğerlerini Gizle + 3D 3D + Pen Kalem + Hand El + Sampler Örnekleyici + Patch Yama + Perspective Crop Perspektif Kırpma + RGB RGB + ISO ISO + ColorSync Renk Senkronizasyonu + ProPhoto ProFotoğraf + sRGB sRGB + ACES ACES + {zoom}% %{zoom} + Mac LCD Mac LCD'si + Mac Sharp Mac Keskin + Mac Crisp Mac Çıtır + Mac Strong Mac Güçlü + Mac Smooth Mac Pürüzsüz + Adobe Single-line Composer Adobe Tek Satır Oluşturucu + Adobe Every-line Composer Adobe Her Satır Oluşturucusu + X X + Y Y + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + URLs URLs + EXIF EXIF + GPS GPS + Mac OS Mac OS + Unix Unix + RGB: RGB: + CMYK: CMYK: + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE-C + HDTV (Rec. 709) HDTV (Kayıt 709) + CIE RGB CIE RGB + Photo (2 × 3 in) Fotoğraf (2 × 3 inç) + Photo (4 × 6 in) Fotoğraf (4 × 6 inç) + Photo (5 × 7 in) Fotoğraf (5 × 7 inç) + Photo (8 × 10 in) Fotoğraf (8 × 10 inç) + Lut Lut + Cooling L B B Soğutma L B B + Cooling80 Soğutma80 + Cooling82 Soğutma82 + Hsb HSB + Hsl HSL + Movie Prime Film Başbakanı + Prime105 Prime105 + Prime35 Prime35 + Rgb RGB + Sponge Sünger + Warming L B A Isınma L B A + Warming81 Isınma81 + Warming85 Isınma85 +@id filter.sharpen.sharpen Keskinleştir + Good iyi + Best En iyi + Out of gamut for printing: click for the nearest printable colour Yazdırma için gamın dışında: yazdırılabilir en yakın renk için tıklayın + Not a web colour: click for the nearest web colour Web rengi değil: en yakın web rengi için tıklayın + Adjustment Properties Ayarlama Özellikleri + Brush Stroke Fırça Darbesi + Default Foreground and Background Colors Varsayılan Ön Plan ve Arka Plan Renkleri + Move Layer Katmanı Taşı + Rectangular Selection Dikdörtgen Seçimi + Reorder Layer Katmanı Yeniden Sırala + Select Layer Katman Seç + Set Colors Renkleri Ayarla + Switch Foreground and Background Colors Ön Plan ve Arka Plan Renklerini Değiştir + Target Hedef + 3 by 3 Average 3'e 3 Ortalama + 5 by 5 Average 5'e 5 Ortalama + 11 by 11 Average 11'e 11 Ortalama + 31 by 31 Average 31'e 31 Ortalama + 51 by 51 Average 51'e 51 Ortalama + 101 by 101 Average 101'e 101 Ortalama + All Layers No Adjustments Tüm Katmanlar Ayarlama Yok + Color to Alpha… Alfa'ya Renk… + Current & Below No Adjustments Mevcut ve Altında Ayarlama Yok + Now drag the selection onto another area Şimdi seçimi başka bir alana sürükleyin + Point Sample Nokta Örneği + Sample Size: Örnek Boyutu: + Show Sampling Ring Örnekleme Halkasını Göster + Dual Axis Çift Eksen + Diagonal diyagonal + Transform Symmetry Simetriyi Dönüştür + Rasterize svg groups deeper than Svg gruplarını şundan daha derin bir şekilde rasterleştirin: + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 bu eşiği kapatır; 100 düzeyden daha derine yerleştirilmiş SVG grupları her zaman rasterleştirilir. + Rule of Thirds Üçte Bir Kuralı + Golden Ratio Altın Oran + Golden Spiral Altın Spiral + Auto Show Overlay Otomatik Göster Yer Paylaşımı + Always Show Overlay Kaplamayı Her Zaman Göster + Never Show Overlay Kaplamayı Hiçbir Zaman Gösterme + Cycle Overlay Döngü Yerleşimi + Cycle Orientation Döngü Yönü + Set the overlay options for the Crop tool Kırpma aracının kaplama seçeneklerini ayarlama + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt tuşuna basıp masaüstüne giderek pencereleri Alt tuşuyla sürükleyerek hareket ettirin. Pencere taşıma tuşunu Süper olarak ayarlayın veya Pencere › Değiştirici Tuşlar'da Alt'ı mandallayın. + Color Picker (Layer Style Color) Renk Seçici (Katman Stili Rengi) + Auto Hide Notifications Bildirimleri Otomatik Gizle + Notification Duration (seconds) Bildirim Süresi (saniye) + Appearance Mode Görünüm Modu + Next Appearance Mode Sonraki Görünüm Modu + Sync with system Sistemle senkronizasyon + Active Aktif + Color Picker (Solid Color) Renk Seçici (Düz Renk) + Straighten the image by drawing a line on it Üzerine bir çizgi çizerek görüntüyü düzeltin + Set additional Crop options Ek Kırpma seçeneklerini ayarlayın + Show Cropped Area Kırpılmış Alanı Göster + Enable Crop Shield Kırpma Kalkanını Etkinleştir + Match Canvas Maç Kanvası + Auto Adjust Opacity Opaklığı Otomatik Ayarla + Crop Shield color Kırpma Kalkanı rengi + Color Picker (Crop Shield Color) Renk Seçici (Kırpma Kalkanı Rengi) + From Center Merkezden + Link radii Bağlantı yarıçapları + Star Ratio: Yıldız Oranı: + Create Rectangle Dikdörtgen Oluştur + Create Ellipse Elips Oluştur + Create Triangle Üçgen Oluştur + Create Polygon Çokgen Oluştur + Pressure curve Basınç eğrisi + Click to add a point, right-click to remove Nokta eklemek için tıklayın, kaldırmak için sağ tıklayın + Some brush presets could not be loaded Bazı fırça ön ayarları yüklenemedi + Retry saving presets Ön ayarları kaydetmeyi yeniden deneyin + Brush preset changes are not saved: {error} Fırça hazır ayarı değişiklikleri kaydedilmiyor: {error} + Keep this tab open to retain your brushes. Fırçalarınızı korumak için bu sekmeyi açık tutun. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Tarayıcı depolama alanı kullanılamıyor; fırçalar yalnızca oturum içindir. Bunları korumak için bu sekmeyi açık tutun. + Saving brush presets… Keep this tab open until saving finishes. Fırça ön ayarları kaydediliyor… Kaydetme bitene kadar bu sekmeyi açık tutun. + Reflect Yansıt + Glow Parıltı + Negation Olumsuzluk + XOR XOR + Color Burn (Paint.NET) Renk Yanması (Paint.NET) + Color Dodge (Paint.NET) Renk Soldurma (Paint.NET) + Pro Medium Profesyonel Orta + Studio Light Stüdyo Işığı + Stroke Options Kontur Seçenekleri + Solid Katı + Dashed Kesikli + Dotted Noktalı + Caps Kapaklar + Butt / Flat Popo / Düz + Miter Gönye + Round Yuvarlak + Bevel Eğim + Miter limit Gönye sınırı + Dashed line Kesikli çizgi + Dash Çizgi + Gap Boşluk + Dash / gap lengths × stroke width Çizgi / boşluk uzunlukları × kontur genişliği + Add pair Çift ekle + Remove pair Çifti kaldır + Dash offset Çizgi ofseti + Open paths use centered strokes Açık yollar ortalanmış konturlar kullanır + Use finite lengths and a positive pattern total Sonlu uzunluklar ve pozitif bir model toplamı kullanın + No subject found — try Object Selection or the Lasso Konu bulunamadı — Nesne Seçimi'ni veya Kement'i deneyin + Quick Selection found nothing there Hızlı Seçim orada hiçbir şey bulamadı + Object Selection found nothing there — try a tighter rectangle Nesne Seçimi burada hiçbir şey bulamadı; daha dar bir dikdörtgen deneyin + No subject found — try a rectangle around the object Konu bulunamadı — nesnenin etrafında bir dikdörtgen oluşturmayı deneyin + Appearance Settings… Görünüm Ayarları… + Add to favorites Favorilere ekle + Remove from favorites Favorilerden kaldır +cameraData Make Üretici +cameraData Model Model +cameraData Date Taken Çekim Tarihi +cameraData Exposure Pozlama +cameraData F-Stop Diyafram +cameraData ISO Speed ISO Hızı +cameraData Focal Length Odak Uzaklığı +cameraData Focal Length (35 mm) Odak Uzaklığı (35 mm) +cameraData Lens Lens + Activate Panel Paneli Etkinleştir + Change Panel Group Panel Grubunu Değiştir + Change Panel Layout Panel Düzenini Değiştir + Close Panel Paneli Kapat + Dock Panel Bağlantı İstasyonu Paneli + Float Panel Şamandıra Paneli + Move Panel Paneli Taşı + Group with Şununla grupla: + Panel menu Panel menüsü + Resize panel window Panel penceresini yeniden boyutlandır + Resize panels Panelleri yeniden boyutlandır + Use Classic Mode Klasik Modu Kullan + Auto Center Preview Otomatik Merkezleme Önizlemesi + Next Document Sonraki Belge + Previous Document Önceki Belge + W x H x Resolution G x Y x Çözünürlük + Front Image Ön Resim + Perspective Crop Tool Perspektif Kırpma Aracı + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Bir kutuyu sürükleyin veya dört köşeyi tıklayın · köşeleri çarpık bir şeklin kenarlarına sürükleyin · {commit} taahhütleri · Esc iptalleri + Artboard Tool Çalışma Yüzeyi Aracı + Make Portrait Portre Yap + Make Landscape Manzara Yap + Add New Artboard Yeni Çalışma Yüzeyi Ekle + Freeform Pen Tool Serbest Kalem Aracı + Curvature Pen Tool Eğrilik Kalemi Aracı + Curve Fit: Eğri Fitili: + Drag: draw freehand · Release on the start point: close Sürükleyin: serbest çizim yapın · Başlangıç noktasında bırakın: kapat + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Tıklama: düzgün nokta · Çift tıklama: köşe · Bir noktayı sürükleyin: taşıma · İlk noktayı tıklayın: kapat · {key} bitiş + New Crop Preset… Yeni Kırpma Ön Ayarı… + Delete Crop Preset… Kırpma Ön Ayarını Sil… + Select the crop presets to delete: Silinecek kırpma ön ayarlarını seçin: + Select the crop presets to delete Silinecek kırpma ön ayarlarını seçin + Enter a name for the crop preset Kırpma ön ayarı için bir ad girin + There is no crop size or ratio to save Kaydedilecek kırpma boyutu veya oranı yok + Search settings… Arama ayarları… + No matching settings Eşleşen ayar yok + PhotoCraft — Screen Color Picker PhotoCraft — Ekran Rengi Seçici + Loading fonts… Yazı tipleri yükleniyor… + Onion Skin Soğan Kabuğu + Marching Ants Yürüyen Karıncalar + On Black Siyah + On White Beyaz + On Layers Katmanlarda + Show Edge Kenarı Göster + Show Original Orijinali Göster + F cycles the views; hold X to see the image F görünümleri döndürür; resmi görmek için X'i basılı tutun + Refine Edge Brush (R) Kenar Fırçasını İncelt (R) + Expand Detection Area Algılama Alanını Genişlet + Restore Original Edge Orijinal Kenarı Geri Yükle + Paint over hair or fur on the image; Alt paints the other mode Görüntünün üzerine saç veya kürkün üzerini boyayın; Alt diğer modu boyar + Clear Brush Strokes Fırça Darbelerini Temizle + Show rulers Cetvelleri göster + Frame Tool Çerçeve Aracı + Create a new rectangular frame Yeni bir dikdörtgen çerçeve oluşturun + Create a new elliptical frame Yeni bir eliptik çerçeve oluşturun + Include in playback Oynatmaya dahil et + Scale: Ölçek: + Snap to Origin Orijine Yapış + Click to place · Alt-click or drag off-canvas to remove Yerleştirmek için tıklayın · Kaldırmak için Alt tuşuna basarak tıklayın veya tuvalin dışına sürükleyin + {n} of {max} sample points {n} / {max} örnek nokta + Emulsion Down Emülsiyon Aşağı + Functions Fonksiyonlar + Print Resolution: Baskı Çözünürlüğü: + Transparency Threshold Saydamlık Eşiği + Opacity Threshold Opaklık Eşiği + (No neutral color exists for {mode} mode.) ({mode} modu için nötr renk bulunmuyor.) + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Sürükle: serbest çiz · {alt}-tıkla: düz segment · Başlangıç noktasında bırak: kapat + Fill with {mode}-neutral color (50% gray) {mode} nötr rengiyle doldur (%50 gri) + Fill with {mode}-neutral color (black) {mode} nötr rengiyle doldur (siyah) + Fill with {mode}-neutral color (white) {mode} nötr rengiyle doldur (beyaz) + Use Previous Layer to Create Clipping Mask Kırpma Maskesi Oluşturmak İçin Önceki Katmanı Kullan + Amount: Miktar: + Apply Vanishing Point ({key}) Ufuk Noktasını Uygula ({key}) + Apply the removal ({key}) Kaldırmayı uygula ({key}) + Drag to draw the curve · {key}-click draws a straight line Eğriyi çizmek için sürükle · {key}-tıklama düz çizgi çizer + Draw to modify the curve Eğriyi değiştirmek için çiz + Edit points to modify the curve Eğriyi değiştirmek için noktaları düzenle + Fix the red plane first: its corners must form a convex quad Önce kırmızı düzlemi düzeltin: köşeleri dışbükey bir dörtgen oluşturmalı + Grid Size: Izgara Boyutu: + Light Blue Açık Mavi + Light Gray Açık Gri + Light Red Açık Kırmızı + Medium Blue Orta Mavi + Merge Group Grubu Birleştir + Protect skin tones Ten tonlarını koru + Protect: Koru: + Remove after each stroke Her vuruştan sonra kaldır + Saving {percent} Kaydediliyor {percent} + Scale or rotate the moved area, then press {key} Taşınan alanı ölçeklendirin veya döndürün, ardından {key} tuşuna basın + Smooth the curve values Eğri değerlerini yumuşat + Strokes to remove: {n} (Enter applies, Esc cancels) Kaldırılacak vuruşlar: {n} (Enter uygular, Esc iptal eder) + The layer or its mask changed. Drag the mask onto the Trash again. Katman veya maskesi değişti. Maskeyi yeniden Çöp Kutusu'na sürükleyin. + Transform On Drop Bırakınca Dönüştür + Vanishing Point is not active Ufuk Noktası etkin değil + Vanishing Point: click four corners to create a plane Ufuk Noktası: düzlem oluşturmak için dört köşeye tıklayın +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} renk örneği {file} dosyasına dışa aktarıldı +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {group} grubundan {n} fırça içe aktarıldı +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file} dosyasından {n} renk örneği içe aktarıldı +@plural Played {n} step|Played {n} steps {n} adım oynatıldı +@plural Saved for Web: {n} file|Saved for Web: {n} files Web İçin Kaydedildi: {n} dosya +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Diskte değişen {n} bağlantılı akıllı nesne güncellendi +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. Bu kompozisyonun kaydettiği {n} katman silindi. Kompozisyonu güncelleyin veya uyarıyı temizleyin. +@plural {n} plane|{n} planes {n} düzlem +@plural {n} preset|{n} presets {n} ön ayar +@plural {n} sample point removed|{n} sample points removed {n} örnek nokta kaldırıldı +@plural {n} slice|{n} slices {n} dilim +@plural {n} step|{n} steps {n} adım +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) +@plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 8b0ebf40ab..cba241e1be 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -48,7 +48,8 @@ catalog). 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the -other catalogs translate, [notes](localization-it.md)). +other catalogs translate, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every +measured key. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -79,3 +80,4 @@ engine localization or native review is complete. | 2026-10-11 | minor | Czech dynamic status/notice and hardware text, render labels and Navigator zoom context; engine messages remain English | | 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed | | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | +| 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 105f927435..2be43cb538 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -579,6 +579,7 @@ mod tests { ("el", "el.tsv"), ("uk", "uk.tsv"), ("nl", "nl.tsv"), + ("tr", "tr.tsv"), ("vi", "vi.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); From 9e623e7a4533063db3ff1f0968455236464d027e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bihacsy=20L=C3=A1szl=C3=B3?= <115165402+Skaywalker@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:07:17 +0200 Subject: [PATCH 150/523] i18n: add Hungarian (hu) UI translation (#3831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n(cs): drop duplicate catalog entries The Czech catalog listed Tool, Clone Stamp, History Brush, Dodge, Burn, Sponge and Stroke Path… twice, which fails `cargo xtask i18n-coverage` and the bundled_catalogs_are_consistent test. Keep one row each; for Stroke Path… keep the later row, the one the loader already used. * test(crop): pin the UI language to English in crop_default_mode The test queries widgets by their English labels ("Ratio", "Swaps height and width") but its harness followed the system locale, so it failed on a machine whose locale has a registered catalog. Set interface.language to "en" in the harness, as paint_bucket_all_layers and export_as_scaling do. * i18n: add Hungarian (hu) UI translation A complete Hungarian catalog (hu.tsv, 3908 entries: 3829 plain, 50 contextual, 21 plural, 8 command-id overrides) registered with the complete-menu languages; hu-HU locales resolve to it. Plural rule: plural_none. A Hungarian count takes the singular noun ("3 réteg"), so every @plural entry has one form. Clean room: written from the plain meaning of the English labels with ordinary Hungarian image-editing vocabulary; no proprietary localisation resource, string table or product UI was used. Also listed in the coverage test, LICENSE-translations.txt, ATTRIBUTION.md and docs/localization.md, with tag, system-locale and plural tests. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + .../ui-egui/src/i18n/LICENSE-translations.txt | 9 +- crates/ui-egui/src/i18n/hu.tsv | 3972 +++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 24 +- crates/ui-egui/src/i18n/system.rs | 1 + crates/ui-egui/tests/crop_default_mode.rs | 2 + docs/localization.md | 1 + xtask/src/i18n_coverage.rs | 1 + 8 files changed, 4006 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/i18n/hu.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7aece035b7..c2b9f87854 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -19,6 +19,7 @@ next to them. | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/cs.tsv` | Czech UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `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) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index eae278e4dc..0177bcb56d 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech and Turkish UI translation catalogs are -licensed under MIT OR Apache-2.0, at your option. The full license texts are in ../../../../LICENSE-MIT -and ../../../../LICENSE-APACHE. +PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and +Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full +license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. @@ -25,3 +25,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Turkish translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Hungarian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv new file mode 100644 index 0000000000..080e204aaa --- /dev/null +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -0,0 +1,3972 @@ +# Hungarian (hu) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Hungarian image-editing vocabulary (réteg, maszk, kijelölés ...). No proprietary localisation +# resource, string table or product UI was consulted or copied; add entries the same way. +# Product and technology names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab ...) stay in Latin +# letters; the trailing "…" of dialog commands is kept. Plural rule: a count is followed by the +# singular noun in Hungarian, so every @plural entry has a single form. + +# Plural messages and command id overrides +@id edit.purge.undo Visszavonási adatok +@id edit.stroke Körvonalazás… +@id edit.transform.scale Méretezés +@id filter.sharpen.sharpen Egyszerű élesítés +@id select.all Mind +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} színminta exportálva. Cél: {file} +@plural Group · {n} layer|Group · {n} layers Csoport · {n} réteg +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} {n} ecset importálva. Forrás: {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} {n} ecset importálva. Csoport: {group} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} {n} színátmenet importálva. Forrás: {file} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} színminta importálva. Forrás: {file} +@plural Played {n} step|Played {n} steps {n} lépés lejátszva +@plural Saved for Web: {n} file|Saved for Web: {n} files Mentés webre: {n} fájl +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk {n} csatolt intelligens objektum frissítve (a lemezen módosult) +@plural {n} item|{n} items {n} elem +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. A rétegkompozícióban rögzített rétegek közül {n} törlődött. Frissítsd a rétegkompozíciót, vagy töröld a figyelmeztetést. +@plural {n} Layer|{n} Layers {n} réteg +@plural {n} layer|{n} layers {n} réteg +@plural {n} plane|{n} planes {n} sík +@plural {n} preset|{n} presets {n} előbeállítás +@plural {n} sample point removed|{n} sample points removed {n} mintavételi pont eltávolítva +@plural {n} sample|{n} samples {n} minta +@plural {n} slice|{n} slices {n} szelet +@plural {n} step|{n} steps {n} lépés +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} további figyelmeztetés az ecset-előbeállításokról) +@plural {what} with {n} warning|{what} with {n} warnings {what} ({n} figyelmeztetés) +@id file.close Bezárás +@id layer.layerMask.apply Alkalmazás +@id edit.clear Kiürítés + +# Menus: File, Edit, Image + 16 Bits/Channel 16 bit/csatorna + 180° 180° + 32 Bits/Channel 32 bit/csatorna + 8 Bits/Channel 8 bit/csatorna + 90° Clockwise 90° az óramutató járása szerint + 90° Counter Clockwise 90° az óramutató járásával szemben + Adjustments Korrekciók + Again Ismétlés + AI Integrations… MI-integrációk… + Analysis Elemzés + Apply Data Set… Adatkészlet alkalmazása… + Apply Image… Kép alkalmazása… + Arbitrary… Tetszőleges… + Artboards to Files… Rajztáblák fájlokba… + Artboards to PDF… Rajztáblák PDF-be… + Assign Profile… Profil hozzárendelése… + Auto Color Automatikus szín + Auto Contrast Automatikus kontraszt + Auto Tone Automatikus tónus + Auto-Align Layers… Rétegek automatikus igazítása… + Auto-Blend Layers… Rétegek automatikus keverése… + Automate Automatizálás + Batch… Kötegelt feldolgozás… + Bitmap Bittérkép + Black & White… Fekete-fehér… + Brightness/Contrast… Fényerő/kontraszt… + Browse… Tallózás… + Calculations… Számítások… + Canvas Size… Vászonméret… + Channel Mixer… Csatornakeverő… + Check Spelling… Helyesírás-ellenőrzés… + Clipboard Vágólap + Close All Összes bezárása + Close Others Többi bezárása + Color Balance… Színegyensúly… + Color Lookup Tables… Színleképezési táblák… + Color Lookup… Színleképezés… + Color Settings… Színbeállítások… + Color Table… Színtábla… + Conditional Mode Change… Feltételes módváltás… + Contact Sheet II… Kontaktlap II… + Content-Aware Fill… Tartalomérzékeny kitöltés… + Content-Aware Scale Tartalomérzékeny méretezés + Convert to Profile… Átalakítás profilra… + Copy Másolás + Copy Merged Egyesített másolás + Create Droplet… Droplet létrehozása… + Crop Körbevágás + Crop and Straighten Photos Fotók körbevágása és kiegyenesítése + Cursors… Kurzorok… + Curves… Görbék… + Cut Kivágás + Data Sets as Files… Adatkészletek fájlokként… + Data Sets… Adatkészletek… + Define Brush Preset… Ecset-előbeállítás megadása… + Define Custom Shape… Egyéni alakzat megadása… + Define Pattern… Mintázat megadása… + Define… Megadás… + Delete All Empty Layers Összes üres réteg törlése + Desaturate Színtelenítés + Distort Torzítás + Duotone Kéttónusú + Duplicate… Kettőzés… + Edit Szerkesztés + Enhanced Controls… Bővített vezérlők… + Equalize Kiegyenlítés + Exit Kilépés + Export As… Exportálás másként… + Export Preferences… Beállítások exportálása… + Export/Import Presets… Előbeállítások exportálása/importálása… + Export… Exportálás… + Exposure… Expozíció… + Fade… Halványítás… + File Fájl + File Handling… Fájlkezelés… + File Info… Fájladatok… + Fill… Kitöltés… + Find and Replace Text… Szöveg keresése és cseréje… + Fit Image… Kép méretre igazítása… + Flatten All Layer Effects Összes rétegeffektus egybesimítása + Flatten All Masks Összes maszk egybesimítása + Flip Canvas Horizontal Vászon vízszintes tükrözése + Flip Canvas Vertical Vászon függőleges tükrözése + Flip Horizontal Vízszintes tükrözés + Flip Vertical Függőleges tükrözés + Free Transform Szabad alakítás + Generate Létrehozás + Gradient Map… Színátmenet-leképezés… + Guides, Grid & Slices… Segédvonalak, rács és szeletek… + HDR Toning… HDR-tónusozás… + Histories Előzmények + History Log… Előzménynapló… + Hue/Saturation… Színárnyalat/telítettség… + Image Kép + Image Assets Képelemek + Image Processor… Képfeldolgozó… + Image Rotation Képforgatás + Indexed Color Indexelt szín + Interface… Felület… + Keyboard Shortcuts… Billentyűparancsok… + Layer Comps to Files… Rétegkompozíciók fájlokba… + Layers to Files… Rétegek fájlokba… + Lens Correction… Objektívkorrekció… + Levels… Szintek… + Load Files into Stack… Fájlok betöltése halomba… + Match Color… Színillesztés… + Menus… Menük… + Merge to HDR Pro… HDR Pro-egyesítés… + Migrate Presets Előbeállítások átvétele + Multichannel Többcsatornás + New… Új… + Notes… Jegyzetek… + Open As… Megnyitás mint… + Open EXR Parts… EXR-részek megnyitása… + Open… Megnyitás… + Package… Csomagolás… + Paste Beillesztés + Paste in Place Beillesztés a helyére + Paste Into Beillesztés belülre + Paste Outside Beillesztés kívülre + Paste Special Speciális beillesztés + Paths to Illustrator… Útvonalak Illustrator-fájlba… + Performance… Teljesítmény… + Perspective Perspektíva + Perspective Warp Perspektivikus torzítás + Photo Filter… Fotószűrő… + Photomerge… Fotóegyesítés… + Place Embedded… Beágyazott elhelyezése… + Place Linked… Csatolt elhelyezése… + Place Scale Marker… Méretarány-jelölő elhelyezése… + Plug-ins… Bővítmények… + Posterize… Poszterizálás… + Preferences Beállítások + Preset Manager… Előbeállítás-kezelő… + Presets Előbeállítások + Print One Copy Egy példány nyomtatása + Print… Nyomtatás… + Puppet Warp Bábtorzítás + Purge Memória ürítése + Quick Export as PNG Gyors exportálás PNG-be + Raw Defaults… Raw-alapértékek… + Record Measurements Mérések rögzítése + Redo Újra + Remove Warp Split Hajlításfelosztás eltávolítása + Render Video… Videó renderelése… + Replace Color… Színcsere… + Reveal All Minden megjelenítése + Revert Visszaállítás + Rotate Forgatás + Rotate 180° Forgatás 180°-kal + Rotate 90° Clockwise Forgatás 90°-kal az óramutató járása szerint + Rotate 90° Counter Clockwise Forgatás 90°-kal az óramutató járásával szemben + Save Mentés + Save a Copy… Másolat mentése… + Save As… Mentés másként… + Save for Web (Legacy)… Mentés webre (régi)… + Scale Méretarány + Scratch Disks… Lapozólemezek… + Script Events Manager… Parancsfájl-eseménykezelő… + Scripts Parancsfájlok + Search… Keresés… + Select Data Points… Adatpontok kiválasztása… + Selective Color… Szelektív szín… + Set Measurement Scale… Mérési méretarány beállítása… + Settings… Beállítások… + Shadows/Highlights… Árnyékok/csúcsfények… + Skew Döntés + Split Warp Crosswise Hajlítás felosztása keresztben + Split Warp Horizontally Hajlítás felosztása vízszintesen + Split Warp Vertically Hajlítás felosztása függőlegesen + Statistics… Statisztika… + Stroke… Körvonal… + Threshold… Küszöb… + Toggle Last State Utolsó állapot váltása + Toolbar… Eszköztár… + Tools… Eszközök… + Transform Alakítás + Transparency & Gamut… Átlátszóság és színtartomány… + Trap… Ráfutás… + Trim… Levágás… + Type… Szöveg… + Undo Visszavonás + Units & Rulers… Mértékegységek és vonalzók… + Variable Data Sets… Változó adatkészletek… + Variables Változók + Vibrance… Élénkség… + Video Cache Videogyorsítótár + Video Frames to Layers… Videókockák rétegekbe… + Warp Hajlítás + WIA Support… WIA-támogatás… + Workspace… Munkaterület… + +# Menus: Layer, Select + Align Igazítás + Apply Alkalmaz + Arrange Elrendezés + Artboard from Group… Rajztábla csoportból… + Artboard from Layers… Rajztábla rétegekből… + Artboard… Rajztábla… + Background from Layer Háttér a rétegből + Bevel & Emboss… Fazetta és domborítás… + Blending Options… Keverési beállítások… + Border… Szegély… + Bottom Edges Alsó szélek + Bring Forward Előrébb hozás + Bring to Front Előrehozás legfelülre + Clear Layer Style Rétegstílus törlése + Clear Smart Filters Intelligens szűrők törlése + Color Decontaminate… Színszennyeződés eltávolítása… + Color Overlay… Színfedés… + Color Range… Színtartomány… + Combine Shapes Alakzatok kombinálása + Contract… Szűkítés… + Convert to Embedded Átalakítás beágyazottá + Convert to Layers Átalakítás rétegekké + Convert to Linked… Átalakítás csatolttá… + Convert to Smart Object Átalakítás intelligens objektummá + Copy Layer Style Rétegstílus másolása + Create Clipping Mask Vágómaszk létrehozása + Create Layer Réteg létrehozása + Current Path Aktuális útvonal + Defringe… Szegélyjavítás… + Delete Filter Mask Szűrőmaszk törlése + Delete Frame Képkocka törlése + Deselect Kijelölés megszüntetése + Deselect Layers Rétegek kijelölésének megszüntetése + Disable Filter Mask Szűrőmaszk letiltása + Disable Smart Filters Intelligens szűrők letiltása + Distribute Elosztás + Drop Shadow… Vetett árnyék… + Duplicate Frame Képkocka kettőzése + Duplicate Layer… Réteg kettőzése… + Edit Contents Tartalom szerkesztése + Edit in Quick Mask Mode Szerkesztés gyorsmaszk módban + Enable Layer Mask Rétegmaszk engedélyezése + Enable Vector Mask Vektormaszk engedélyezése + Entropy Entrópia + Exclude Overlapping Shapes Átfedő alakzatok kizárása + Expand… Bővítés… + Export Contents… Tartalom exportálása… + Feather… Elmosott szél… + Fill Content Kitöltés tartalma + Find Layers Rétegek keresése + Flatten Image Kép egybesimítása + Focus Area… Fókuszterület… + Frame from Layers Keret rétegekből + From Transparency Átlátszóságból + Global Light… Globális fény… + Gradient Overlay… Színátmenet-fedés… + Gradient… Színátmenet… + Group from Layers… Csoport rétegekből… + Group Layers Rétegek csoportosítása + Group… Csoport… + Grow Növelés + Hidden Layers Rejtett rétegek + Hide All Minden elrejtése + Hide All Effects Összes effektus elrejtése + Hide Layers Rétegek elrejtése + Hide Selection Kijelölés elrejtése + Horizontal Centers Vízszintes középvonalak + Inner Glow… Belső ragyogás… + Inner Shadow… Belső árnyék… + Insert Blank Frame Üres képkocka beszúrása + Interpret Footage… Videoanyag értelmezése… + Intersect Shape Areas Alakzatterületek metszete + Inverse Selection Kijelölés megfordítása + Invert… Invertálás… + Isolate Layers Rétegek elkülönítése + Kurtosis Csúcsosság + Layer Réteg + Layer Content Options… Rétegtartalom beállításai… + Layer from Background… Réteg a háttérből… + Layer Style Rétegstílus + Layer via Copy Réteg másolással + Layer via Cut Réteg kivágással + Layer… Réteg… + Left Edges Bal szélek + Link Layer Mask Rétegmaszk összekapcsolása + Link Layers Rétegek összekapcsolása + Link Vector Mask Vektormaszk összekapcsolása + Load Selection… Kijelölés betöltése… + Lock Layers… Rétegek zárolása… + Mask All Objects Összes objektum maszkolása + Matting Szegélykezelés + Maximum Maximum + Mean Átlag + Median Medián + Merge Layers Rétegek egyesítése + Merge Shape Components Alakzatösszetevők egyesítése + Merge Visible Láthatók egyesítése + Modify Módosítás + New Adjustment Layer Új korrekciós réteg + New Blank Video Layer Új üres videoréteg + New Fill Layer Új kitöltőréteg + New Layer Based Slice Új rétegalapú szelet + New Smart Object via Copy Új intelligens objektum másolással + New Video Layer from File… Új videoréteg fájlból… + Outer Glow… Külső ragyogás… + Paste Layer Style Rétegstílus beillesztése + Pattern Overlay… Mintázatfedés… + Pattern… Mintázat… + Range Terjedelem + Rasterize Raszterizálás + Relink to File… Újracsatolás fájlhoz… + Reload Frame Képkocka újratöltése + Remove Black Matte Fekete szegély eltávolítása + Remove White Matte Fehér szegély eltávolítása + Rename Layer Réteg átnevezése + Replace Contents… Tartalom cseréje… + Replace Footage… Videoanyag cseréje… + Reselect Újbóli kijelölés + Restore All Frames Összes képkocka visszaállítása + Restore Frame Képkocka visszaállítása + Reveal in Finder Megjelenítés a Finderben + Reveal Selection Kijelölés megjelenítése + Right Edges Jobb szélek + Satin… Szatén… + Save Selection… Kijelölés mentése… + Scale Effects… Effektusok méretezése… + Select Kijelölés + Select and Mask… Kijelölés és maszk… + Select Linked Layers Összekapcsolt rétegek kijelölése + Send Backward Hátrébb küldés + Send to Back Hátraküldés legalulra + Show Altered Video Módosított videó megjelenítése + Similar Hasonló + Skewness Ferdeség + Sky Égbolt + Smart Filter Intelligens szűrő + Smart Object Intelligens objektum + Smart Objects Intelligens objektumok + Smooth… Simítás… + Solid Color… Egyszínű kitöltés… + Stack Mode Halommód + Standard Deviation Szórás + Subject Fő téma + Subtract Front Shape Elülső alakzat kivonása + Summation Összegzés + Top Edges Felső szélek + Transform Selection Kijelölés alakítása + Type Szöveg + Ungroup Layers Csoport bontása + Unite Shapes Alakzatok egyesítése + Update All Modified Content Összes módosult tartalom frissítése + Update Modified Content Módosult tartalom frissítése + Variance Variancia + Vector Mask Vektormaszk + Vertical Centers Függőleges középvonalak + Video Videó + Video Layers Videorétegek + +# Menus: Type, Filter, View, Window; UI and engine commands + (coming soon) (hamarosan) + 100% 100% + 2-up Horizontal 2 nézet vízszintesen + 2-up Vertical 2 nézet függőlegesen + 200% 200% + 3-up Horizontal 3 nézet vízszintesen + 3-up Stacked 3 nézet egymás alatt + 3-up Vertical 3 nézet függőlegesen + 32-bit Preview Options… 32 bites előnézet beállításai… + 4-up 4 nézet + 6-up 6 nézet + About PhotoCraft A PhotoCraft névjegye + About {app} Névjegy: {app} + Actions Műveletsorok + Activate Panel Panel aktiválása + Actual Pixels Tényleges méret + Adaptive Wide Angle… Adaptív nagy látószög… + Add Layer Mask Rétegmaszk hozzáadása + Add Noise… Zaj hozzáadása… + Add Vector Mask Vektormaszk hozzáadása + Adwaita Dark Theme Adwaita sötét téma + Adwaita Light Theme Adwaita világos téma + AI Integrations MI-integrációk + Anamorphic 2:1 (2) Anamorfikus 2:1 (2) + Anti-Alias Élsimítás + Appearance Megjelenés + Apply Data Set Adatkészlet alkalmazása + Apply Layer Mask Rétegmaszk alkalmazása + Artboard Guides Rajztábla-segédvonalak + Artboards to Files Rajztáblák fájlokba + Artboards to PDF Rajztáblák PDF-be + ArtCraft Website ArtCraft-weboldal + Average Átlag + Background From Layer Háttér a rétegből + Bitmap… Bittérkép… + Blur Elmosás + Blur Gallery Elmosásgaléria + Blur More Erősebb elmosás + Box Blur… Dobozelmosás… + Breeze Dark Theme Breeze sötét téma + Breeze Light Theme Breeze világos téma + Brush Preview Ecset előnézete + Brush Settings Ecsetbeállítások + Brushes Ecsetek + brushes ecsetek + Camera Raw Filter… Camera Raw szűrő… + Canvas Guides Vászon-segédvonalak + Cascade Lépcsőzetes + Change Panel Group Panelcsoport váltása + Change Panel Layout Panelelrendezés váltása + Change… Módosítás… + Channels Csatornák + Character Panel Karakter panel + Character Styles Karakterstílusok + Character Styles Panel Karakterstílusok panel + Classic Theme Klasszikus téma + Clear Canvas Guides Vászon-segédvonalak törlése + Clear Guides Segédvonalak törlése + Clear Recent Files Legutóbbi fájlok listájának törlése + Clear Selected Artboard Guides Kijelölt rajztábla segédvonalainak törlése + Clear Slices Szeletek törlése + Clipping Mask Vágómaszk + Clipping Path… Vágóútvonal… + Clone Source Klónozási forrás + Close Panel Panel bezárása + Close Window Ablak bezárása + Clouds Felhők + Color Blindness — Deuteranopia-type Színtévesztés — deuteranópia típusú + Color Blindness — Protanopia-type Színtévesztés — protanópia típusú + Color Halftone… Színes féltónus… + Color Lookup Tables Színleképezési táblák + Color Picker (Custom Canvas Color) Színválasztó (egyéni vászonszín) + Color to Alpha… Szín átalakítása átlátszóvá… + Consolidate All to Tabs Összes lapfülre + Contextual Alternates Környezetfüggő változatok + Convert for Smart Filters Átalakítás intelligens szűrőkhöz + Convert to Paragraph Text Átalakítás bekezdésszöveggé + Convert to Point Text Átalakítás pontszöveggé + Convert to Shape Átalakítás alakzattá + Copy Complete Stroke Teljes körvonal másolása + Copy Fill Kitöltés másolása + Count Darabszám + Create Vector Mask Vektormaszk létrehozása + Create Work Path Munkaútvonal létrehozása + Crisp Tiszta + Crosswise Keresztben + Crystallize… Kristályosítás… + Custom Pixel Aspect Ratio Egyéni pixelarány + Custom Pixel Aspect Ratio… Egyéni pixelarány… + Custom… Egyéni… + Cycle Screen Mode Képernyőmód váltása + D1/DV NTSC (0.91) D1/DV NTSC (0,91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC szélesvásznú (1,21) + D1/DV PAL (1.09) D1/DV PAL (1,09) + D1/DV PAL Widescreen (1.46) D1/DV PAL szélesvásznú (1,46) + Dark gray Sötétszürke + Dark Gray Sötétszürke + Data Sets as Files Adatkészletek fájlokként + De-Interlace… Váltott soros megjelenítés megszüntetése… + Default Features Alapértelmezett funkciók + Define Brush Preset Ecset-előbeállítás megadása + Define Custom Shape Egyéni alakzat megadása + Define Pattern Mintázat megadása + Delete and Fill Selection Kijelölés törlése és kitöltése + Delete Layer Réteg törlése + Delete Layer Mask Rétegmaszk törlése + Delete Layers Rétegek törlése + Delete Vector Mask Vektormaszk törlése + Delete Workspace… Munkaterület törlése… + Despeckle Szemcsék eltávolítása + Difference Clouds Különbségfelhők + Diffuse… Diffúzió… + Disable Layer Mask Rétegmaszk letiltása + Disable Symmetry Path Szimmetriaútvonal letiltása + Discretionary Ligatures Választható ligatúrák + Displace… Elmozdítás… + Dock Panel Panel dokkolása + Document Bounds Dokumentum határai + Duotone… Kéttónusú… + Duplicate Layers… Rétegek kettőzése… + Dust & Scratches… Por és karcolások… + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1,5) + East Asian Features Kelet-ázsiai funkciók + Edit Pins Tűk szerkesztése + Edit Type Szöveg szerkesztése + Effects Effektusok + Emboss… Domborítás… + Essentials Alapok + Extra Large Nagyon nagy + Extras Segédelemek + Extrude… Kihúzás… + F F + Facet Lapolás + Fibers… Szálak… + Field Blur… Mezőelmosás… + Fill Path… Útvonal kitöltése… + Fill Screen Képernyő kitöltése + Filter Szűrő + Filter Gallery… Szűrőgaléria… + Find and Replace Text Szöveg keresése és cseréje + Fit Artboard on Screen Rajztábla képernyőhöz igazítása + Fit Layer(s) on Screen Réteg(ek) képernyőhöz igazítása + Fit on Screen Képernyőhöz igazítás + Flame… Láng… + Float All in Windows Összes lebegő ablakban + Float in Window Lebegő ablakban + Float Panel Panel lebegtetése + Font Preview Size Betűtípus-előnézet mérete + Fractions Törtek + Fragment Töredezés + Frame from Layers… Keret rétegekből… + Free Transform a Copy Másolat szabad alakítása + Free Transform Path Útvonal szabad alakítása + Full Screen Teljes képernyő + Full Screen Mode Teljes képernyős mód + Full Screen Mode With Menu Bar Teljes képernyős mód menüsorral + Gamut Warning Színtartomány-figyelmeztetés + Gaussian Blur… Gauss-elmosás… + Glyphs Glifák + Glyphs Panel Glifák panel + Gradients Színátmenetek + Graphic and Web Grafika és web + Graphic and web Grafika és web + Grid Rács + Group from Layers Csoport rétegekből + Guides Segédvonalak + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1,33) + Help Súgó + Hide Elrejtés + Hide Others Többi elrejtése + Hide {app} Elrejtés: {app} + High Pass… Felüláteresztő… + Histogram Hisztogram + History Előzmények + Horizontal Vízszintes + HSB/HSL HSB/HSL + Huge Óriási + Image Processor Képfeldolgozó + Indexed Color… Indexelt szín… + Info Információ + Install Plug-in Bővítmény telepítése + Install Plug-in… Bővítmény telepítése… + Internet Standard RGB Internetes szabványos RGB + Inverse Megfordítás + Iris Blur… Íriszelmosás… + Join the ArtCraft Discord Csatlakozás az ArtCraft Discordhoz + Join the ArtCraft Discord… Csatlakozás az ArtCraft Discordhoz… + Language Nyelv + Language Options Nyelvi beállítások + Large Nagy + Last Filter Utolsó szűrő + Layer Comps Rétegkompozíciók + Layer Comps to Files Rétegkompozíciók fájlokba + Layer Content Options Rétegtartalom beállításai + Layer Edges Rétegszélek + Layer from Background Réteg a háttérből + Layer From Background… Réteg a háttérből… + Layers Rétegek + Layers to Files Rétegek fájlokba + Legacy Macintosh RGB Régi Macintosh RGB + Lens Blur… Objektívelmosás… + Lens Flare… Lencsecsillanás… + Light gray Világosszürke + Light Gray Világosszürke + Lighting Effects… Fényhatások… + Liquify… Képlékenyítés… + Load Default Type Styles Alapértelmezett szövegstílusok betöltése + Load Files into Stack Fájlok betöltése halomba + Lock Guides Segédvonalak zárolása + Lock Slices Szeletek zárolása + Lock Workspace Munkaterület zárolása + Make Selection… Kijelölés készítése… + Make Symmetry Path Szimmetriaútvonal készítése + Make Work Path… Munkaútvonal készítése… + Match All Összes illesztése + Match Location Hely illesztése + Match Rotation Elforgatás illesztése + Match Zoom Nagyítás illesztése + Maximum… Maximum… + Measurement Log Mérési napló + Median… Medián… + Medium gray Középszürke + Medium Gray Középszürke + Merge Down Egyesítés lefelé + Merge to HDR Pro HDR Pro-egyesítés + Mesh Háló + Mezzotint… Mezzotinto… + Middle Eastern & South Asian Composer Közel-keleti és dél-ázsiai szedőmotor + Middle Eastern Features Közel-keleti funkciók + Minimize Minimalizálás + Minimum… Minimum… + Modifier Keys Módosítóbillentyűk + Monitor RGB Monitor-RGB + Mosaic… Mozaik… + Motion Mozgókép + Motion Blur… Mozgási elmosás… + Move Panel Panel áthelyezése + Navigator Navigátor + New from Clipboard Új a vágólapról + New Guide Layout… Új segédvonal-elrendezés… + New Guides From Shape Új segédvonalak alakzatból + New Guide… Új segédvonal… + New Layer… Új réteg… + New Shape Layer Új alakzatréteg + New Type Layer Új szövegréteg + New Video Layer Új videoréteg + New Window Új ablak + New Window for Document Új ablak a dokumentumhoz + New Workspace… Új munkaterület… + Next Appearance Mode Következő megjelenési mód + Next Document Következő dokumentum + Next Theme Következő téma + No matching commands Nincs egyező parancs + Notes Jegyzetek + NTSC Colors NTSC-színek + Offset… Eltolás… + Oil Paint… Olajfestmény… + Oldstyle Régies számok + Open As Megnyitás mint + Open Recent Legutóbbi megnyitása + Open Recent is unavailable on the web A legutóbbi fájlok listája a webes változatban nem érhető el + OpenType OpenType + Options Beállítósáv + Ordinals Sorszámnevek + Orientation Tájolás + Ornaments Díszek + Other… Egyéb… + Painting Festés + Panels Panelek + Paragraph Panel Bekezdés panel + Paragraph Styles Bekezdésstílusok + Paragraph Styles Panel Bekezdésstílusok panel + Paste Complete Stroke Teljes körvonal beillesztése + Paste Fill Kitöltés beillesztése + Paste Lorem Ipsum Lorem ipsum beillesztése + Path Blur… Útvonal-elmosás… + Paths Útvonalak + Paths to Illustrator Útvonalak Illustrator-fájlba + Pattern Preview Mintázat előnézete + PhotoCraft PhotoCraft + PhotoCraft on GitHub PhotoCraft a GitHubon + PhotoCraft Website PhotoCraft-weboldal + Photography Fotózás + Picture Frame… Képkeret… + Pinch… Becsípés… + Pixel Art Pixelgrafika + Pixel Art Preview Pixelgrafikus előnézet + Pixel Aspect Ratio Pixelarány + Pixel Aspect Ratio Correction Pixelarány-korrekció + Pixel Grid Pixelrács + Pixelate Pixelesítés + Plug-ins Bővítmények + Pointillize… Pontozás… + Polar Coordinates… Polárkoordináták… + Preferences… Beállítások… + Previous Document Előző dokumentum + Print Size Nyomtatási méret + Pro Light Gray Theme Pro világosszürke téma + Pro Light Theme Pro világos téma + Pro Medium Gray Theme Pro középszürke téma + Pro Theme Pro téma + Proof Colors Színpróba + Proof Setup Színpróba beállítása + Properties Tulajdonságok + Quick Export As PNG Gyors exportálás PNG-be + Quit Kilépés + Quit {app} Kilépés: {app} + Radial Blur… Sugárirányú elmosás… + Rasterize Layer Réteg raszterizálása + Rasterize Layer Style Rétegstílus raszterizálása + Rasterize Layers Rétegek raszterizálása + Rasterize Shape Alakzat raszterizálása + Rasterize Type Szöveg raszterizálása + Rasterize Type Layer Szövegréteg raszterizálása + Raw Defaults Raw-alapértékek + Reduce Noise… Zajcsökkentés… + Release Clipping Mask Vágómaszk feloldása + Relight… Újravilágítás… + Rename Layer… Réteg átnevezése… + Render Renderelés + Replace All Missing Fonts Összes hiányzó betűtípus cseréje + Report an Issue Hiba bejelentése + Report an Issue… Hiba bejelentése… + Reset All Összes alaphelyzetbe + Reset All Tools Összes eszköz alaphelyzetbe + Reset Tool Eszköz alaphelyzetbe + Reset View Nézet alaphelyzetbe + Reset Workspace Munkaterület alaphelyzetbe + Resolve Missing Fonts Hiányzó betűtípusok kezelése + Resolve Missing Fonts… Hiányzó betűtípusok kezelése… + Ripple… Fodrozódás… + Rotate View Nézetforgatás + Rulers Vonalzók + Save As Mentés másként + Save Default Type Styles Alapértelmezett szövegstílusok mentése + Screen Mode Képernyőmód + Scroll menu down Menü görgetése lefelé + Scroll menu up Menü görgetése felfelé + Search menus Keresés a menükben + Select All Összes kijelölése + Select Custom Color… Egyéni szín kiválasztása… + Select Inverse Kijelölés megfordítása + Selection Edges Kijelölésszélek + Settings Beállítások + Shape Blur… Alakzatos elmosás… + Shapes Alakzatok + Sharp Éles + Sharpen Élesítés + Sharpen Edges Élek élesítése + Sharpen More Erősebb élesítés + Shear… Nyírás… + Show Megjelenítés + Show All Összes megjelenítése + Show Extras Options… Segédelemek beállításai… + Show in Explorer Megjelenítés az Intézőben + Show in Folder Megjelenítés mappában + Show menu colors Menüszínek megjelenítése + Show/Hide All Panels Összes panel megjelenítése/elrejtése + Show/Hide Panels Panelek megjelenítése/elrejtése + Simulate Pressure Nyomás szimulálása + Slices Szeletek + Small Kicsi + Smart Blur… Intelligens elmosás… + Smart Guides Intelligens segédvonalak + Smart Sharpen… Intelligens élesítés… + Smooth Sima + Snap Illesztés + Snap To Illesztési célok + Solarize Szolarizáció + Solarized Dark Theme Solarized sötét téma + Spherize… Gömbösítés… + Spin Blur… Pörgetéses elmosás… + Square Négyzetes + Standard Ligatures Normál ligatúrák + Standard Screen Mode Normál képernyőmód + Statistics Statisztika + Stroke Path… Útvonal körvonalazása… + Strong Erős + Studio Light Theme Studio világos téma + Studio Theme Studio téma + Styles Stílusok + Stylistic Alternates Stilisztikai változatok + Stylize Stilizálás + Subtract Shapes at Overlap Alakzatok kivonása az átfedésben + Surface Blur… Felületi elmosás… + Swash Díszbetűk + Swatches Színminták + Sync with system Rendszer szerint + System Info… Rendszerinformáció… + Target Path Célútvonal + Theme Téma + Tile Mozaikelrendezés + Tile All Horizontally Összes vízszintes mozaikba + Tile All Vertically Összes függőleges mozaikba + Tiles… Csempék… + Tilt-Shift… Tilt-shift… + Timeline Idővonal + Titling Alternates Címbetű-változatok + Tool Presets Eszköz-előbeállítások + Tools Eszközök + Trace Contour… Kontúrkövetés… + Transform Symmetry Szimmetria alakítása + Tree… Fa… + Twirl… Örvény… + Unite Shapes at Overlap Alakzatok egyesítése az átfedésben + Unlink Layer Mask Rétegmaszk szétkapcsolása + Unsharp Mask… Életlenmaszk… + Update All Text Layers Összes szövegréteg frissítése + Vanishing Point… Enyészpont… + Variable Data Sets Változó adatkészletek + Vector Mask from Current Path Vektormaszk az aktuális útvonalból + View menu Nézet menü + Warp Text… Szövegtorzítás… + Wave… Hullám… + WIA Support WIA-támogatás + Window Ablak + Windows Windows + Windows LCD Windows LCD + Wind… Szél… + Working Black Plate Fekete munkalemez + Working CMY Plate CMY munkalemezek + Working CMYK Munka-CMYK + Working Cyan Plate Cián munkalemez + Working Magenta Plate Magenta munkalemez + Working Yellow Plate Sárga munkalemez + Workspace Munkaterület + ZigZag… Cikcakk… + Zoom In Nagyítás + Zoom Out Kicsinyítés + +# Tools, brushes and the options bar + 101 by 101 Average 101 x 101 átlag + 11 by 11 Average 11 x 11 átlag + 3 by 3 Average 3 x 3 átlag + 3 x 3 3 x 3 + 31 by 31 Average 31 x 31 átlag + 4 x 4 4 x 4 + 5 by 5 Average 5 x 5 átlag + 5 x 5 5 x 5 + 51 by 51 Average 51 x 51 átlag + Add Hozzáad + Add New Artboard Új rajztábla hozzáadása + Add pair Pár hozzáadása + Add space after paragraph Térköz a bekezdés után + Add space before paragraph Térköz a bekezdés előtt + Adds the current gradient to the Gradients panel Hozzáadja a jelenlegi színátmenetet a Színátmenetek panelhez + Adjust for Zoom Igazítás a nagyításhoz + Adjustment layers have nothing to transform A korrekciós rétegeken nincs mit alakítani + Airbrush: keeps depositing paint while the pointer rests. Festékszóró: a festék akkor is gyűlik, amikor a mutató egy helyben áll. + Align with layer Igazítás a réteghez + Aligned Igazított + Alignment Igazítás + All Caps Csupa nagybetű + All Layers Minden réteg + All Layers No Adjustments Minden réteg, korrekciók nélkül + Alt Alt + Alt-click to define a source point to clone from Alt+kattintással add meg a klónozás forráspontját + Alternative Alternatív + Always Mindig + Always Show Overlay Fedvény megjelenítése mindig + Android 1080p Android 1080p + Angle Gradient Kúpos színátmenet + Angle Jitter Szög ingadozása + Angle: Szög: + Anti-alias Élsimítás + Anti-aliasing method Élsimítási mód + Apply Per Tip Alkalmazás hegyenként + Apply the pen's tilt and rotation to the tip shape A toll dőlését és elforgatását alkalmazza a hegy alakjára + Artboard Rajztábla + Artboard Background Color Rajztábla háttérszíne + Artboard from Group Rajztábla csoportból + Artboard from Layers Rajztábla rétegekből + Artboards Rajztáblák + At Itt + Auto Adjust Opacity Átlátszatlanság automatikus igazítása + Auto Center Preview Előnézet automatikus középre helyezése + Auto Show Overlay Fedvény megjelenítése automatikusan + Background Eraser Háttérradír + Baseline Alapvonal + Baseline shift Alapvonal-eltolás + Baseline Shift Alapvonal-eltolás + Basic Alapbeállítások + Before Előtte + Bend: Hajlás: + Bevel Levágott + Bicubic Bikubikus + Bilinear Bilineáris + Black Fekete + Black, White Fekete, fehér + Block Blokk + Blur Tool Elmosó + Bold Félkövér + Both Axes Mindkét tengely + Brightness Jitter Fényerő ingadozása + Bring the selected pin forward A kijelölt tű előrébb hozása + Brush Ecset + Brush Name Ecsetnév + Brush name Ecsetnév + Brush picker Ecsetválasztó + Brush Pose Ecsettartás + Brush Preset Options Ecset-előbeállítás beállításai + Brush preview color Ecset-előnézet színe + Brush Projection Ecsetvetítés + Brush Settings… (F5): every option of this brush Ecsetbeállítások… (F5): az ecset összes beállítása + Brush Stroke Ecsetvonás + Brush Tip Ecsethegy + Brush Tip Shape Ecsethegy alakja + Brush Tool Ecset + Build-up Felhalmozódás + Burn Égetés + Burn Tool Égető + Butt / Flat Tompa / lapos + Cache Level: Gyorsítótárszint: + Cancel any current edits (Esc) Folyamatban lévő módosítások elvetése (Esc) + Cancel current crop operation (Esc) Körbevágás elvetése (Esc) + Cancel transform Alakítás elvetése + Cancel transform (Esc) Alakítás elvetése (Esc) + Cancel warp (Esc) Hajlítás elvetése (Esc) + Canvas Vászon + Caps Vonalvégek + Catch-up on Stroke End Utolérés a vonás végén + Center Középen + Center text Szöveg középre igazítása + Centre the gradient (offset 0, 0) Színátmenet középre helyezése (eltolás: 0, 0) + Change Cserél + Change All Mindet cserél + Change To: Csere erre: + Character Karakter + Check All Layers Minden réteg ellenőrzése + Check Spelling Helyesírás-ellenőrzés + Clear Override Felülbírálás törlése + Clear Selection Kijelölés törlése + Click a red pupil to neutralize it Kattints egy vörös pupillára a semlegesítéséhez + Click to add a point, right-click to remove Kattintással pontot adhatsz hozzá, jobb kattintással eltávolíthatod + Click to apply, {key}-click to clear overrides, double-click for Style Options Kattintással alkalmazhatod, {key}+kattintással törölheted a felülbírálásokat, dupla kattintással megnyithatod a stílusbeállításokat + Click to edit the gradient Kattints a színátmenet szerkesztéséhez + Click to sample the foreground colour · {key}-click for background Kattintás: mintavétel az előtérszínhez · {key}+kattintás: a háttérszínhez + Clone source set at {x}, {y} Klónozási forrás beállítva: {x}, {y} + Clone Stamp Klónozó bélyegző + Close Style Options Stílusbeállítások bezárása + Color Szín + Color Dynamics Színdinamika + Color Picker (Crop Shield Color) Színválasztó (takarás színe) + Color Picker (Solid Color) Színválasztó (egyszínű kitöltés) + Color Picker (Stroke Color) Színválasztó (körvonalszín) + Color Picker (Text Color) Színválasztó (szövegszín) + Color Replacement Színcserélő + Color: Szín: + Commit any current edits ({key}) Folyamatban lévő módosítások véglegesítése ({key}) + Commit current crop operation ({key}) Körbevágás véglegesítése ({key}) + Commit Puppet Warp ({key}) Bábtorzítás véglegesítése ({key}) + Commit transform Alakítás véglegesítése + Commit transform ({key}) Alakítás véglegesítése ({key}) + Commit warp ({key}) Hajlítás véglegesítése ({key}) + Condensed Keskeny + Content-Aware Tartalomérzékeny + Content-Aware Move Tartalomérzékeny mozgatás + Contiguous Összefüggő + Control: Vezérlés: + Corners Sarkok + Could not complete the Free Transform command because the layer is locked. A szabad alakítás nem hajtható végre, mert a réteg zárolva van. + Could not transform: the layer is empty Az alakítás nem hajtható végre: a réteg üres + Could not use Puppet Warp: no opaque pixels A bábtorzítás nem használható: nincsenek átlátszatlan pixelek + Could not use Puppet Warp: the layer is empty A bábtorzítás nem használható: a réteg üres + Could not use the move tool because the layer is locked. A Mozgatás eszköz nem használható, mert a réteg zárolva van. + Count Jitter Darabszám ingadozása + Create a new elliptical frame Új ellipszis alakú keret létrehozása + Create a new rectangular frame Új téglalap alakú keret létrehozása + Create Ellipse Ellipszis létrehozása + Create new brush from the current settings Új ecset a jelenlegi beállításokból + Create new brush from these settings Új ecset ezekből a beállításokból + Create new brush preset from the current settings Új ecset-előbeállítás a jelenlegi beállításokból + Create new style (from the selected type) Új stílus létrehozása (a kijelölt szövegből) + Create Polygon Sokszög létrehozása + Create Rectangle Téglalap létrehozása + Create Texture Textúra létrehozása + Create Triangle Háromszög létrehozása + Crop Shield color Takarás színe + current jelenlegi + Current Jelenlegi + Current & Below Jelenlegi és alatta lévők + Current & Below No Adjustments Jelenlegi és alatta lévők, korrekciók nélkül + Current Layer Jelenlegi réteg + Custom Egyéni + Cycle Orientation Tájolás váltása + Cycle Overlay Fedvény váltása + Darken Amount Sötétítés mértéke + Dash Szakasz + Dash / gap lengths × stroke width Szakasz- és hézaghossz × vonalvastagság + Dash offset Szaggatás eltolása + Dashed Szaggatott + Dashed line Szaggatott vonal + Degrees Fok + Delete Brush Ecset törlése + Delete brush Ecset törlése + Delete Crop Preset… Körbevágási előbeállítás törlése… + Delete Group Csoport törlése + Delete style Stílus törlése + Density: Sűrűség: + Depth Jitter Mélység ingadozása + Destination Cél + Diagonal Átlós + Diameter Átmérő + Diameter: Átmérő: + Diamond Gradient Rombusz alakú színátmenet + Discontiguous Nem összefüggő + Divide… Felosztás… + Dodge Derítés + Dodge Tool Derítő + Dodge/Burn Derítés/égetés + Done Kész + Dot Pont + Dots Pontok + Dotted Pontozott + Drag a rectangle around the object Húzz egy téglalapot az objektum köré + Drag the arrow to set the angle, the dots to set the roundness A nyíl húzásával a szöget, a pontokéval a kerekséget állíthatod + Dual Axis Kettős tengely + Dual Brush Kettős ecset + Duplicate Kettőzés + Duplicate style Stílus kettőzése + Dynamics Dinamika + Enable Crop Shield Körbevágási takarás bekapcsolása + Enable {name} Bekapcsolás: {name} + Enhance Edge Élek javítása + Enter a name for the crop preset Add meg a körbevágási előbeállítás nevét + Entire Font Teljes betűtípus + Erase Radírozás + Eraser Radír + Expansion: Bővítés: + Exposure: Expozíció: + Extend Kiterjesztés + Extra Bold Extrakövér + Extra Light Extravékony + ExtraBold Extrakövér + ExtraLight Extravékony + Fade Halványulás + Fade steps Halványulás lépései + Faux Bold Álfélkövér + Faux Italic Áldőlt + Fewer Points Kevesebb pont + Fill Layer Kitöltőréteg + Fill new type layers with placeholder Új szövegrétegek kitöltése helyőrző szöveggel + Find Edges Élkeresés + Finger Painting Ujjfestés + First Line Indent Első sor behúzása + Fixed Rögzített + Flip Tükrözés + Flip X X-tükrözés + Flip X Jitter X-tükrözés ingadozása + Flip Y Y-tükrözés + Flip Y Jitter Y-tükrözés ingadozása + Flow Áramlás + Flow Jitter Áramlás ingadozása + Font Betűtípus + Font size Betűméret + Foreground to Background Előtérszínből háttérszínbe + Foreground/Background Jitter Előtér/háttér ingadozása + From Center Középpontból + Front Image Elülső kép + Gap Hézag + General Általános + Glyph Glifa + Golden Ratio Aranymetszés + Golden Spiral Aranyspirál + Gradient Színátmenet + Gradient Editor Színátmenet-szerkesztő + Gradient Options Színátmenet beállításai + Gradient picker Színátmenet-választó + Gradient stops Színátmenetpontok + Grid view Rácsnézet + Grid: Rács: + Group name Csoport neve + H M + Hard Kemény + Hardness Keménység + Hardness: Keménység: + Healing Javítás + Healing Brush Javítóecset + Height Magasság + Height: Magasság: + Hide Auto Slices Automatikus szeletek elrejtése + History Brush Előzményecset + Horizontal scale Vízszintes méretarány + Horizontal type Vízszintes szöveg + Hue Jitter Színárnyalat ingadozása + Hyphenate Elválasztás + Ignore Kihagy + Ignore All Mindet kihagy + Import Brushes… Ecsetek importálása… + Import Brushes… (.abr) Ecsetek importálása… (.abr) + Impressionist Impresszionista + Indent First Első sor behúzása + Indent first line Első sor behúzása + Indent Left Bal behúzás + Indent left margin Behúzás balról + Indent Right Jobb behúzás + Indent right margin Behúzás jobbról + Initial Direction Kezdeti irány + Inside Belül + Interpolation: Interpoláció: + Italic Dőlt + Justification Sorkizárás + Justify Sorkizárt + Justify all Teljes sorkizárás + Justify last centered Sorkizárt, utolsó sor középen + Justify last left Sorkizárt, utolsó sor balra + Justify last right Sorkizárt, utolsó sor jobbra + Kerning Alávágás + Lasso around an area, then drag the selection Keríts körbe egy területet lasszóval, majd húzd el a kijelölést + Leading Sorköz + Leading (set to the font size × auto-leading when Auto) Sorköz (automatikus értéknél: betűméret × automatikus sorköz) + Left align text Szöveg balra igazítása + Letter Letter + Letter Spacing Betűköz + Library Könyvtár + Ligature Ligatúra + Light Vékony + Limits: Határok: + Linear Gradient Lineáris színátmenet + Linear Height Lineáris magasság + Lines Vonalak + Link radii Sugarak összekapcsolása + List view Listanézet + Load Töltet + Loading fonts… Betűtípusok betöltése… + Location Hely + Location: Hely: + Lock: keep these settings when picking another preset Zárolva: ezek a beállítások megmaradnak, ha másik előbeállítást választasz + Loop Ismétlődés + Lorem Ipsum Lorem ipsum + Maintain aspect ratio Oldalarány megtartása + Make Landscape Fekvő tájolás + Make Portrait Álló tájolás + Make Work Path Munkaútvonal készítése + Match Canvas Vászonnal egyező + Mean: Átlag: + Median: Medián: + Medium Közepes + Metrics Metrikus + Minimum Depth Minimális mélység + Minimum Diameter Minimális átmérő + Minimum Roundness Minimális kerekség + Miter Hegyes + Miter limit Hegyességi korlát + Mix Keverés + Mix Jitter Keverés ingadozása + Mixer Brush Keverőecset + Mode: Mód: + More Points Több pont + Mouse Egér + Move Mozgatás + My Brushes Saját ecsetek + Name Név + Nearest Neighbor Legközelebbi szomszéd + Never Soha + Never Show Overlay Fedvény megjelenítése soha + New Brush Új ecset + New Brush Preset Új ecset-előbeállítás + New Brush Preset… Új ecset-előbeállítás… + New Crop Preset… Új körbevágási előbeállítás… + New Tool Preset Új eszköz-előbeállítás + No brush presets Nincs ecset-előbeállítás + No character style Nincs karakterstílus + No patterns Nincs mintázat + No subject found — try a rectangle around the object Nem található fő téma – próbálj egy téglalapot húzni az objektum köré + No subject found — try Object Selection or the Lasso Nem található fő téma – próbáld az Objektumkijelölést vagy a Lasszót + Noise Zaj + Not in Dictionary: Nincs a szótárban: + Now drag the selection onto another area Most húzd a kijelölést egy másik területre + Object Selection found nothing there — try a tighter rectangle Az Objektumkijelölés ott nem talált semmit – próbálj szűkebb téglalapot + Off Ki + Off: the speed of the pointer sets the spacing Kikapcsolva: a térközt a mutató sebessége határozza meg + Opacity Jitter Átlátszatlanság ingadozása + Opacity: Átlátszatlanság: + Open paths use centered strokes Nyitott útvonalon a körvonal középre igazított + Optical Optikai + Option-click to define a source point to clone from Option+kattintással add meg a klónozás forráspontját + Outside Kívül + Override Pressure Nyomás felülbírálása + Override Rotation Elforgatás felülbírálása + Override Tilt Dőlés felülbírálása + Paints from the document's opening state A dokumentum megnyitáskori állapotából fest + Paper Papír + Paragraph Bekezdés + Patch Foltozó + Patch: Foltozás: + Pattern Mintázat + Pattern Size Mintázatméret + Pattern Stamp Mintázatbélyegző + Pen Toll + Pen Pressure Tollnyomás + Pen Tilt Tolldőlés + Pencil Ceruza + Perspective Crop Perspektivikus körbevágás + Pin Depth: Tűmélység: + Pixels: Pixelek: + Point Sample Pontminta + Pressure curve Nyomásgörbe + Preview Image Előnézeti kép + Pro Pro + Promote Előléptetés + Protect Detail Részletek védelme + Protect Foreground Color Előtérszín védelme + Protect Texture Textúra védelme + Proximity Match Közelségi egyezés + Pulled String Mode Húzott zsinór mód + Punctuation Írásjelek + Pupil Size Pupillaméret + Puppet Warp is not active A bábtorzítás nem aktív + Purity Színtisztaság + Quick Selection found nothing there A Gyorskijelölés ott nem talált semmit + Radial Gradient Körkörös színátmenet + Radius: Sugár: + Ratio Arány + Raw Data Nyers adatok + Recently Used Legutóbb használt + Redefine style by current selection Stílus újbóli megadása a jelenlegi kijelölésből + Reflected Gradient Tükrözött színátmenet + Regular Normál + Remove Eltávolítás + Remove All Pins Összes tű eltávolítása + Remove pair Pár eltávolítása + Rename Brush Ecset átnevezése + Rename Brush… Ecset átnevezése… + Rename Group Csoport átnevezése + Rename Group… Csoport átnevezése… + Reset Visszaállít + Reset Alignment Igazítás visszaállítása + Reset all tools to their default settings? Visszaállítod az összes eszközt az alapértelmezett beállításokra? + Reset the brush to the defaults Ecset visszaállítása az alapértékekre + Restore original size Eredeti méret visszaállítása + Right align text Szöveg jobbra igazítása + Right click with painting tools Jobb kattintás festőeszközökkel + Rigid Merev + Roman Álló + Rotate: Forgatás: + Rotation Elforgatás + Round Lekerekített + Roundness Jitter Kerekség ingadozása + Roundness: Kerekség: + Rule of Thirds Harmadolási szabály + Sample All Layers Mintavétel minden rétegből + Sample Size: Mintaméret: + Sample: Minta: + Sampling: Background Swatch Mintavétel: háttérszín + Sampling: Continuous Mintavétel: folyamatos + Sampling: Once Mintavétel: egyszeri + Saturate Telítés + Saturation Jitter Telítettség ingadozása + Save As Preset Mentés előbeállításként + Scale: Méretarány: + Scatter Szóródás + Scattering Szóródás + Search Brushes Ecsetek keresése + Search fonts Betűtípusok keresése + Select a type layer or click into text to insert glyphs Glifák beszúrásához jelölj ki egy szövegréteget, vagy kattints a szövegbe + Select a type layer to edit its character and paragraph settings. Jelölj ki egy szövegréteget a karakter- és bekezdésbeállítások szerkesztéséhez. + Select text to apply a character style Karakterstílus alkalmazásához jelölj ki szöveget + Select the crop presets to delete Jelöld ki a törlendő körbevágási előbeállításokat + Select the crop presets to delete: Jelöld ki a törlendő körbevágási előbeállításokat: + Semi Bold Középkövér + SemiBold Középkövér + Send the selected pin backward A kijelölt tű hátrébb küldése + Set additional Crop options További körbevágási beállítások + Set painting symmetry options Festési szimmetria beállításai + Set the overlay options for the Crop tool A Körbevágás eszköz fedvénybeállításai + Set the text color Szövegszín beállítása + Settings: Beállítások: + Shape Dynamics Alakdinamika + Sharpen Tool Élesítő + Show bounding box when dragging layer Határolókeret megjelenítése réteg húzásakor + Show Cropped Area Levágott terület megjelenítése + Show Grid Rács megjelenítése + Show highlight on rollover Kiemelés rámutatáskor + Show Sampling Ring Mintavételi gyűrű megjelenítése + Show slice numbers Szeletszámok megjelenítése + Show: Megjelenítés: + Sides: Oldalak: + Size Méret + Size Jitter Méret ingadozása + Slant Dőlésszög + Slice color Szeletszín + Slice Options Szeletbeállítások + Slice Options… Szeletbeállítások… + Slices are locked (View › Lock Slices) A szeletek zárolva vannak (Nézet › Szeletek zárolása) + Slices From Guides Szeletek segédvonalakból + Small Caps Kiskapitális + Smart quotes Tipográfiai idézőjelek + Smoothing Simítás + Smudge Maszatoló + Smudge Tool Maszatoló + Snap to Origin Illesztés az origóhoz + Soft Lágy + Solid Folytonos + Solid Color Egyszínű + Space After Térköz utána + Space Before Térköz előtte + Spacing Térköz + Sparkle Csillogás + Spell check complete. A helyesírás-ellenőrzés befejeződött. + Split: Felosztás: + Sponge Tool Szivacs + Spot Healing Brush Foltjavító ecset + Stamp Down Másolat egyesítése lefelé + Stamp Visible Láthatók egyesített másolata + Star Ratio: Csillagarány: + Std Dev: Szórás: + Straighten the image by drawing a line on it Húzz egy vonalat a képen a kiegyenesítéshez + Strength: Erősség: + Strikethrough Áthúzás + Stroke Catch-up Vonás utolérése + Stroke color Körvonalszín + Stroke Color Körvonalszín + Stroke Options Körvonal beállításai + Structure: Struktúra: + Style Options Stílusbeállítások + Style Options… Stílusbeállítások… + Style: Normal Stílus: normál + Stylus Wheel Tollkerék + Suggestions: Javaslatok: + Swaps height and width Felcseréli a magasságot és a szélességet + Switch between free transform and warp modes Váltás a szabad alakítás és a hajlítás mód között + Symbols Szimbólumok + Symmetry Off Szimmetria ki + Text color Szövegszín + Text engine Szövegmotor + Text Layer Szövegréteg + Texture Each Tip Textúra hegyenként + The gradient the Gradient tool paints with A Színátmenet eszközzel festett színátmenet + There is no crop size or ratio to save Nincs menthető körbevágási méret vagy arány + Thin Hajszálvékony + Tilt Scale Dőlés méretaránya + Tilt X Dőlés X + Tilt Y Dőlés Y + Time Idő + Toggle text orientation Szövegirány váltása + Toggle the Brush Settings panel Ecsetbeállítások panel megjelenítése/elrejtése + Toggle the Brush Settings panel (F5) Ecsetbeállítások panel megjelenítése/elrejtése (F5) + Tolerance: Tűrés: + Tool Options Eszközbeállítások + Tracking Betűköz + Tracking (1/1000 em) Betűköz (1/1000 em) + Transfer Átvitel + Transformation Alakítás + Transparent Átlátszó + Triangle Háromszög + Type Options Szövegbeállítások + Type Styles Szövegstílusok + Type units Szöveg mértékegysége + Type: Típus: + Underline Aláhúzás + Unlock Feloldás + Unlock: picking a preset replaces these settings Feloldva: előbeállítás választásakor ezek a beállítások lecserélődnek + Update the current brush with these settings A jelenlegi ecset frissítése ezekkel a beállításokkal + Updates Frissítések + Use Classic Mode Klasszikus mód használata + Use esc to commit Véglegesítés az Esc billentyűvel + Use finite lengths and a positive pattern total Véges hosszakat adj meg, és a mintázat teljes hossza legyen pozitív + Use legacy free transform Régi szabad alakítás használata + Vary round brush hardness on hud Kerek ecset keménységének állítása a HUD-on + Version Verzió + Vertical Függőleges + Vertical scale Függőleges méretarány + Vertical type Függőleges szöveg + W Sz + W x H x Resolution Sz x M x felbontás + Warp Text Szövegtorzítás + Warp: Hajlítás: + Weight Vastagság + Wet Nedvesség + Wet Edges Nedves szélek + Wetness Jitter Nedvesség ingadozása + Width Szélesség + Width: Szélesség: + X X + {key} to subtract {key}: kivonás + {key}-click to set the source {key}+kattintással állíthatod be a forrást + {label} is not available: {reason} Nem érhető el: {label} ({reason}) +fontWeight Black Ultrakövér +fontWeight Bold Félkövér +fontWeight ExtraBold Extrakövér +fontWeight ExtraLight Extravékony +fontWeight Italic Dőlt +fontWeight Light Vékony +fontWeight Medium Közepes +fontWeight Regular Normál +fontWeight SemiBold Középkövér +fontWeight Thin Hajszálvékony + +# Layers, masks, layer styles and blend modes + 30 fps 30 fps + Add Layer Above to Selection Felette lévő réteg hozzáadása a kijelöléshez + Add Layer Below to Selection Alatta lévő réteg hozzáadása a kijelöléshez + Adjustment Korrekció + Advanced Blending Speciális keverés + Align and Distribute Igazítás és elosztás + Align {what} Igazítás: {what} + Alpha Alfa + Altitude Magasság + Angle Szög + Anti-aliased Élsimítás + Appearance (Layer Style) Megjelenés (rétegstílus) + Apply Image Kép alkalmazása + Apply Mask Maszk alkalmazása + Apply next selected layer comp Következő kijelölt rétegkompozíció alkalmazása + Apply previous selected layer comp Előző kijelölt rétegkompozíció alkalmazása + Apply {option}: {state} Alkalmazás – {option}: {state} + Bevel & Emboss Fazetta és domborítás + Black & White Fekete-fehér + Blend Clipped Layers as Group Vágott rétegek keverése csoportként + Blend If: Keverés, ha: + Blend Interior Effects as Group Belső effektusok keverése csoportként + Blend Mode Keverési mód + Blending Options Keverési beállítások + Blue Kék + Bold Italic Félkövér dőlt + Calculations Számítások + Capture the visibility, position and style of every layer with + below, then switch between versions. Az alábbi + gombbal rögzítheted minden réteg láthatóságát, pozícióját és stílusát, majd válthatsz a változatok között. + Channel Csatorna + Channels: Csatornák: + Choke Zsugorítás + Clear Brush Strokes Ecsetvonások törlése + CMYK: CMYK: + Collapse Összecsukás + Collapse All Groups Összes csoport összecsukása + Collapse group ({key}-click: all groups) Csoport összecsukása ({key}+kattintás: minden csoport) + Color Burn Színégetés + Color Burn (Paint.NET) Színégetés (Paint.NET) + Color Decontaminate Színszennyeződés eltávolítása + Color Dodge Színderítés + Color Dodge (Paint.NET) Színderítés (Paint.NET) + Color indicates A szín jelöli + Color Indicates Masked Areas A szín a maszkolt területeket jelöli + Color Indicates Selected Areas A szín a kijelölt területeket jelöli + Color Overlay Színfedés + Color Picker (Layer Style Color) Színválasztó (rétegstílus színe) + Color Swatches Színminták + Cone Kúp + Cone (Inverted) Kúp (fordított) + Cone - Inverted Kúp – fordított + Confirm Megerősít + Contour Kontúr + Contour Editor Kontúrszerkesztő + Convert to Alpha Channel Átalakítás alfacsatornává + Corner Sarokpont + Cove - Deep Öböl – mély + Cove - Shallow Öböl – sekély + Create new channel Új csatorna létrehozása + Create new layer Új réteg létrehozása + Create new layer comp Új rétegkompozíció létrehozása + Darken Sötétítés + Darker Color Sötétebb szín + Decontaminate Szennyeződés eltávolítása + Deep Mély + Defringe Szegélyjavítás + Delete Channel Csatorna törlése + Delete current channel Aktuális csatorna törlése + Delete layer comp Rétegkompozíció törlése + Delete layer ‘{name}’? Törlöd ezt a réteget: „{name}”? + Delete layer? Törlöd a réteget? + Delete {count} layers? {names} {count} réteget törölsz? {names} + Depth Mélység + Diagonal (Descending) Átlós (csökkenő) + Diamond Rombusz alakú + Difference Különbség + Direction Irány + Dissolve Szétszórás + Distance Távolság + Divide Osztás + Domed Domború + Domed (Inverted) Domború (fordított) + Don't show again Ne jelenjen meg újra + Double click layer mask launches select and mask A rétegmaszkra duplán kattintva megnyílik a Kijelölés és maszk + Down Le + Drop Shadow Vetett árnyék + Drop shadow Vetett árnyék + Duplicate Channel Csatorna kettőzése + Duplicate Layer Réteg kettőzése + Duplicate Layers Rétegek kettőzése + Edge Szél + Exclusion Kizárás + Exit Quick Mask Kilépés a gyorsmaszkból + Expand Kibontás + Expand Detection Area Észlelési terület bővítése + Expand group ({key}-click: all groups) Csoport kibontása ({key}+kattintás: minden csoport) + F cycles the views; hold X to see the image Az F lépteti a nézeteket; az X nyomva tartásával a kép látszik + File Format Fájlformátum + Fill Opacity Kitöltés átlátszatlansága + From Kezdőszín + Fuchsia Fukszia + Gaussian Gauss + General Blend Általános keverés + General Blending Általános keverés + Global Light Globális fény + Gloss Contour Csillogáskontúr + Glow Izzás + GPU backend GPU-háttérrendszer + Gradient Overlay Színátmenet-fedés + Gray Szürke + Green Zöld + Group Csoport + Half Round Félkör + Hard Light Kemény fény + Hard Mix Kemény keverés + Hide the layer's effects ({key}-click: all layers) Réteg effektusainak elrejtése ({key}+kattintás: minden réteg) + Horizontally Vízszintesen + Indigo Indigó + Information Információ + Inner Bevel Belső fazetta + Inner Glow Belső ragyogás + Inner Shadow Belső árnyék + Invalid delete command Érvénytelen törlési parancs + Invert Invertálás + Knockout Kiütés + Lab Lab + Last Document State Dokumentum utolsó állapota + Layer deleted, but confirmation preference could not be saved: {error} A réteg törölve, de a megerősítési beállítás nem menthető: {error} + Layer Knocks Out Drop Shadow A réteg kiüti a vetett árnyékot + layer mask rétegmaszk + Layer Mask Hides Effects A rétegmaszk elrejti az effektusokat + Lighten Világosítás + Lighter Color Világosabb szín + Linear Lineáris + Linear Burn Lineáris égetés + Linear Dodge Lineáris derítés + Linear Dodge (Add) Lineáris derítés (összeadás) + Linear Light Lineáris fény + Link Összekapcsolás + Link the mask to the layer Maszk összekapcsolása a réteggel + Link width and height Szélesség és magasság összekapcsolása + Link with Layer Összekapcsolás a réteggel + Load channel as selection Csatorna betöltése kijelölésként + Load Selection Kijelölés betöltése + Load… Betöltés… + Lock Layers Rétegek zárolása + Luminosity Fényesség + Magenta Magenta + Make Default Legyen alapértelmezett + Marching Ants Menetelő hangyák + Masked Areas Maszkolt területek + Masks Maszkok + Merge Channels… Csatornák egyesítése… + Merge Spot Channel Direktszín-csatorna egyesítése + Method Módszer + Minimum Minimum + Missing delete command Hiányzó törlési parancs + Mode Mód + Multiply Szorzás + Negation Negálás + New Channel Új csatorna + New Channel… Új csatorna… + New Spot Channel… Új direktszín-csatorna… + New Style… Új stílus… + Next Tovább + No Color Nincs szín + No document Nincs dokumentum + No Image Nincs kép + No selection Nincs kijelölés + Normal Normál + off ki + On Black Feketén + On Layers Rétegeken + On White Fehéren + Onion Skin Hagymahéj + Opacity Átlátszatlanság + Open Folder Mappa megnyitása + Operation Művelet + Outer Bevel Külső fazetta + Outer Glow Külső ragyogás + Overlay Átfedés + Overlay Color Átfedés színe + Overlay Opacity Átfedés átlátszatlansága + Paint over hair or fur on the image; Alt paints the other mode Fesd át a hajat vagy a szőrzetet a képen; Alt lenyomásával a másik módban festesz + Pass Through Áteresztés + Pattern Overlay Mintázatfedés + Pillow Emboss Párnás domborítás + Pin Light Pontfény + Pixel Pixel + Position Pozíció + Precise Pontos + Preserve Transparency Átlátszóság megőrzése + Quick Mask Gyorsmaszk + Radial Körkörös + Red Vörös + Refine Finomítás + Refine Edge Brush (R) Élfinomító ecset (R) + Refine Strokes Finomító ecsetvonások + Reflect Visszaverődés + Reflected Tükrözött + Rename Channel… Csatorna átnevezése… + Reorder Layer Réteg átrendezése + Reset to Default Visszaállítás alapértelmezettre + Restore Original Edge Eredeti él visszaállítása + Reverse Megfordítás + RGB: RGB: + Ring Gyűrű + Ring - Double Gyűrű – dupla + Rolling Slope - Descending Hullámos lejtő – csökkenő + Rounded Steps Lekerekített lépcsők + Satin Szatén + Save Selection Kijelölés mentése + Save selection as channel Kijelölés mentése csatornaként + Save… Mentés… + Sawtooth 1 Fűrészfog 1 + Scale Effects Effektusok méretezése + Screen Képernyő + Seafoam Tengerzöld + Select All Layers Összes réteg kijelölése + Select Bottom Layer Legalsó réteg kijelölése + Select Layer Above Felette lévő réteg kijelölése + Select Layer Below Alatta lévő réteg kijelölése + Select Top Layer Legfelső réteg kijelölése + Selected Areas Kijelölt területek + Shadow Árnyék + Shallow Sekély + Show Edge Él megjelenítése + Show Original Eredeti megjelenítése + Show the layer mask as an overlay Rétegmaszk megjelenítése átfedésként + Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg) + Skip Kihagy + Smart Filters Intelligens szűrők + Soft Light Lágy fény + Soften Lágyítás + Source Forrás + Split Channels Csatornák szétválasztása + Spot Direktszín + Spot Channel Direktszín-csatorna + Spot Color Direktszín + Spread Kiterjedés + Stroke Körvonal + Style {name} saved Stílus mentve: {name} + Subtract Kivonás + The document is closed A dokumentum be van zárva + The document or selected layers changed. Please request deletion again. A dokumentum vagy a kijelölt rétegek megváltoztak. Kérd újra a törlést. + This Layer: Ez a réteg: + To Végszín + Toggle visibility Láthatóság váltása + Transparency Shapes Layer Az átlátszóság alakítja a réteget + UI scale Felület méretezése + Underlying Layer: Alatta lévő réteg: + Unlink Szétkapcsolás + Unlink the mask from the layer Maszk szétkapcsolása a rétegtől + Up Fel + Update layer comp (visibility, position and appearance) Rétegkompozíció frissítése (láthatóság, pozíció és megjelenés) + Use Global Light Globális fény használata + vector mask vektormaszk + Vector Mask Hides Effects A vektormaszk elrejti az effektusokat + Vertically Függőlegesen + Visibility Láthatóság + Vivid Light Élénk fény + XOR XOR + Yellow Sárga + {verb} {mask} {layer} {verb}: {mask} ({layer}) +layerLabel No Color Nincs szín +layerLabel Red Piros +layerLabel Magenta Bíbor + Use Previous Layer to Create Clipping Mask Vágómaszk létrehozása az előző réteggel + Fill with {mode}-neutral color (white) Kitöltés {mode} módhoz semleges színnel (fehér) + Fill with {mode}-neutral color (black) Kitöltés {mode} módhoz semleges színnel (fekete) + Fill with {mode}-neutral color (50% gray) Kitöltés {mode} módhoz semleges színnel (50%-os szürke) + (No neutral color exists for {mode} mode.) ({mode} módhoz nincs semleges szín.) + +# Panels + (none) (nincs) + + Add variable + Változó hozzáadása + 30 frames @ 30 fps 30 képkocka, 30 fps + 32-bit Preview Options 32 bites előnézet beállításai + 50% Gray 50%-os szürke + 80% 80% + A save to {name} is still running; try again when it finishes Még tart egy mentés ide: {name}; próbáld újra, ha befejeződött + Add a layer style Rétegstílus hozzáadása + Add a mask (from the selection; {key} inverts) Maszk hozzáadása (a kijelölésből; {key}: invertálás) + Add an adjustment Korrekció hozzáadása + Add to selection ({key}) Hozzáadás a kijelöléshez ({key}) + Add vector mask Vektormaszk hozzáadása + Added swatch {name} Színminta hozzáadva: {name} + Adjustment visible Korrekció látható + Align and distribute Igazítás és elosztás + Align horizontal centers Igazítás a vízszintes középvonalakhoz + Align left edges Igazítás a bal szélekhez + Align right edges Igazítás a jobb szélekhez + Always use pressure for opacity Mindig nyomásfüggő átlátszatlanság + Always use pressure for size Mindig nyomásfüggő méret + Appearance Mode Megjelenési mód + Auto Erase Automatikus radírozás + Auto Hide Automatikus elrejtés + Auto-Select: Automatikus kijelölés: + Beep when done Hangjelzés befejezéskor + Brush Preset picker Ecset-előbeállítás-választó + Capture Rögzítés + Change Screen Mode (F) Képernyőmód váltása (F) + Click the first point or press {key} to close · Esc cancels Lezárás: kattintás az első pontra vagy {key} · Esc: megszakítás + Click the first point or press {key} to close · {del} removes a point · Esc cancels Lezárás: kattintás az első pontra vagy {key} · {del}: pont eltávolítása · Esc: megszakítás + Click to fill similar colours Kattintással kitöltheted a hasonló színeket + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kattintás: nagyítás · {key}+kattintás: kicsinyítés · húzás jobbra/balra: nagyítás/kicsinyítés + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kattints, majd haladj végig egy élen · kattintás: pont rögzítése · {alt}+kattintás: egyenes szakasz · dupla kattintás: lezárás + Clip to layer below Vágás az alatta lévő réteghez + Clipped Vágott + Clone source {n} ({key}-click with Clone Stamp to set) Klónozási forrás: {n} (beállítás: {key}+kattintás a klónozó bélyegzővel) + Close Tab Group Lapcsoport bezárása + Collapse Panel Group Panelcsoport összecsukása + Color & Swatches Szín és színminták + Conform Nyújtás + Create a new group Új csoport létrehozása + Create a new layer Új réteg létrehozása + Create new document from current state Új dokumentum az aktuális állapotból + Create new fill or adjustment layer Új kitöltő- vagy korrekciós réteg létrehozása + Create new gradient Új színátmenet létrehozása + Create new group Új csoport létrehozása + Create new pattern from the selection Új mintázat létrehozása a kijelölésből + Create new shape from the current path Új alakzat létrehozása az aktuális útvonalból + Create new snapshot Új pillanatkép létrehozása + Create new style from the selected layer Új stílus létrehozása a kijelölt rétegből + Create new swatch Új színminta létrehozása + Create new tool preset Új eszköz-előbeállítás létrehozása + Create Video Timeline Videó-idővonal létrehozása + Create Warped Text Torzított szöveg létrehozása + Current Tool Only Csak az aktuális eszköz + Curves Görbék + Dark Sötét + Data Set: {name} ({index}/{total}) Adatkészlet: {name} ({index}/{total}) + Data Sets Adatkészletek + Default colours (D) Alapértelmezett színek (D) + Define Megadás + Define at least one variable first. Előbb adj meg legalább egy változót. + Delete Cropped Pixels Levágott pixelek törlése + Delete current state Aktuális állapot törlése + Delete layer Réteg törlése + Delete Timeline Idővonal törlése + Delete Tool Preset Eszköz-előbeállítás törlése + Delete tool preset Eszköz-előbeállítás törlése + Delete Workspace Munkaterület törlése + Discord Discord + Doc: {w} × {h} px Dok.: {w} × {h} px + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Húzz egy keretet, vagy kattints a négy sarokra · húzd a sarkokat a ferde alakzat éleire · {commit}: véglegesítés · Esc: megszakítás + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Húzz egy körbevágási keretet · belül húzva mozgatod · az élekkel átméretezed ({ratio}: arány, {centre}: középről) · Space: mozgatás rajzolás közben · nyilak: léptetés · X: tájolás cseréje · {commit}: véglegesítés · Esc: megszakítás + Drag around the centre to rotate the view · Shift constrains to 15° A középpont körül húzva forgatod a nézetet · Shift: 15°-os lépések + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Húzz, vagy kattints sokszögpontokat · lasszózás közben Alt: egyenes szakaszok · {add}: hozzáadás · {sub} rajzolás előtt: kivonás + Drag to draw a gradient Húzással rajzolhatsz színátmenetet + Drag to move the active layer Húzással mozgathatod az aktív réteget + Drag to pan · hold Space with any tool Húzással mozgathatod a nézetet · bármely eszköznél: Space nyomva tartása + Duration Időtartam + Edit in Quick Mask Mode (Q) Szerkesztés gyorsmaszk módban (Q) + Edit in Standard Mode (Q) Szerkesztés normál módban (Q) + Edit Toolbar Eszköztár szerkesztése + Edit Toolbar… Eszköztár szerkesztése… + Enable airbrush-style build-up effects Festékszóró-szerű felhalmozódás bekapcsolása + Esc to cancel Esc: megszakítás + Expand Panel Group Panelcsoport kibontása + Export Swatches for Exchange… Színminták exportálása cseréhez… + Export Swatches… Színminták exportálása… + Exposure and Gamma Expozíció és gamma + Factor: Tényező: + Fill: Kitöltés: + Filter for adjustment layers Szűrés korrekciós rétegekre + Filter for pixel layers Szűrés pixelrétegekre + Filter for shape layers Szűrés alakzatrétegekre + Filter for smart objects Szűrés intelligens objektumokra + Filter for type layers Szűrés szövegrétegekre + Fit Igazítás + Fit Screen Képernyőhöz igazítás + Fixed Ratio Rögzített arány + Fixed Size Rögzített méret + Folder Mappa + Follows only edges this close to the pointer ([ and ] change it) Csak a mutatóhoz ennyire közeli éleket követi (a [ és a ] módosítja) + Foreground Előtérszín + Frame {current} / {total} Képkocka: {current} / {total} + Frequency Gyakoriság + Gamma: Gamma: + gradients színátmenetek + Group with Csoportosítás ezzel + Group · {n} layer Csoport · {n} réteg + Heart Szív + Hide Properties Tulajdonságok elrejtése + Higher values follow only edges that contrast sharply with their surroundings Nagyobb értéknél csak a környezetüktől élesen elütő éleket követi + Highlight Compression Csúcsfény-tömörítés + Hold Alt while drawing for straight segments Rajzolás közben az Alt nyomva tartásával egyenes szakaszokat húzhatsz + How often fastening points are placed by themselves Milyen gyakran kerülnek le automatikusan rögzítőpontok + Hsb HSB + image file path képfájl elérési útja + Import Brushes Ecsetek importálása + Import Swatches… Színminták importálása… + Imported Importált + Importing {what} from {file}… {what} importálása. Forrás: {file}… + Intersect with Metszet ezzel + Intersect with selection ({key}) Metszet a kijelöléssel ({key}) + Invalid panel layout; restored the default dock Érvénytelen panelelrendezés; az alapértelmezett dokkolás visszaállítva + Join the ArtCraft Discord ({url}) Csatlakozz az ArtCraft Discord-szerveréhez ({url}) + Keyboard Shortcuts Billentyűparancsok + Kind Fajta + Lasso Lasszó + Link layers Rétegek összekapcsolása + Lock Zárolás + Lock all Összes zárolása + Lock image pixels Képpixelek zárolása + Lock layer Réteg zárolása + Lock position Pozíció zárolása + Lock transparent pixels Átlátszó pixelek zárolása + Lock: Zárolás: + Marquee Kijelölőkeret + Menus Menük + Move Group Down Csoport mozgatása lefelé + Move Group Up Csoport mozgatása felfelé + Move to Áthelyezés ide + Name: Név: + New selection Új kijelölés + New Swatch Group… Új színmintacsoport… + New Swatch… Új színminta… + New Workspace Új munkaterület + Next frame Következő képkocka + No custom shape "{name}": pick one in the options bar or Window › Shapes Nincs ilyen egyéni alakzat: „{name}”; válassz egyet a beállítósávon vagy az Ablak › Alakzatok panelen + No presets for the current tool. Az aktuális eszközhöz nincs előbeállítás. + No properties Nincsenek tulajdonságok + No tool presets. Nincs eszköz-előbeállítás. + Open a document to define variables. Változók megadásához nyiss meg egy dokumentumot. + Open a document to use the timeline. Az idővonal használatához nyiss meg egy dokumentumot. + Opening Megnyitás + Opening {name} Megnyitás: {name} + Opening {name}… Megnyitás: {name}… + Panel Panel + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. A panelek helye a munkaterülettel együtt mentődik. A billentyűparancsok, a menük és az eszköztár mentése választható. + Panel menu Panelmenü + Patterns Mintázatok + Pause Szünet + Pixel Replacement Pixelcsere + Play Lejátszás + Pressure for Opacity Nyomásfüggő átlátszatlanság + Pressure for Size Nyomásfüggő méret + Prevent auto-nesting in and out of Artboards and Frames Automatikus be- és kiágyazás tiltása rajztábláknál és kereteknél + Previous frame Előző képkocka + Quick Actions Gyorsműveletek + Rasterize Fill Content Kitöltés tartalmának raszterizálása + Rasterize Smart Object Intelligens objektum raszterizálása + Reading… Olvasás… + Remember workspace changes Munkaterület-módosítások megjegyzése + Remove Background Háttér eltávolítása + Remove variable Változó eltávolítása + Rename Tool Preset Eszköz-előbeállítás átnevezése + Rename Tool Preset… Eszköz-előbeállítás átnevezése… + Rename… Átnevezés… + Replace Swatches… Színminták cseréje… + Reset Swatches Színminták visszaállítása + Reset Transform Alakítás visszaállítása + Resize panel window Panelablak átméretezése + Resize panels Panelek átméretezése + Sample Size Mintaméret + Save File Fájl mentése + Save Pixel Aspect Ratio Pixelarány mentése + Saving {name} Mentés: {name} + Scrubby Zoom Húzásos nagyítás + Search commands Parancsok keresése + Search commands, tools and panels Parancsok, eszközök és panelek keresése + Search commands, tools and panels… Parancsok, eszközök és panelek keresése… + Select Subject Fő téma kijelölése + Set additional smoothing options További simítási beállítások + Set background color Háttérszín beállítása + Set foreground color Előtérszín beállítása + Shape: Alakzat: + Shift Shift + Show Extras Options Segédelemek beállításai + Show Overlay Átfedés megjelenítése + Show Transform Controls Alakítási vezérlők megjelenítése + Size: Méret: + Source: not set ({key}-click with Clone Stamp) Forrás: nincs beállítva ({key}+kattintás a klónozó bélyegzővel) + Source: set Forrás: beállítva + Style Stílus + Subtract from selection ({key}) Kivonás a kijelölésből ({key}) + Swap colours (X) Színek cseréje (X) + System title bar Rendszer címsora + Text Replacement Szövegcsere + Thumbnail size (smallest: list) Bélyegképméret (a legkisebb: lista) + Tolerance Tűrés + Toolbar Eszköztár + Type Layer Szövegréteg + Ungroup Csoport bontása + Use tablet pressure to change pen width A tablet nyomása módosítja a toll szélességét + Variables bind a layer's visibility, text or pixels to a named data slot. A változók egy réteg láthatóságát, szövegét vagy pixeleit egy elnevezett adatmezőhöz kötik. + Visible Látható + Warning Figyelmeztetés + Working… Feldolgozás… + Workspaces Munkaterületek + Zoom Nagyító + {label} is writing the file and can no longer be cancelled {label}: a fájl írása folyamatban, már nem szakítható meg + {message} (notes: {n}; {warning}) {message} (megjegyzések: {n}; {warning}) + {mode} Color · {bits} bit Színmód: {mode} · {bits} bit + {name} Properties Tulajdonságok: {name} + ↑↓ navigate {key} run esc close ↑↓ navigálás {key} futtatás esc bezárás +navigator Zoom Nagyítás mértéke + +# Adjustments and filters + 3D LUT File 3D LUT-fájl + 90% 90% + Absolute Abszolút + Accented Edges Kiemelt élek + Adaptive Wide Angle isn't open Az Adaptív nagy látószög nincs megnyitva + Add Noise Zaj hozzáadása + Add Stop Átmenetpont hozzáadása + Add to Sample Hozzáadás a mintához + Adjust Interval For Loops Térköz igazítása hurkokhoz + Adjustment Preview Korrekció előnézete + Amount Mérték + Amplitude Max Legnagyobb amplitúdó + Amplitude Min Legkisebb amplitúdó + Angled Strokes Ferde vonások + Arbitrary Tetszőleges + Around Center Középpont körül + Artistic Művészi + Author: Szerző: + Auto Automatikus + Automatically level near-horizontal line segments A közel vízszintes szakaszok automatikus vízszintesre állítása + Automatically straighten and level Automatikus függőlegesre és vízszintesre állítás + Automatically straighten near-vertical line segments A közel függőleges szakaszok automatikus függőlegesre állítása + Background Level Háttérszint + Bamboo Bambusz + Bas Relief Dombormű + Base Tree Type Fa alaptípusa + Berries Bogyók + Best Legjobb + Black Clip Fekete levágása + Black Intensity Fekete intenzitása + Black Matte Fekete háttér + Blacks Feketék + Blend If Keverés, ha + Blocks Tömbök + Blue Yellow Kék–sárga + Blues Kékek + Blur Angle Elmosás szöge + Border Thickness Szegély vastagsága + Both Mindkettő + Bottom Alul + Box Blur Dobozelmosás + Branches Color Ágak színe + Branches Height Ágak magassága + Branches Thickness Ágak vastagsága + Brick Tégla + Brightness Fényerő + Brightness/Contrast Fényerő/kontraszt + Bristle Detail Sörték részletessége + Brush Detail Ecset részletessége + Brush Reconstruct Options Ecsetes helyreállítás beállításai + Brush Size Ecsetméret + Brush Strokes Ecsetvonások + Brush Tool Options Ecset beállításai + Brush Type Ecsettípus + Burlap Zsákvászon + Camera Calibration Fényképezőgép-kalibráció + Cancel (Esc) Mégse (Esc) + Candle Light Gyertyaláng + Cell Size Cellaméret + Center X Középpont X + Center Y Középpont Y + Chalk & Charcoal Kréta és szén + Chalk Area Krétás terület + Channel Mixer Csatornakeverő + Channel: Csatorna: + Charcoal Szénrajz + Charcoal Area Szenes terület + Charcoal Thickness Szén vastagsága + Chrome Króm + Clear All Összes törlése + Clear Amount Tiszta terület mértéke + Clear Samples Minták törlése + Click to add a point · drag off or {key}-click or Delete to remove Kattintással pontot adhatsz hozzá · eltávolítás: lehúzás, {key}+kattintás vagy Delete + Click to place · Alt-click or drag off-canvas to remove Kattintással elhelyezheted · eltávolítás: Alt+kattintás vagy a vásznon kívülre húzás + Click with the Note tool to add a note. Jegyzet hozzáadásához kattints a Jegyzet eszközzel. + Clumped Csomós + Coarse Dots Durva pontok + Color Balance Színegyensúly + Color Halftone Színes féltónus + Color Lookup Színleképezés + Color Range preview Színtartomány előnézete + Colored Pencil Színes ceruza + Colorize Színezés + Colors Színek + Commit Perspective Warp ({key}) Perspektivikus torzítás véglegesítése ({key}) + Constant Állandó + Constraints ({n}) Kényszerek ({n}) + Continue Folytat + Contrast Kontraszt + Contrasty Kontrasztos + Conté Crayon Conté-kréta + Cooling L B B Hűtőszűrő (LBB) + Cooling80 Hűtőszűrő (80) + Cooling82 Hűtőszűrő (82) + Correction Korrekció + Could not use Perspective Warp: the layer is empty A perspektivikus torzítás nem használható: a réteg üres + Count: {n} Darabszám: {n} + Crack Brightness Repedések fényereje + Crack Depth Repedések mélysége + Crack Spacing Repedések térköze + Craquelure Krakelé + Create a new count group Új számlálócsoport létrehozása + Crop Factor Szűkítési tényező + Crosshatch Keresztsraffozás + Crystallize Kristályosítás + Curve Splits Görbe osztáspontjai + Custom Branch Color Egyéni ágszín + Cutout Papírkivágás + Cyan Cián + Cyan · Red Cián · Vörös + Cyans Ciánok + Daisies Margaréták + Dark Intensity Sötét intenzitása + Dark Rough Sötét, durva + Dark Strokes Sötét vonások + Darken Only Csak sötétítés + Darkness Sötétség + De-Interlace Váltott soros megjelenítés megszüntetése + Deep Blue Mélykék + Deep Emerald Mély smaragd + Deep Red Mélyvörös + Deep Yellow Mélysárga + Default Alapértelmezett + Default Leaves Alapértelmezett levelek + Definition Határozottság + Delete count group Számlálócsoport törlése + Delete effect layer Effektusréteg törlése + Delete note Jegyzet törlése + Delete selected measurements Kijelölt mérések törlése + Density Sűrűség + Depth Map Mélységtérkép + Deselect All Összes kijelölés megszüntetése + Diffuse Diffúzió + Diffuse Glow Szórt ragyogás + Direction Balance Irányegyensúly + Displace Elmozdítás + Display Text Szöveg megjelenítése + Distortion Torzítás + Dither Árnyalatkeverés + Double Line Kettős vonal + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Húzással kényszert adhatsz a képhez; Shift: vízszintes/függőleges; jobb kattintás: törlés. + Dry Brush Száraz ecset + Dust & Scratches Por és karcolások + Dynamic color sliders Dinamikus színcsúszkák + Edge Brightness Élek fényereje + Edge Fidelity Élhűség + Edge Intensity Élek intenzitása + Edge Only Csak élek + Edge Simplicity Élek egyszerűsége + Edge Thickness Élvastagság + Edge Width Élszélesség + Emboss Domborítás + Enlarged Nagyított + Export selected measurements (CSV) Kijelölt mérések exportálása (CSV) + Exposure Expozíció + Extrude Kihúzás + Eyedropper Pipetta + Eyedroppers: Pipetták: + Fast Gyors + Fiber Length Szálhossz + Fibers Szálak + Field Blur Mezőelmosás + Film Grain Filmszemcse + Filter Gallery Szűrőgaléria + Filter: Szűrő: + Fine Dots Finom pontok + Fisheye Halszem + Fit in View Igazítás a nézethez + Flame Bottom Alignment Lángalap igazítása + Flame Lines Lángvonalak + Flame Shape Lángalak + Flame Style Lángstílus + Flame Type Lángtípus + Flat Shading Lapos árnyalás + Flower Color Virágszín + Focal Distance Fókusztávolság + Focal Length Gyújtótávolság + Font Size: Betűméret: + Foreground Level Előtérszint + Free Szabad + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Fagyasztólasszó: húzással fagyaszthatsz egy területet, Alt+húzással feloldhatod (L) + Fresco Freskó + From Right Jobbról + Frosted Mattüveg + Full Spherical Teljes gömb + Fuzziness Elmosódottság + Fuzziness: Elmosódottság: + Gamma Gamma + Gamma Correction Gammakorrekció + Gaussian Blur Gauss-elmosás + Glass Üveg + Glow Amount Ragyogás mértéke + Glow Brightness Ragyogás fényereje + Glow Color Ragyogás színe + Glow Size Ragyogás mérete + Glowing Edges Izzó élek + Gradient Map Színátmenet-leképezés + Grain Szemcse + Grain Type Szemcsetípus + Graininess Szemcsésség + Grainy Dots Szemcsés pontok + Graphic Pen Grafikus toll + Greens Zöldek + Grid size Rácsméret + Grout Width Fugaszélesség + Halftone Pattern Féltónusmintázat + HDR Toning HDR-tónusozás + Hearts Szívek + High Pass Felüláteresztő + Highlight Csúcsfény + Highlight Amount Csúcsfény mértéke + Highlight Area Csúcsfény-terület + Highlight Radius Csúcsfény sugara + Highlight Strength Csúcsfény erőssége + Highlight Tone Csúcsfény tónusa + Highlights Csúcsfények + Highlights from: Csúcsfények ettől: + Horizontal Only Csak vízszintes + Hue Színárnyalat + Hue/Saturation Színárnyalat/telítettség + Image Balance Képegyensúly + In Black Bemeneti fekete + In White Bemeneti fehér + Ink Outlines Tintakörvonalak + Input: Bemenet: + Intensity Intenzitás + Invert All Összes invertálása + Invert has no settings. Az Invertálásnak nincsenek beállításai. + Invert Mask Maszk invertálása + Iris Blur Íriszelmosás + ISO ISO + Ivy Borostyán + Label Size Címkeméret + Layout Elrendezés + Leaf Color Levélszín + Leaves Amount Levelek mennyisége + Leaves Color Levelek színe + Leaves Size Levélméret + Left Diagonal Balra átlós + Legacy Régi + Length: Hossz: + Lens Blur Objektívelmosás + Lens Flare Lencsecsillanás + Less Kevesebb + Level Szint + Level Based Szintalapú + Levels Szintek + Light Dark Balance Világos–sötét egyensúly + Light Direction Fény iránya + Light Intensity Fényintenzitás + Light Rough Világos, durva + Light Source Fényforrás + Lighten Grout Fuga világosítása + Lighten Only Csak világosítás + Lighting Effects Fényhatások + Lightness Világosság + Liquify is not open A Képlékenyítés nincs megnyitva + Load 3D LUT 3D LUT betöltése + Load 3D LUT… 3D LUT betöltése… + Localized Color Clusters Helyi színcsoportok + Logical Length: Logikai hossz: + Logical Units: Logikai egységek: + Long Lines Hosszú vonalak + Long Strokes Hosszú vonások + Lut LUT + Mac OS Mac OS + Magenta · Green Magenta · Zöld + Magentas Magenták + Map Document Térkép-dokumentum + Marker Size Jelölőméret + Mask All Összes maszkolása + Mask Options Maszkbeállítások + Master Összes szín + Match Color Színillesztés + Max Offset Legnagyobb eltolás + Max Radius Legnagyobb sugár + Measure Mérés + Measurement Scale Mérési méretarány + Measurement Scale Marker Méretarány-jelölő + Medium Dots Közepes pontok + Medium Lines Közepes vonalak + Medium Strokes Közepes vonások + Mesh Size Hálóméret + Mezzotint Mezzotinto + Midtone Középtónus + Midtones Középtónusok + Midtones from: Középtónusok ettől: + Midtones to: Középtónusok eddig: + Monochrome Monokróm + More Több + Mosaic Mozaik + Mosaic Tiles Mozaiklapok + Motion Blur Mozgási elmosás + Move effect layer down Effektusréteg mozgatása lefelé + Move effect layer up Effektusréteg mozgatása felfelé + Movie Prime Filmes fix objektív + Multiple Flames Along Path Több láng az útvonal mentén + Multiple Flames One Direction Több láng egy irányba + Multiple Flames Path Directions Több láng az útvonal irányaiba + Multiple Flames Various Length Több láng eltérő hosszal + Neon Glow Neonragyogás + Neutralize Semlegesítés + Neutrals Semlegesek + New effect layer Új effektusréteg + Next note Következő jegyzet + No author Nincs szerző + No document open Nincs megnyitott dokumentum + None Nincs + Note Paper Jegyzetpapír + Number Of Levels Szintek száma + Ocean Ripple Óceánfodrozódás + Offset Eltolás + Oil Paint Olajfestmény + One Flame Along Path Egy láng az útvonal mentén + Opacity Threshold Átlátszatlansági küszöb + Open Megnyitás + Open a document to select a colour range. Színtartomány kijelöléséhez nyiss meg egy dokumentumot. + Orange Narancs + Out Black Kimeneti fekete + Out From Center Középponttól kifelé + Out Of Gamut Színtartományon kívüli + Out White Kimeneti fehér + Output Channel: Kimeneti csatorna: + Output Levels Kimeneti szintek + Output Levels: Kimeneti szintek: + Output: Kimenet: + Oval Ovális + Overlay Edge Élátfedés + Paint Daubs Festékpacák + Palette Knife Festőkés + Paper Brightness Papír fényereje + Parallel Párhuzamos + Patchwork Foltvarrás + Path Blur Útvonal-elmosás + Pattern Type Mintázattípus + Pencil Width Ceruzaszélesség + Perspective Warp is not active A perspektivikus torzítás nem aktív + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivikus torzítás: rajzolj síkokat a kép perspektívája mentén, majd válts Hajlítás módra + Photo Filter Fotószűrő + Photocopy Fénymásolat + Picture Frame Képkeret + Pinch Becsípés + Pixel Length: Pixelhossz: + Plaster Gipsz + Plastic Wrap Műanyag fólia + Point Curve Pontgörbe + Point Curve Blue Pontgörbe, kék + Point Curve Green Pontgörbe, zöld + Point Curve Red Pontgörbe, vörös + Pointed Hegyes + Pointillize Pontozás + Polar Coordinates Polárkoordináták + Polar To Rectangular Polárisból derékszögűbe + Pond Ripples Tavi fodrozódás + Poster Edges Poszterélek + Posterization Poszterizáció + Posterize Poszterizálás + Preserve Luminosity Fényesség megőrzése + Preset: Előbeállítás: + Presets: Előbeállítások: + Pressure Nyomás + Preview Előnézet + Previous note Előző jegyzet + Pro photo ProPhoto + Processing… Feldolgozás… + ProPhoto ProPhoto + ProPhoto RGB ProPhoto RGB + Protect Tones Tónusok védelme + Radial Blur Sugárirányú elmosás + Radius X X sugár + Radius Y Y sugár + Randomize Length Véletlenszerű hossz + Randomize Shapes Véletlenszerű alakok + Range: Tartomány: + Rate Ütem + Ready Készen áll + Reconstruct Helyreállítás + Rectangular To Polar Derékszögűből polárisba + Red Cyan Vörös–cián + Reds Vörösek + Reduce Color Noise Színzaj csökkentése + Reduce Noise Zajcsökkentés + Relative Relatív + Relief Domborulat + Remove Jpeg Artifact JPEG-hibák eltávolítása + Remove Planes Síkok eltávolítása + Remove stop Átmenetpont eltávolítása + Replace Color Színcsere + Reset to defaults Alapértékek visszaállítása + Restore All Összes visszaállítása + Resume Folytatás + Reticulation Retikuláció + RGB RGB + Right Diagonal Jobbra átlós + Ripple Fodrozódás + Ripple Magnitude Fodrozódás nagysága + Ripple Size Fodrozódás mérete + Rope Kötél + Roses Rózsák + Rough Pastels Durva pasztell + Ruler Vonalzó + Sample: foreground colour Minta: előtérszín + Sampled Colors Mintavételezett színek + Sandstone Homokkő + Saturation Telítettség + Scaling Méretezés + Scallops Csipkés szél + Select and Mask Kijelölés és maszk + Select Data Points Adatpontok kiválasztása + Select: Kijelölés: + Selection Kijelölés + Selection Preview: Kijelölés előnézete: + Selections Kijelölések + Selective Color Szelektív szín + Sepia Szépia + Set Black Point Feketepont beállítása + Set Neutral Gray Point Semleges szürkepont beállítása + Set White Point Fehérpont beállítása + Shadow Amount Árnyék mértéke + Shadow Intensity Árnyék intenzitása + Shadow Radius Árnyék sugara + Shadow Tone Árnyék tónusa + Shadows Árnyékok + Shadows up to: Árnyékok eddig: + Shadows/Highlights Árnyékok/csúcsfények + Shape Blur Alakzatos elmosás + Sharpen Details Részletek élesítése + Sharpen Radius Élesítési sugár + Sharpness Élesség + Shear Nyírás + Short Lines Rövid vonalak + Short Strokes Rövid vonások + Show Backdrop Háttér megjelenítése + Show Mask Maszk megjelenítése + Show Mesh Háló megjelenítése + Show or hide the Notes panel A Jegyzetek panel megjelenítése vagy elrejtése + Sketch Vázlat + Smart Blur Intelligens elmosás + Smart Sharpen Intelligens élesítés + Smoothness Simaság + Smudge Stick Maszatolórúd + Snowflakes Hópelyhek + Source Layer Forrásréteg + Spatter Fröcskölés + Speckle Pöttyös + Speed Sebesség + Spherize Gömbösítés + Spin Blur Pörgetéses elmosás + Sponge Szivacs + Spot: Spotlámpa: + Spray Radius Szórási sugár + Sprayed Strokes Szórt vonások + Sprinkles Szórt szemcsék + Square Size Négyzetméret + sRGB sRGB + Stained Glass Ólomüveg + Stamp Bélyegző + Stars Csillagok + Stippled Pontozott + Stops Átmenetpontok + Straighten Kiegyenesítés + Straighten Layer Réteg kiegyenesítése + Stroke Detail Vonás részletessége + Stroke Direction Vonás iránya + Stroke Length Vonáshossz + Stroke Pressure Vonásnyomás + Stroke Size Vonásméret + Strokes Vonások + Subtract from Kivonás ebből + Subtract from Sample Kivonás a mintából + Sumi-e Sumi-e + Surface Blur Felületi elmosás + Target X Cél X + Target Y Cél Y + Tetrahedral Tetraéderes + Text Position: Szöveg helye: + Texture Textúra + Texture Coverage Textúrafedettség + Texturizer Textúrázó + Thickness Vastagság + This adjustment ({key}) is kept as imported and can't be edited yet. Ez a korrekció ({key}) importált állapotában marad, és egyelőre nem szerkeszthető. + Threshold Küszöb + Threshold Level Küszöbszint + Tile Size Csempeméret + Tiles Csempék + Tilt Dőlés + Tilt-Shift Tilt-shift + Timing Időzítés + Tint Color Színezés színe + Tiny Lens Apró lencsék + Toggle count group visibility Számlálócsoport láthatóságának váltása + Tone Curve Tónusgörbe + Tone: Tónus: + Top Felül + Torn Edges Tépett szélek + Total: Összesen: + Trace Contour Kontúrkövetés + Transparency Threshold Átlátszósági küszöb + Trilinear Trilineáris + Twirl Örvény + Unavailable in this color mode Ebben a színmódban nem érhető el + Undefined Areas Nem definiált területek + Underpainting Alapozó festés + Underwater Víz alatti + Unsharp Mask Életlenmaszk + Use Legacy Régi módszer használata + Use Measurement Scale Mérési méretarány használata + Use Selection In Source Kijelölés használata a forrásban + Use Selection In Target Kijelölés használata a célban + Uses the foreground and background colours. Az előtér- és a háttérszínt használja. + Vertical Only Csak függőleges + Vibrance Élénkség + View Options Nézetbeállítások + Vignette Amount Vignetta mértéke + Vignette Highlights Vignetta csúcsfényei + Vignette Midpoint Vignetta középpontja + Vignette Style Vignetta stílusa + Vine Color Inda színe + Vine With Flowers Inda virágokkal + Vine With Leaves Inda levelekkel + Violent Heves + Violet Ibolya + Warming L B A Melegítőszűrő (LBA) + Warming81 Melegítőszűrő (81) + Warming85 Melegítőszűrő (85) + Water Paper Vizes papír + Watercolor Akvarell + Wave Hullám + Wavelength Max Legnagyobb hullámhossz + Wavelength Min Legkisebb hullámhossz + Waves Hullámok + White Clip Fehér levágása + White Intensity Fehér intenzitása + White Is High Fehér a magas + White Matte Fehér háttér + Whites Fehérek + Wide Blurry Széles, elmosódott + Wide Sharp Széles, éles + Wind Szél + Yellow · Blue Sárga · Kék + Yellows Sárgák + Zigzag Cikcakk + ZigZag Cikcakk + Zoom in Nagyítás + Zoom out Kicsinyítés + {added}, {removed} subtracted {added}, {removed} kivonva + {index} of {total} {index} / {total} + {n} of {max} sample points {n} / {max} mintavételi pont + +# Camera Raw and camera data + After Utána + Aqua Türkiz + Balance Egyensúly + Before (Y) Előtte (Y) + Blending Keverés + Camera Raw Camera Raw + Camera Raw ({name}) Camera Raw ({name}) + Camera Raw Filter Camera Raw szűrő + Camera Raw Filter ({layer}) Camera Raw szűrő ({layer}) + Camera Raw isn't open A Camera Raw nincs megnyitva + Cancelled opening {name} Megnyitás megszakítva: {name} + Cancelled {label} Megszakítva: {label} + Clarity Tisztaság + Color Detail Színrészletesség + Color Grading Színtónusozás + Color Mixer Színkeverő + Color Priority Színprioritás + Curve Görbe + Curve Darks Görbe: sötét tónusok + Curve Highlights Görbe: csúcsfények + Curve Lights Görbe: világos tónusok + Curve Shadows Görbe: árnyékok + Darks Sötét tónusok + Dehaze Párátlanítás + Detail Részletesség + Developing {name} Előhívás: {name} + Feather Elmosott szél + Full-resolution preview unavailable A teljes felbontású előnézet nem érhető el + Global Globális + Grade Balance Színtónusozás: egyensúly + Grade Blending Színtónusozás: keverés + Grade Global Színtónusozás: globális + Grade Highlights Színtónusozás: csúcsfények + Grade Midtones Színtónusozás: középtónusok + Grade Shadows Színtónusozás: árnyékok + Grain Amount Szemcse mértéke + Grain Roughness Szemcse érdessége + Grain Size Szemcseméret + Hand Tool Kéz + Highlight Clipping Warning (O) Figyelmeztetés csúcsfény-levágásra (O) + Highlight Priority Csúcsfény-prioritás + Hsl Hue HSL-színárnyalat + Hsl Lum HSL-luminancia + Hsl Sat HSL-telítettség + Lights Világos tónusok + Lum Lum. + Luminance Luminancia + Luminance Detail Luminancia-részletesség + Masking Maszkolás + Midpoint Középpont + Noise Color Zajcsökkentés: szín + Noise Color Detail Zajcsökkentés: színrészletesség + Noise Luminance Zajcsökkentés: luminancia + Noise Luminance Detail Zajcsökkentés: luminancia-részletesség + Noise Reduction Zajcsökkentés + Open in camera raw Megnyitás a Camera Raw-ban + Paint Overlay Ráfestés + Previous Előző + Purple Lila + Radius Sugár + Reading Beolvasás + Roughness Érdesség + Roundness Kerekség + Sat Tel. + Scope Hatókör + Shadow Clipping Warning (U) Figyelmeztetés árnyéklevágásra (U) + Sharpen Amount Élesítés mértéke + Sharpen Detail Élesítés részletessége + Sharpen Masking Élesítés maszkolása + Sharpening Élesítés + Show Lab Color Readouts Lab-színértékek megjelenítése + Show Red at 3 o'clock Vörös a 3 óra irányában + Show Selected Region Kijelölt terület megjelenítése + Show Skin Tone Indicator Bőrtónusjelző megjelenítése + Show Vectorscope Vektorszkóp megjelenítése + Temperature Színhőmérséklet + Tint Színezet + Tone Histogram Tónushisztogram + Vignette Feather Vignetta elmosott széle + Vignette Roundness Vignetta kereksége + Vignetting Vignettálás + White Balance: As Shot Fehéregyensúly: felvétel szerint + Zoom Tool Nagyító +cameraData Date Taken Készítés ideje +cameraData Exposure Expozíciós idő +cameraData F-Stop Rekesz +cameraData Focal Length Gyújtótávolság +cameraData Focal Length (35 mm) Gyújtótávolság (35 mm) +cameraData ISO Speed ISO-érzékenység +cameraData Lens Objektív +cameraData Make Gyártó +cameraData Model Típus +cameraRaw Amount Mérték +cameraRaw Aqua Türkiz +cameraRaw Blacks Feketék +cameraRaw Blending Keverés +cameraRaw Blue Kék +cameraRaw Color Szín +cameraRaw Color Detail Színrészletesség +cameraRaw Color Grading Színtónusozás +cameraRaw Darks Sötét tónusok +cameraRaw Global Globális +cameraRaw Green Zöld +cameraRaw Hue Színárnyalat +cameraRaw Light Fény +cameraRaw Lights Világos tónusok +cameraRaw Luminance Luminancia +cameraRaw Luminance Detail Luminancia-részletesség +cameraRaw Magenta Bíbor +cameraRaw Masking Maszkolás +cameraRaw Orange Narancs +cameraRaw Purple Lila +cameraRaw Red Vörös +cameraRaw Roughness Érdesség +cameraRaw Temperature Színhőmérséklet +cameraRaw Tint Színezet +cameraRaw Vibrance Élénkség +cameraRaw Whites Fehérek +cameraRaw Yellow Sárga + +# Dialogs, preferences and file formats + % of all commits Commitok aránya (%) + (print to the printer) (nyomtatás a nyomtatón) + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. A 0 kikapcsolja ezt a küszöböt; a 100 szintnél mélyebben egymásba ágyazott SVG-csoportok mindig raszterizálódnak. + 12 Columns 12 oszlop + 125% 125% + 150% 150% + 16 bit 16 bites + 175% 175% + 24 bits/pixel 24 bit/pixel + 300% 300% + 32 bit (float) 32 bites (lebegőpontos) + 32 bits/pixel 32 bit/pixel + 3D 3D + 5 Columns, 3 Rows 5 oszlop, 3 sor + 75% 75% + 8 bit 8 bites + 8 Columns 8 oszlop + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commit, +{added} / −{deleted} sor (Δ {delta}), +{binAdded} / −{binDeleted} bináris elem, {first} – {last} + About Névjegy + Absolute Colorimetric Abszolút kolorimetrikus + Action Művelet + Active Aktív + Adaptive Adaptív + Add disk Lemez hozzáadása + Add to favorites Hozzáadás a kedvencekhez + Add to Swatches Hozzáadás a színmintákhoz + Adobe Adobe + Adobe RGB (1998) Adobe RGB (1998) + Adobe RGB (1998) compatible Adobe RGB (1998)-kompatibilis + Advanced Speciális + Adwaita dark Adwaita sötét + All Mind + All shortcuts reset to PhotoCraft defaults. Minden billentyűparancs visszaállt a PhotoCraft alapértékére. + All Slices Összes szelet + All User Slices Összes felhasználói szelet + Always create smart objects when placing Elhelyezéskor mindig intelligens objektum létrehozása + Answer the unsaved-changes prompt first Előbb válaszolj a nem mentett módosításokra vonatkozó kérdésre + Appearance Settings… Megjelenési beállítások… + Apple Watch 45mm Apple Watch 45 mm + Applies at next launch. A következő indításkor lép életbe. + Art & Illustration Művészet és illusztráció + Artist Alkotó + Ask before saving layered tiff Kérdés rétegzett TIFF mentése előtt + Assign working space Munkaszíntér hozzárendelése + Auto Hide Notifications Értesítések automatikus elrejtése + Automatic (recommended) Automatikus (ajánlott) + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Az automatikus mód GPU-gyorsítást használ, ha elérhető, hiba esetén pedig CPU-s renderelésre vált. + Autosave Automatikus mentés + Autosave failed: {error} Az automatikus mentés nem sikerült: {error} + Autosave minutes Automatikus mentés gyakorisága (perc) + Background Color Háttérszín + Background Contents Háttér tartalma + Batch Kötegelt feldolgozás + Binary assets added Hozzáadott bináris elemek + Binary assets removed Eltávolított bináris elemek + Bit depth Bitmélység + Black Point Compensation Feketepont-kompenzáció + BLANK DOCUMENT PRESETS ({n}) ÜRES DOKUMENTUMOK ELŐBEÁLLÍTÁSAI ({n}) + Bleach Bypass Kihagyott fehérítés + Bottom: Alsó: + Breeze dark Breeze sötét + Breeze light Breeze világos + Cancel Mégse + Canvas color Vászonszín + Canvas custom color Egyéni vászonszín + Caption Képaláírás + Center Columns Oszlopok középre igazítása + Center Crop Marks Középső vágójelek + Centimeters Centiméter + Clear Kiürít + Clear Existing Guides Meglévő segédvonalak törlése + Click to restore the current colour Kattints a jelenlegi szín visszaállításához + Cmd Cmd + CMYK Color CMYK-szín + Coated CMYK Mázolt papíros CMYK + Color Adaptation Színadaptáció + Color Management Színkezelés + Color Mode Színmód + Color Picker (Background Color) Színválasztó (háttérszín) + Color Picker (Fill Color) Színválasztó (kitöltőszín) + Color Picker (Foreground Color) Színválasztó (előtérszín) + Color Picker ({name}) Színválasztó ({name}) + Color Profile Színprofil + Color space Színtér + Color… Szín… + Column width Oszlopszélesség + Columns Oszlopok + Comic Book Képregény + Command Parancs + command or shortcut parancs vagy billentyűparancs + Commits Commitok + Company Cég + Compare Összehasonlítás + Compatibility Kompatibilitás + Compression Tömörítés + Conditional Mode Change Feltételes módváltás + Contact Sheet II Kontaktlap II + Content-Aware Fill needs a selection on a pixel layer A tartalomérzékeny kitöltéshez kijelölés kell egy pixelrétegen + Contents: Tartalom: + Contributors Közreműködők + Contributors: {contributors} · Commits: {commits} Közreműködők: {contributors} · Commitok: {commits} + Convert document's colors to the working space A dokumentum színeinek átalakítása a munkaszíntérbe + Convert to sRGB Átalakítás sRGB-re + Cool Hideg + Copies Példányszám + Copyright Szerzői jog + Copyright and Contact Info Szerzői jog és elérhetőség + Copyrighted Szerzői jog által védett + Corner Crop Marks Sarokvágójelek + Couldn't open {name}: {error} A fájl nem nyitható meg: {name} ({error}) + Couldn't place {name}: {error} A fájl nem helyezhető el: {name} ({error}) + Couldn't save preferences: {error}. Retrying… A beállítások nem menthetők: {error}. Újrapróbálkozás… + CPU (no GPU acceleration) CPU (GPU-gyorsítás nélkül) + CPU / Compatibility CPU / kompatibilitás + Create Létrehoz + Create Droplet Droplet létrehozása + Cross Process Kereszthívás + Ctrl Ctrl + Custom Pattern: Egyéni mintázat: + Custom Shapes Egyéni alakzatok + Dark Theme Sötét téma + Data Adatok + Date Dátum + Day For Night Nappali éjszaka + Default PhotoCraft Size Alapértelmezett PhotoCraft-méret + Default printer Alapértelmezett nyomtató + Default restored. Alapértelmezés visszaállítva. + Defaults Alapértékek + Delete Törlés + Delete Shortcut Billentyűparancs törlése + Description Leírás + Diffusion Diffúzió + DirectX 12 DirectX 12 + Discard Layers and Save a Copy Rétegek elvetése és másolat mentése + Discard the embedded profile (don't color manage) Beágyazott profil elvetése (nincs színkezelés) + Disks Lemezek + Display name Megjelenített név + Display P3 Display P3 + Display p3 Display P3 + Do you want to save the changes you made to “{name}” before closing? Mented a módosításokat bezárás előtt? Dokumentum: „{name}” + Do you want to save the changes you made to “{name}” before quitting? Mented a módosításokat kilépés előtt? Dokumentum: „{name}” + Document Dokumentum + Don't Import Importálás nélkül + Don't Save Mentés nélkül + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Az importálást a Szerkesztés › Billentyűparancsok › Összes visszaállítása alapértékre vonja vissza. + Embed Color Profile Színprofil beágyazása + Embedded Profile Mismatch Eltérő beágyazott profil + Emulsion Down Emulziós oldal lefelé + Error Hiba + Exact Pontos + EXIF EXIF + Export As Exportálás másként + Export Options Exportálás beállításai + Export Preferences Beállítások exportálása + Export Presets Előbeállítások exportálása + Export Settings Exportálási beállítások + Export/Import Presets Előbeállítások exportálása/importálása + Exported presets to {path} Előbeállítások exportálva. Cél: {path} + Exported {path} Exportálva: {path} + Exported {path} ({size}) Exportálva: {path} ({size}) + Extensions Kiterjesztések + Faded Film Fakult film + File Info Fájladatok + File Name Fájlnév + File path Fájl elérési útja + File Settings Fájlbeállítások + Fill color Kitöltőszín + Film & Video Film és videó + Finish Befejezés + First Első + First commit Első commit + Fit Image Kép méretre igazítása + Floating point Lebegőpontos + Focal Length35mm Gyújtótávolság (35 mm) + Foreground Color Előtérszín + Format Formátum + Functions Funkciók + Gamut warning color Színtartomány-figyelmeztetés színe + Gamut warning opacity Színtartomány-figyelmeztetés átlátszatlansága + Gif GIF + GPU GPU + GPU Backend GPU-háttérrendszer + Grab bag Felhő + Graphics Grafika + Gray Gamma 2.2 Szürke, gamma 2.2 + Grayscale Szürkeárnyalatos + Guide color Segédvonal színe + Guide Layout… Segédvonal-elrendezés… + Gutter Hasábköz + Image previews Képelőnézetek + Image Size Képméret + Import brushes (.abr) Ecsetek importálása (.abr) + Import keyboard shortcuts? Importálod a billentyűparancsokat? + Import Presets Előbeállítások importálása + Import Shortcuts… Billentyűparancsok importálása… + Imported {n} shortcuts from {file}. Click OK to keep them. {n} billentyűparancs importálva. Forrás: {file}. A megtartásukhoz kattints az OK gombra. + Inches Hüvelyk + Including layers will increase file size. A rétegek mentése növeli a fájlméretet. + Integer Egész + Interlaced Váltott soros + Invalid guide layout Érvénytelen segédvonal-elrendezés + ISO Speed ISO-érzékenység + Jpeg quality JPEG-minőség + Jpg JPG + Keyboard Shortcuts and Menus Billentyűparancsok és menük + Keyboard shortcuts imported Billentyűparancsok importálva + Keywords Kulcsszavak + Lab Color Lab-szín + Labels Címkék + Landscape Fekvő + Landscape, 10 x 8 Fekvő, 10 x 8 + Landscape, 6 x 4 Fekvő, 6 x 4 + Landscape, 7 x 5 Fekvő, 7 x 5 + Last Utolsó + Last commit Utolsó commit + Leave as is (don't color manage) Változatlanul hagyás (nincs színkezelés) + Leave empty for automatic size Automatikus mérethez hagyd üresen + Left Bal + Left: Bal: + Legal Legal + Lens Objektív + Light Theme Világos téma + Line delta Sorváltozás + Lines +/− Sorok +/− + Lines added Hozzáadott sorok + Lines deleted Törölt sorok + Load a keyboard shortcut set saved as .kys .kys formátumban mentett billentyűparancs-készlet betöltése + Lossless Veszteségmentes + Lowercase extension Kisbetűs kiterjesztés + Make Gyártó + Margin Margó + Matte Alapszín + Merged PRs Beolvasztott PR-ek + Metadata Metaadatok + Metal Metal + Millimeters Milliméter + Mobile Mobil + Model Modell + Models Modellek + modified módosítva + Mono Contrast Kontrasztos monokróm + Name (A–Z) Név (A–Z) + New Document Új dokumentum + New Guide Layout Új segédvonal-elrendezés + No Nem + No Color Management Nincs színkezelés + No contributor data was built into this copy. Ez a példány nem tartalmaz közreműködői adatokat. + No Dither Nincs árnyalatkeverés + No matching settings Nincs egyező beállítás + No model credits were built into this copy. Ez a példány nem tartalmaz adatokat a modellek készítőiről. + No presets of this type. Ilyen típusú előbeállítás nincs. + No saved presets yet. Még nincs mentett előbeállítás. + Not a web colour: click for the nearest web colour Nem webes szín: kattints a legközelebbi webes színért + Notification Duration (seconds) Értesítés időtartama (másodperc) + Number Szám + OK OK + Only Web Colors Csak webes színek + Open File Fájl megnyitása + OpenGL OpenGL + Optimized Optimalizált + Original Eredeti + Out of gamut for printing: click for the nearest printable colour Nyomtatási színtartományon kívül: kattints a legközelebbi nyomtatható színért + Out of memory Elfogyott a memória + Package Csomagolás + Percent Százalék + Perceptual Érzékelési + Permission denied Hozzáférés megtagadva + Phone Telefon + Photo Fotó + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? A PhotoCraft egy másik képszerkesztő billentyűparancs-készletét találta ezen a számítógépen: {set}. Ebben {n} billentyűparancs eltér a PhotoCraftétól. Importálod? + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. A PhotoCraft tovább próbálkozik; amíg a mentés nem sikerül, a beállítások módosításai bezáráskor elvesznek. + PhotoCraft Manages Colors A PhotoCraft kezeli a színeket + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft – nyílt forráskódú, natív képszerkesztő Rust nyelven. + Picas Pica + Pixels Pixel + Pixels/Centimeter Pixel/centiméter + Pixels/Inch Pixel/hüvelyk + Place Embedded Beágyazott elhelyezése + Place Linked Csatolt elhelyezése + Png PNG + Points Pont + Portrait Álló + Portrait, 4 x 6 Álló, 4 x 6 + Portrait, 5 x 7 Álló, 5 x 7 + Position and Size Pozíció és méret + Postcard Képeslap + Poster Plakát + PostScript (72 points/inch) PostScript (72 pont/hüvelyk) + Preferences can't be saved A beállítások nem menthetők + Preferences saved Beállítások mentve + Preferences were reset: {error} A beállítások alaphelyzetbe álltak: {error} + Preset Előbeállítás + PRESET DETAILS AZ ELŐBEÁLLÍTÁS RÉSZLETEI + Preset Manager Előbeállítás-kezelő + Preset Name Előbeállítás neve + Preset Type Előbeállítás típusa + Press keys Nyomd le a billentyűket + Press keys… Nyomd le a billentyűket… + Print Nyomtatás + Print resolution Nyomtatási felbontás + Print Resolution: Nyomtatási felbontás: + Printed to {path} Nyomtatva. Cél: {path} + Printer Nyomtató + Printer Manages Colors A nyomtató kezeli a színeket + Printer Setup Nyomtatóbeállítás + Printing Marks Nyomdai jelek + Progressive Progresszív + PSD PSD + Public Domain Közkincs + Quality Minőség + Quick export format Gyors exportálás formátuma + Ran script {name} Parancsfájl lefuttatva: {name} + Real name Valódi név + Recent Legutóbbiak + Recover on launch Helyreállítás indításkor + Recovered {name} Helyreállítva: {name} + Recovery Helyreállítás + Registration Marks Illesztőjelek + Relative Colorimetric Relatív kolorimetrikus + Remove from favorites Eltávolítás a kedvencek közül + Rename Átnevezés + Render Video Videó renderelése + Rendering Mode Renderelési mód + Reset All to Defaults Összes visszaállítása alapértékre + Reset Section Szakasz visszaállítása + Resize image during place Kép átméretezése elhelyezéskor + Resolution Felbontás + Resolution: {dpi} pixels/inch Felbontás: {dpi} pixel/hüvelyk + Restore Defaults Alapértékek visszaállítása + Restrictive (Web) Korlátozott (web) + Result Eredmény + Retry Újrapróbál + Reverse the order Sorrend megfordítása + Revert “{name}” to the last saved version? Your changes will be lost. Visszaállítod az utolsó mentett változatot? A módosításaid elvesznek. Dokumentum: „{name}” + RGB Color RGB-szín + Right: Jobb: + Rows Sorok + Rulers & Grids Vonalzók és rácsok + Save a Copy Másolat mentése + Save as PDF Mentés PDF-ként + Save cancelled Mentés megszakítva + Save failed: {error} A mentés nem sikerült: {error} + Save for Web (Legacy) Mentés webre (régi) + Save HTML and Images HTML és képek mentése + Save Preset… Előbeállítás mentése… + Saved Mentve + Scale to Fit Media Méretezés a papírmérethez + Scratch disks Lapozólemezek + Script Events Manager Parancsfájl-eseménykezelő + Search Keresés + Search settings… Keresés a beállítások között… + Selected Artboards Kijelölt rajztáblák + Selective Szelektív + Sensitivity Érzékenység + Sent to the printer ({spooler}) Elküldve a nyomtatóra ({spooler}) + Services Szolgáltatások + Sessions only Csak munkamenetek + Shape Layer Alakzatréteg + Shortcut removed. Billentyűparancs eltávolítva. + Shortcuts Billentyűparancsok + Show All Menu Items Összes menüpont megjelenítése + Show Guides Segédvonalak megjelenítése + Show rulers Vonalzók megjelenítése + Show Rulers Vonalzók megjelenítése + Show tooltips Eszköztippek megjelenítése + Software Szoftver + Solarized dark Solarized sötét + Sort Rendezés + Square, 12 x 12 Négyzet, 12 x 12 + Square, 5 x 5 Négyzet, 5 x 5 + Srgb sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Studio light Studio világos + Studio Light Studio világos + System Info Rendszerinformáció + Table Táblázat + Tabloid Tabloid + Targa Options Targa-beállítások + Target: Cél: + Teal Orange Kékeszöld-narancs + The document does not have an embedded colour profile. Working space: {working}. A dokumentumnak nincs beágyazott színprofilja. Munkaszíntér: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} A dokumentum beágyazott színprofilja eltér a munkaszíntértől.\nBeágyazott: {embedded}\nMunkaszíntér: {working} + The keyboard shortcut set was not imported: {error} A billentyűparancs-készlet importálása nem sikerült: {error} + These settings aren't available in PhotoCraft yet. Ezek a beállítások a PhotoCraftban még nem érhetők el. + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? A folytatáshoz a kitöltőréteget raszterizálni kell. A kitöltés tartalma ezután nem lesz szerkeszthető. Raszterizálod a réteget? + This layer must be rasterized before proceeding. A folytatáshoz a réteget raszterizálni kell. + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? A folytatáshoz az alakzatréteget raszterizálni kell. Ezután alakzatként nem lesz szerkeszthető. Raszterizálod az alakzatot? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? A folytatáshoz az intelligens objektumot raszterizálni kell. A tartalma ezután nem lesz szerkeszthető. Raszterizálod az intelligens objektumot? + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? A folytatáshoz a szövegréteget raszterizálni kell. A szöveg ezután nem lesz szerkeszthető. Raszterizálod a szöveget? + TIFF Options TIFF-beállítások + Top: Felső: + Traditional (72.27 points/inch) Hagyományos (72,27 pont/hüvelyk) + Transparency Átlátszóság + Unix Unix + Unsaved changes Nem mentett módosítások + Untitled Névtelen + Untitled-1 Névtelen-1 + URLs URL-ek + Use Default Alapértelmezés használata + Use shift key for tool switch Eszközváltás a Shift billentyűvel + Use the embedded profile (instead of the working space) A beágyazott profil használata (a munkaszíntér helyett) + Username Felhasználónév + Version {version} Verzió: {version} + Vulkan Vulkan + Warm Meleg + Web Web + Web Color Webes szín + Web Large Web, nagy + Web Minimum Web, minimális + Web Most Common Web, leggyakoribb + Web Snap Webes illesztés + Webp WebP + Wetness Nedvesség + White Fehér + Width and Height Szélesség és magasság + Working Munkaszíntér + Y Y + Yes Igen + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. A billentyűparancs-készleted itt is használatban van: {set}. Ebből {n} billentyűparancs eltér az alapértelmezettől. A Szerkesztés › Billentyűparancsok alatt láthatod őket; az Összes visszaállítása alapértékre paranccsal ez visszavonható. + {n} commands are not in PhotoCraft. {n} parancs nem érhető el a PhotoCraftban. + {shortcut} assigned. Billentyűparancs hozzárendelve: {shortcut} + {shortcut} is already in use by {commands} and will be removed from it when you click OK. A billentyűparancs már foglalt: {shortcut}. Jelenlegi parancs: {commands}. Az OK gombra kattintva onnan törlődik. + +# Status bar, messages and other labels + Action playback failed: {error} A műveletsor lejátszása sikertelen: {error} + Adaptive Wide Angle Adaptív nagy látószög + Add Anchor Point Tool Horgonypont hozzáadása + Add beauty precomp from the AOVs Beauty előkompozíció hozzáadása az AOV-kből + Add to Hozzáadás + Additional plugins folder További bővítmények mappája + Adjustment Layer Korrekciós réteg + Adjustment Properties Korrekció tulajdonságai + Adobe rgb Adobe RGB + Adwaita Adwaita + Adwaita (Dark) Adwaita (sötét) + Adwaita (Light) Adwaita (világos) + Allow agent control Ágensvezérlés engedélyezése + Allow scripts to connect Parancsfájlok csatlakozásának engedélyezése + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Az Alt+kattintást az asztali környezet kapta meg, mert nála az Alt+húzás ablakot mozgat. Állítsd az ablakmozgató billentyűt a Super billentyűre, vagy rögzítsd az Alt billentyűt itt: Ablak › Módosítóbillentyűk. + Ambience Környezeti fény + Ambient Környezeti + Anchor: Rögzítési pont: + Animated zoom Animált nagyítás + Anisotropic Anizotróp + Apply At Canvas Bounds Alkalmazás a vászon szélén is + Apply auto tone Automatikus tónus alkalmazása + Apply Layer Comp Rétegkompozíció alkalmazása + Arc Ív + Arch Boltív + Area Terület + Artboard Tool Rajztábla + ArtCraft ArtCraft + Ask Rákérdezés + Aspect Ratio Oldalarány + Assign Profile Profil hozzárendelése + Author Szerző + auto automatikus + Auto collapse icon panels Ikonpanelek automatikus összecsukása + Auto Scale Automatikus méretezés + Auto show hidden panels Rejtett panelek automatikus megjelenítése + Auto show home screen Kezdőképernyő automatikus megjelenítése + Auto-Align Layers Rétegek automatikus igazítása + Auto-Blend Layers Rétegek automatikus keverése + Automatic Automatikus + Back Vissza + Backend: {backend} ({type}) Háttérrendszer: {backend} ({type}) + Background Háttér + Background Eraser Tool Háttérradír + Begin recording Rögzítés indítása + Bicubic (smooth gradients) Bikubikus (sima átmenetekhez) + Bicubic automatic Automatikus bikubikus + Bicubic sharper Élesebb bikubikus + Bicubic Sharper (reduction) Élesebb bikubikus (kicsinyítéshez) + Bicubic smoother Simább bikubikus + Bicubic Smoother (enlargement) Simább bikubikus (nagyításhoz) + Blade Curvature Lamellák görbülete + Blast Szélroham + Blend Keverés + Border Szegély + Bottom Left Bal alsó + Bottom Right Jobb alsó + Breeze Dark Breeze sötét + Breeze Light Breeze világos + Browse Tallózás + Bulge Kidudorodás + Cache levels Gyorsítótár-szintek + Cache tile size Gyorsítótár csempemérete + Camera Data Fényképezőgép adatai + Camera Raw Defaults Camera Raw-alapértékek + Camera Raw… Camera Raw… + Cancel opening Megnyitás megszakítása + Canvas border Vászonszegély + Canvas extension color: Vászonbővítés színe: + Canvas Size Vászonméret + Category Kategória + Channel1 1. csatorna + Channel2 2. csatorna + Channel3 3. csatorna + Channel4 4. csatorna + Circle Kör + Circularity Köralakúság + Classic Klasszikus + Cleanliness Vonaltisztaság + Click a segment to add an anchor point · {alt}-click an anchor to delete it Kattints egy szakaszra horgonypont hozzáadásához · {alt}+kattintás egy horgonyponton: törlés + Click an anchor point to delete it · {alt}-click a segment to add one Kattints egy horgonypontra a törléséhez · {alt}+kattintás egy szakaszon: új horgonypont + Click to pick · Esc to cancel Kattintás: kiválasztás · Esc: mégse + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Kattintás: sarokpont · Húzás: simított pont · {alt}+húzás: fogantyú megtörése · Kattintás vagy húzás az első ponton: lezárás · {key}: befejezés · Esc: mégse + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Kattintás: horgonypont kijelölése · Húzás: horgonypont, fogantyú vagy szakasz mozgatása · {shift}+kattintás: hozzáadás · {alt}+kattintás: teljes részútvonal + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Kattintás: simított pont · Dupla kattintás: sarokpont · Pont húzása: mozgatás · Kattintás az első ponton: lezárás · {key}: befejezés + Clipping Path Vágóútvonal + Clone Klónozás + Clone Stamp Tool Klónozó bélyegző + Close Bezár + Color management: {error} Színkezelés: {error} + Color picker Színválasztó + Color Picker Színválasztó + Color Range Színtartomány + Color Replacement Tool Színcserélő + Color Settings Színbeállítások + Color Table Színtábla + ColorSync ColorSync + Compose Szedés + Concise Tömör + Constrain proportions Arányok megtartása + Constraints Megkötések + Content-Aware Fill Tartalomérzékeny kitöltés + Content-Aware Move Tool Tartalomérzékeny mozgatás + Contract Szűkítés + Control port Vezérlőport + Convert Point Tool Pontátalakító + Convert to Linked Átalakítás csatolttá + Convert to Profile Átalakítás profilra + Convert to srgb Átalakítás sRGB-re + Copyright Status Szerzői jogi állapot + Corner radius Sarokkerekítés sugara + Correct C A Kromatikus aberráció javítása + Correct Distortion Torzítás javítása + Correct Vignette Vignettálás javítása + Could not rename the action: {error} A műveletsor nem nevezhető át: {error} + Count Tool Számláló + Create a new document or open an existing file. Hozz létre egy új dokumentumot, vagy nyiss meg egy meglévő fájlt. + Create By Pótlás módja + Create new action Új műveletsor létrehozása + Create new path Új útvonal létrehozása + Crop Tool Körbevágás + Cross Kereszt + Current Size Jelenlegi méret + Current Tool Aktuális eszköz + Cursors Kurzorok + Curvature Pen Tool Görbületi toll + Curve Fit: Görbeillesztés: + Custom dark Egyéni sötét + Custom light Egyéni világos + Custom Shape Tool Egyéni alakzat + Dashed lines Szaggatott vonalak + Date and Time Dátum és idő + Default Foreground and Background Colors Alapértelmezett előtér- és háttérszín + Delete Anchor Point Tool Horgonypont törlése + Delete path Útvonal törlése + Delete Path Útvonal törlése + Delete vector mask Vektormaszk törlése + Depth Mode Mélységmód + Detailed Részletes + Details Részletek + Dimensions: Méretek: + Direct Selection Tool Közvetlen kijelölés + Disable Kikapcsolás + Disabled Kikapcsolva + Dismiss Elvetés + Display Kijelző + Display profile not used A kijelzőprofil nincs használatban + Display profiles: {reason} Kijelzőprofilok: {reason} + Distribution Eloszlás + Doc: {flat}/{layered} Dokumentum: {flat}/{layered} + Document Dimensions Dokumentum méretei + Document Profile Dokumentumprofil + Document Size Dokumentumméret + Document Sizes Dokumentumméretek + Documents Dokumentumok + Draft Vázlat + Drag to move the active shape's path or the Work Path Húzással mozgathatod az aktív alakzat útvonalát vagy a munkaútvonalat + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Húzás: szabadkézi rajzolás · {alt}+kattintás: egyenes szakasz · Elengedés a kezdőponton: lezárás + Draw with the Pen tool (P) or make a work path from a selection. Rajzolj a Toll eszközzel (P), vagy készíts munkaútvonalat egy kijelölésből. + Driver: {driver} Illesztőprogram: {driver} + Drop an image or PSD anywhere to open it. Húzz ide bárhová egy képet vagy PSD-fájlt a megnyitásához. + Duplicate Layer Comp Rétegkompozíció kettőzése + Duplicate Path Útvonal kettőzése + Duplication Kettőzés + East asian Kelet-ázsiai + Edge Extension Szélek kiterjesztése + Edit › Paste Szerkesztés › Beillesztés + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. A teljes dokumentumot érintő módosítások (például egy réteg keverési módja, átlátszatlansága vagy láthatósága) az egész dokumentumot újrarajzolják. + Effect cache mb Effektus-gyorsítótár (MB) + Efficiency Hatékonyság + Efficiency: 100% Hatékonyság: 100% + Elevation Magassági szög + Eliminate Eltávolítandó + Ellipse Tool Ellipszis + Elliptical Marquee Tool Ellipszis-kijelölés + Enable Bekapcsolás + Enable flick panning Lendítéses pásztázás engedélyezése + Enable floating document window docking Lebegő dokumentumablakok dokkolásának engedélyezése + Enable narrow options bar Keskeny beállítósáv engedélyezése + Enabled Bekapcsolva + End X Vég X + End Y Vég Y + Enhanced Controls Bővített vezérlők + Eraser Tool Radír + Errors Hibák + Even Fields Páros félképek + Export Exportálás + Export clipboard Vágólap exportálása + Export Contents Tartalom exportálása + Exported contents to {path} Tartalom exportálva. Cél: {path} + Extra large Nagyon nagy + Eyedropper Tool Pipetta + Failed Sikertelen + fallback tartalék + Fallback: {reason} Tartalék: {reason} + File Handling Fájlkezelés + Filename Fájlnév + Fill Kitöltés + Fill Color Kitöltőszín + Fill Path Útvonal kitöltése + Fill path with foreground color Útvonal kitöltése előtérszínnel + Find Keresés + Fine Finom + Fish Hal + Flag Zászló + Flame Láng + Flat Lapos + Focus Fókusz + Focus Area Fókuszterület + Font preview Betűtípus-előnézet + Frame Keret + Frame Tool Keret + Freeform Pen Tool Szabadkézi toll + From Left Balról + Full size tip Teljes méretű hegy + Fullscreen Teljes képernyő + General… Általános… + Generator enabled Generátor bekapcsolva + Generators Generátorok + Generic Általános + GitHub GitHub + Gloss Csillogás + Glossy Fényes + Good Jó + GPU acceleration could not continue. Your documents are unchanged. A GPU-gyorsítás leállt. A dokumentumok nem változtak. + GPU backend preference: {backend} Előnyben részesített GPU-háttérrendszer: {backend} + GPU device was lost; using the CPU renderer. A GPU-eszköz elérhetetlenné vált; a CPU-s megjelenítő működik. + GPU error; using the CPU renderer. GPU-hiba; a CPU-s megjelenítő működik. + GPU retry scheduled GPU-újrapróbálás ütemezve + Gradient Tool Színátmenet + Graphics adapter Grafikus adapter + Graphics adapter: {adapter} Grafikus adapter: {adapter} + Gray Value (Maximum) Szürkeérték (maximum) + Gray Value (Mean) Szürkeérték (átlag) + Gray Value (Median) Szürkeérték (medián) + Gray Value (Minimum) Szürkeérték (minimum) + Grid color Rács színe + Grid colors Rács színei + Grid style Rács stílusa + Grid unit Rács mértékegysége + Gridline every Rácsvonalak távolsága + Group Layer Csoportréteg + Groups Csoportok + Guide style Segédvonalak stílusa + Guides, Grid & Slices Segédvonalak, rács és szeletek + Hand Kéz + Heal Javítás + Healing Brush Tool Javítóecset + Heptagon Hétszög + Hexagon Hatszög + Hidden Rejtett + High Magas + History Brush Tool Előzményecset + History Log Előzménynapló + History states Előzményállapotok + Home Kezdőképernyő + Horizontal Type Mask Tool Vízszintes szövegmaszk + Horizontal Type Tool Vízszintes szöveg + Hotspot Fényfolt + Hsl HSL + Ignore exif profile tag EXIF-profilcímke figyelmen kívül hagyása + Image compositor: {processor} Képösszeállító: {processor} + Image interpolation Képinterpoláció + Image Size: Képméret: + Image Size… Képméret… + Import Importálás + In Kezdőpont + Include in playback Lejátszásba foglalás + Indexed Indexelt + Infinite Végtelen + Inflate Felfújás + Input Bemenet + Input Mode Bemeneti mód + Integrated Density Integrált sűrűség + Integrations Integrációk + Integrations… Integrációk… + Interface Felület + Interpolation Interpoláció + Interpret Footage Videoanyag értelmezése + Interval Intervallum + Invalid {mode} profile Érvénytelen profil ({mode}) + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. A dokumentum rétegei nem férnek el a GPU memóriakeretében. Ha a Beállítások › Teljesítmény alatt növeled a memóriahasználatot, a keret is nő, legfeljebb a számítógép memóriájának negyedéig. + Join us on Discord Csatlakozz hozzánk a Discordon + Keep Using CPU Marad a CPU + Kernel Szűrőmag + Keyboard Billentyűzet + Label Címke + Lanczos (sharp) Lanczos (éles) + Large document: redrawing on the CPU Nagy dokumentum: újrarajzolás a CPU-n + Large tabs Nagy lapfülek + Lasso Tool Lasszó + Layer Comp Rétegkompozíció + Layer Count Rétegek száma + Layer From Background Réteg a háttérből + Layer Mask Rétegmaszk + Layer Properties Réteg tulajdonságai + Legacy compositing Régi képösszeállítás + Length Hossz + Lens Correction Objektívkorrekció + Libraries Könyvtárak + Light Type Fényforrás típusa + Light X Fényforrás X + Light Y Fényforrás Y + Light Z Fényforrás Z + Lighting Megvilágítás + Line Vonal + Line Tool Vonal + Linux display server Linuxos megjelenítőszerver + Liquify Képlékenyítés + List Lista + Load path as a selection Útvonal betöltése kijelölésként + Lock All Összes zárolása + Locked Zárolva + Low Alacsony + Low resolution previews Alacsony felbontású előnézetek + Lower Alsó + Magic Eraser Tool Varázsradír + Magic Wand Tool Varázspálca + Magnetic Lasso Mágneses lasszó + Magnetic Lasso Tool Mágneses lasszó + Make Selection Kijelölés készítése + Make work path from selection Munkaútvonal készítése a kijelölésből + Manual Kézi + manual kézi + Map Layer Térképréteg + Map Path Térképfájl + Mask Maszk + Mask Incomplete Blocks Hiányos tömbök maszkolása + Maximize Teljes méret + Maximize psd compatibility PSD-kompatibilitás maximalizálása + Memory usage mb Memóriahasználat (MB) + Metallic Fémesség + Middle Közép + Missing glyph protection Hiányzó glifák elleni védelem + Mixer Brush Tool Keverőecset + Modern Modern + Monitor profile in use on {display}: {profile} Használt monitorprofil ({display}): {profile} + Monitor profile in use: {profile} Használt monitorprofil: {profile} + Monitor profile on {display} (main window): {profile} Monitorprofil ({display}, főablak): {profile} + Monitor profile on {display}: {profile} Monitorprofil ({display}): {profile} + Monitor profile: {profile} Monitorprofil: {profile} + Monochromatic Egyszínű + More documents További dokumentumok + More panels További panelek + Move Layer Réteg mozgatása + Move Tool Mozgatás + name név + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Wayland alatt a natív fájlbehúzás még nem támogatott. Használd a Fájl › Megnyitás… parancsot, vagy másold a képet a fájlkezelőben, és illeszd be ({paste}). + Native file drag-and-drop is unavailable A natív fájlbehúzás nem érhető el + Nearest neighbor Legközelebbi szomszéd + Nearest Neighbor (hard edges) Legközelebbi szomszéd (éles szélek) + New Új + new új + New document Új dokumentum + New Document With Mask Új dokumentum maszkkal + New document… Új dokumentum… + New Group Új csoport + New Guide Új segédvonal + New Layer Új réteg + New Layer Comp Új rétegkompozíció + New Layer With Mask Új réteg maszkkal + New Selection Új kijelölés + New Size Új méret + New Video Layer from File Új videoréteg fájlból + No Document Nincs dokumentum + No layers Nincs réteg + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nincs útvonal, amelyből kijelölés készülhetne: rajzolj egyet a Toll eszközzel, vagy válassz egyet az Útvonalak panelen. + Normal tip Normál hegy + Note Tool Jegyzet + Note tool Jegyzet + Nothing to paste: the clipboard holds no image Nincs mit beilleszteni: a vágólapon nincs kép + Object Selection Tool Objektumkijelölés + Octagon Nyolcszög + Odd Fields Páratlan félképek + On Be + on be + Open as smart object Megnyitás intelligens objektumként + Open documents as tabs Dokumentumok megnyitása lapfülként + open source nyílt forráskódú + Opened {name} Megnyitva: {name} + Opened {url} Megnyitva: {url} + Opening… Megnyitás… + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Vagy állítsd X11-re a Beállítások › Teljesítmény › Linuxos megjelenítőszerver értékét: a PhotoCraft ekkor mindig XWayland alatt indul. + Original Ratio Eredeti arány + Other Egyéb + Out Végpont + Output Kimenet + Overscroll Túlgörgetés + Paint Bucket Tool Festékesvödör + Patch Tool Foltozó + Path Útvonal + Path Selection Tool Útvonal-kijelölés + Path: Útvonal: + Pattern Stamp Tool Mintázatbélyegző + Pen Tool Toll + Pencil Tool Ceruza + Pentagon Ötszög + Percentage Százalék + Performance Teljesítmény + Perimeter Kerület + Perspective Crop Tool Perspektivikus körbevágás + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. A PhotoCraft a CPU-s képösszeállítót használja. Az ablak megjelenítése ettől még használhatja a grafikus adaptert. + PhotoCraft website PhotoCraft-weboldal + PhotoCraft — Screen Color Picker PhotoCraft — Képernyő-színválasztó + Photomerge Fotóegyesítés + Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz + Pick screen color Szín kiválasztása a képernyőről + Pins Tűk + Pixel Layer Pixelréteg + Place Scale Marker Méretarány-jelölő elhelyezése + Plane Sík + Planes Síkok + Platform: {os} {arch} Platform: {os} {arch} + Play selection Kijelöltek lejátszása + Point Pont + Point size Pontméret + Polygon Tool Sokszög + Polygonal Lasso Tool Sokszöglasszó + Preserve Megőrzendő + Preserve Details Részletek megőrzése + Preserve details Részletek megőrzése + previous reading kept; the last re-read failed: {reason} az előző érték maradt érvényben; a legutóbbi újraolvasás sikertelen: {reason} + Prime105 105 mm-es fix + Prime35 35 mm-es fix + Pro (Dark) Pro (sötét) + Pro (Light Gray) Pro (világosszürke) + Pro (Light) Pro (világos) + Pro (Medium Gray) Pro (középszürke) + Pro light Pro világos + Pro light gray Pro világosszürke + Pro medium Pro középszürke + Pro Medium Pro középszürke + Profile Profil + Pyramids Gúlák + Quick export location Gyors exportálás helye + Quick Selection Gyorskijelölés + Quick Selection Tool Gyorskijelölés + Random Véletlenszerű + Rasterize svg groups deeper than SVG-csoportok raszterizálása ennél mélyebb szinten + Recent file count Legutóbbi fájlok száma + Recent files Legutóbbi fájlok + Recent Files Legutóbbi fájlok + Recent fonts Legutóbbi betűtípusok + Record ● a sequence of edits, then play ▶ it on any document. Rögzíts ● egy szerkesztési sorozatot, majd játszd le ▶ bármelyik dokumentumon. + Rectangle Téglalap + Rectangle Tool Téglalap + Rectangular Marquee Tool Téglalap-kijelölés + Rectangular Selection Téglalap-kijelölés + Red Eye Vörösszem-javítás + Red Eye Tool Vörösszem-javító + Refine Edge Élfinomítás + Relink to File Újracsatolás fájlhoz + Remove All Összes eltávolítása + Remove from Recent Eltávolítás a legutóbbiak közül + Remove Tool Eltávolító + Remove {name} from Recent Eltávolítás a legutóbbiak közül: {name} + Rename Layer Comp Rétegkompozíció átnevezése + Rendering mode Renderelési mód + Repeat Ismétlés + Replace Csere + Replace Contents Tartalom cseréje + Replace Footage Videoanyag cseréje + Resample Újramintavételezés + Reset Preferences Beállítások alaphelyzetbe + Resolution: Felbontás: + Restore Előző méret + Retry GPU GPU újrapróbálása + Retry GPU requires a restart. Save your work first. A GPU újrapróbálásához újraindítás kell. Előbb mentsd a munkádat. + Rgb RGB + Ridges Hullámgerincek + Right Jobbra + Rise Emelkedés + Rotate View Tool Nézetforgatás + Rotate view with trackpad Nézetforgatás érintőpárnával + Row Order Csatornasorrend + Ruler Tool Vonalzó + Same folder Ugyanaz a mappa + Sample a pixel anywhere on the screen Mintavétel a képernyő bármely pixeléből + Sampler Színmintavevő + Save in background Mentés a háttérben + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. A GPU-gyorsítás újrapróbálásához mentsd a munkádat, majd indítsd újra a PhotoCraftot. Ebben a munkamenetben a CPU-s megjelenítés marad aktív. + Saved a copy as {path} Másolat mentve: {path} + Saved {name} Mentve: {name} + Saved {path}; image assets: {n} in {dir} Mentve: {path}; képelemek: {n} ({dir}) + Scale Factor Méretezési tényező + Scale Units Méretarány mértékegysége + Scratch Disks Lapozólemezek + Scratch Sizes Lapozólemez-méretek + Scratch: {size} Lapozólemez: {size} + Screen resolution Képernyőfelbontás + Scroll to zoom Nagyítás görgetéssel + Scrubby slider acceleration Húzócsúszka gyorsítása + Seed Kezdőérték + Select Layer Réteg kijelölése + Selected Kijelölt + Selected at launch: {backend} Indításkor kiválasztva: {backend} + Set Colors Színek beállítása + Set Measurement Scale Mérési méretarány beállítása + Set shape fill type Alakzat kitöltéstípusának beállítása + Set shape stroke type Alakzat körvonaltípusának beállítása + Shape Alakzat + Sharpen for Élesítés célja + Shell Lower Alsó kagyló + Shell Upper Felső kagyló + Shift Edge Él eltolása + Shine Fénylés + shortcut billentyűparancs + Shortcut Billentyűparancs + Show canvas dots Vászonpontok megjelenítése + Show channels in color Csatornák megjelenítése színesen + Show crosshair in brush tip Szálkereszt az ecsethegyben + Show extension panels Bővítménypanelek megjelenítése + Show font names in english Betűnevek angolul + Show only crosshair while painting Festéskor csak szálkereszt + Show transformation values Alakítási értékek megjelenítése + Sides Oldalak + Sine Szinusz + Single Column Marquee Tool Egyoszlopos kijelölés + Single Row Marquee Tool Egysoros kijelölés + Slice Select Tool Szeletkijelölés + Slice Tool Szelet + Smart guide color Intelligens segédvonalak színe + Smart Radius Intelligens sugár + Snap vector tools and transforms to pixel grid Vektoreszközök és alakítások illesztése a pixelrácshoz + Snapshots Pillanatképek + Softness Lágyság + Software adapter Szoftveres adapter + Solarized Dark Solarized sötét + Solid Front Faces Tömör előlapok + Spin Pörgetés + Spot Healing Foltjavítás + Spot Healing Brush Tool Foltjavító ecset + Squareness Szögletesség + Squeeze Összenyomás + Stagger Lökésszerű + Standard Szabványos + Star Csillag + Star ratio Csillagarány + Start X Kezdő X + Start Y Kezdő Y + Step Backward Lépés vissza + Step {step} ({id}) failed: {error} {step}. lépés ({id}) sikertelen: {error} + Stop Leállítás + Stop playing/recording Lejátszás/rögzítés leállítása + Strength Erősség + Stretch Nyújtás + Stroke Path Útvonal körvonalazása + Stroke path with brush Útvonal körvonalazása ecsettel + Stroke Width Körvonal vastagsága + Stroke: Körvonal: + Studio Studio + Studio (Dark) Studio (sötét) + Studio (Light) Studio (világos) + Stylization Stilizálás + Switch Foreground and Background Colors Előtér- és háttérszín cseréje + Switched to CPU rendering Átváltás CPU-s megjelenítésre + System Rendszer + Tablet Tablet + Taper Elvékonyodás + Target Cél + Text file Szövegfájl + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A vászon sRGB-ként jelenik meg. Monitorprofilt itt választhatsz: Szerkesztés › Színbeállítások. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. A vászon sRGB-ként jelenik meg ezen a kijelzőn: {display}. Monitorprofilt itt választhatsz: Szerkesztés › Színbeállítások. + The GPU compositor wasn't used: {reason} A GPU-s képösszeállító nem volt használatban: {reason} + This redraw took {seconds} s on the CPU. Az újrarajzolás ideje a CPU-n: {seconds} s. + This session: {reason} Ebben a munkamenetben: {reason} + Tiny Apró + To Center Középre + To drop files, start PhotoCraft under XWayland: `{command}` Fájlok behúzásához indítsd a PhotoCraftot XWayland alatt: `{command}` + Tool Eszköz + Top Left Bal felső + Top Right Jobb felső + Touch gestures Érintési gesztusok + Transform Again on a Copy Alakítás ismétlése másolaton + Transition Átmenet + Transparency & Gamut Átlátszóság és színtartomány + Trap Ráfutás + Tree Fa + Triangle Tool Háromszög + Trim Levágás + Twist Csavarás + Unaltered Változatlan + Uniform Egyenletes + Units Mértékegységek + Units & Rulers Mértékegységek és vonalzók + Unknown Ismeretlen + unknown ismeretlen + unknown type ismeretlen típus + Unnamed {mode} profile Névtelen profil ({mode}) + Untagged {mode} Profil nélkül ({mode}) + Update Frissítés + Update Layer Comp Rétegkompozíció frissítése + Upper Felső + Use additional plugins folder További bővítménymappa használata + Use Custom Color Egyéni szín használata + Use File › Open, or paste a copied image with {paste}. Használd a Fájl › Megnyitás… parancsot, vagy illessz be egy másolt képet ({paste}). + Use gpu GPU használata + Use tablet pressure Tablet nyomásérzékelésének használata + Vanishing Point Enyészpont + Vertical Type Mask Tool Függőleges szövegmaszk + Vertical Type Tool Függőleges szöveg + Video Frames to Layers Videókockák rétegekbe + View Nézet + Wand Varázspálca + Warmth Melegség + Weight: Vastagság: + Window renderer: {renderer} Ablakmegjelenítő: {renderer} + Work Path Munkaútvonal + World ready Többnyelvű + Wrap Körbefordítás + X11 X11 + Zoom clicked point to center Kattintott pont nagyítása középre + Zoom resizes windows Nagyításkor az ablak is átméreteződik + Zoom with scroll wheel Nagyítás görgővel + Zoom with trackpad pinch Nagyítás csippentéssel az érintőpárnán + {name} options {name}: beállítások + {size} (was {old}) {size} (korábban: {old}) + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + …and {n} more …és még {n} + +# Labels kept for parity with the other catalogs + 10 fps 10 fps + 15 fps 15 fps + 24 fps 24 fps + 25 fps 25 fps + 250% 250% + 3D… 3D… + 85% 85% + 95% 95% + Accurate Pontos + ACES ACES + Actual Color Tényleges szín + Add a mask (from the selection; ⌥ inverts) Maszk hozzáadása (a kijelölésből; ⌥: invertálás) + Add Mask Maszk hozzáadása + Add Pins Tűk hozzáadása + Add to selection (⇧) Hozzáadás a kijelöléshez (⇧) + Adds grain to the soft edges of the brush tip. Szemcsézettséget ad az ecsethegy lágy széleihez. + Adjustment Layers Korrekciós rétegek + Adobe Every-line Composer Adobe többsoros szedőmotor + Adobe PDF Presets… Adobe PDF-előbeállítások… + Adobe Single-line Composer Adobe egysoros szedőmotor + Advanced Controls Speciális vezérlők + After (X) Utána (X) + Airbrush: keeps painting while the pointer rests. Festékszóró: a festés akkor is folytatódik, amikor a mutató áll. + Alternates for Current Selection Változatok a jelenlegi kijelöléshez + Aperture Rekesznyílás + Apple RGB Apple RGB + Apply to All Alkalmaz mindre + Are you sure you want to quit? Biztosan kilépsz? + Are you sure? Biztos vagy benne? + Arrangement Elrendezés + Art History Brush Művészi előzményecset + Ask Before Saving Saved Embedded Rákérdezés a beágyazott tartalom mentése előtt + Ask When Pasting Rákérdezés beillesztéskor + Audio Hang + Auto Clean Brush Ecset automatikus tisztítása + Auto-Hide Automatikus elrejtés + Auto-Leading Automatikus sorköz + Auto-leading % Automatikus sorköz % + Automatically Create First Snapshot Első pillanatkép automatikus létrehozása + Automatically Create New Snapshot When Saving Új pillanatkép automatikus létrehozása mentéskor + Avoid Widows/Orphans Fattyúsorok elkerülése + Blend RGB Colors Using Gamma 1.0 RGB-színek keverése 1,0 gammával + Blending Mode Keverési mód + Blue, Red, Yellow Kék, vörös, sárga + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. A böngésző tárhelye nem érhető el, ezért az ecsetek csak ebben a munkamenetben maradnak meg. Ne zárd be ezt a lapot, ha meg akarod tartani őket. + Brush preset changes are not saved: {error} Az ecset-előbeállítások módosításai nincsenek mentve: {error} + Byline Szerző + Byline Title Szerző beosztása + Camera Make Fényképezőgép gyártója + Camera Model Fényképezőgép típusa + Canvas rotation Vászonforgatás + Caption Credits Képaláírás és forrás + Capture Comp Rétegkompozíció rögzítése + Changes have not been saved. A módosítások nincsenek mentve. + Changes Saved Módosítások mentve + Check for Updates Frissítések keresése + Choose Folder Mappa kiválasztása + Choose… Kiválasztás… + CIE RGB CIE RGB + City Város + Clear History Előzmények törlése + Click sets foreground · right-click sets background Kattintás: előtérszín · jobb kattintás: háttérszín + Click the first point or press ↵ to close · Esc cancels Lezáráshoz kattints az első pontra, vagy nyomd meg: ↵ · Esc: megszakítás + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Kattintással pontot adhatsz hozzá · eltávolítás: lehúzás, ⌘/Ctrl-kattintás vagy Delete + Click to add text Kattints a szöveg hozzáadásához + Click to apply, ⌥-click to clear overrides, double-click for Style Options Kattintás: alkalmazás, ⌥-kattintás: felülbírálások törlése, dupla kattintás: stílusbeállítások + Click to zoom in · ⌥-click to zoom out Kattintás: nagyítás · ⌥-kattintás: kicsinyítés + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Kattintás: sarok · Húzás: sima · Kattintás az első pontra: lezárás · {key}: befejezés · Esc: megszakítás + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Kattintás: sarok · Húzás: sima · Kattintás az első pontra: lezárás · ↩: befejezés · Esc: megszakítás + Coerce to sRGB Kényszerítés sRGB-re + Collapse group (⌥-click: all groups) Csoport összecsukása (⌥-kattintás: minden csoport) + Color Management Policies Színkezelési szabályok + Color Noise Reduction Színzaj csökkentése + ColorMatch RGB ColorMatch RGB + Commit any current edits (⌘↩) Folyamatban lévő szerkesztések véglegesítése (⌘↩) + Commit current crop operation (↵) Körbevágás véglegesítése (↵) + Commit Perspective Warp (↩) Perspektivikus torzítás véglegesítése (↩) + Commit Puppet Warp (↩) Bábtorzítás véglegesítése (↩) + Commit transform (↩) Alakítás véglegesítése (↩) + Commit warp (↩) Hajlítás véglegesítése (↩) + Convert Frames > Video Layers Képkockák átalakítása > Videórétegek + Convert to Grayscale Átalakítás szürkeárnyalatossá + Copper Réz + Copyright Info URL Szerzői jogi információk URL-je + Could not open Nem nyitható meg + Country Ország + Create a new layer (⇧⌘N) Új réteg létrehozása (⇧⌘N) + Create Date Létrehozás dátuma + Create Multiple Picture Packages Több képcsomag létrehozása + Create new count group Új számlálócsoport létrehozása + Create new set Új készlet létrehozása + Create Plane Sík létrehozása + Create Snapshot Pillanatkép létrehozása + Create… Létrehozás… + Credit Forrásmegjelölés + Current Time Aktuális idő + Custom pattern Egyéni mintázat + Date Created Létrehozás dátuma + DateTime Original Eredeti dátum és idő + Defaults restored. Alapértékek visszaállítva. + Delete Layer… Réteg törlése… + Delete Mask Maszk törlése + Delete Pins Tűk törlése + Delete Preset… Előbeállítás törlése… + Delete Smart Filters Intelligens szűrők törlése + Delete Snapshot Pillanatkép törlése + Delete… Törlés… + Desaturate Monitor Colors By Monitorszínek telítettségének csökkentése ennyivel + Detect Faces Arcfelismerés + Digits Számjegyek + Direction of Stroke Vonás iránya + Disable Mask Maszk letiltása + Discard Changes Módosítások elvetése + Do you want to save you made to “{name}” before quitting? Mented a módosításokat kilépés előtt? Dokumentum: „{name}” + Documentation Dokumentáció + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Húzás (lasszó) vagy pontok kattintással (sokszög) · {add}: hozzáadás · {sub}: kivonás + Drag to move the layer Húzással mozgathatod a réteget + Duplicated selection name Kettőzött kijelölés neve + Edit Plane Sík szerkesztése + Edit… Szerkesztés… + Email E-mail + Embed profiles Profilok beágyazása + Enable Mask Maszk engedélyezése + Enable Smart Filters Intelligens szűrők engedélyezése + Enable Timeline Quick Config Idővonal gyorsbeállításának engedélyezése + Enter description… Írd be a leírást… + Error File Hibafájl + EXIF Version EXIF-verzió + Expand All Groups Összes csoport kibontása + Expand group (⌥-click: all groups) Csoport kibontása (⌥-kattintás: minden csoport) + Export Image Kép exportálása + Export to Exportálás célja + Exporting… Exportálás… + Exposure Program Expozíciós program + Failed to open file A fájl nem nyitható meg + Failed to save file A fájl nem menthető + Feather Radius Elmosott szél sugara + File Naming Fájlelnevezés + File not found A fájl nem található + File Size Fájlméret + Fill Light Derítőfény + First Color Readout Első színkijelzés + Fit to Window Ablakhoz igazítás + Flash Vaku + Flatten Frames Into Clips Képkockák egybesimítása klipekbe + Flatten Frames Into Layers Képkockák egybesimítása rétegekbe + Frame Duration Képkocka időtartama + Frame Sync Képkocka-szinkronizálás + General Brushes Általános ecsetek + Glyph Scaling Glifaméretezés + Go to Layer Ugrás a rétegre + GPS GPS + Gray: Szürke: + Grayscale Slider Szürkeárnyalat-csúszka + Grid (⌘') Rács (⌘') + Hanging Punctuation Függő írásjelek + HDTV (Rec. 709) HDTV (Rec. 709) + Heal Auto Javítás: automatikus + Heal Color Javítás: szín + Heal Off Javítás: ki + Heal On Javítás: be + Hide Grid Rács elrejtése + Hide the layer's effects (⌥-click: all layers) Rétegeffektusok elrejtése (⌥-kattintás: minden réteg) + HSB Color HSB-szín + HSL/Grayscale HSL/szürkeárnyalat + Humidity Páratartalom + Import Image Kép importálása + Import Options Importálási beállítások + Import Settings Beállítások importálása + Importing… Importálás… + Import… Importálás… + Include ICC Profile ICC-profil belefoglalása + Industry Standard Iparági szabvány + Info Options… Információ-beállítások… + Initial Direction of Stroke Vonás kezdőiránya + Input Levels Bemeneti szintek + Intersect with selection (⇧⌥) Metszet a kijelöléssel (⇧⌥) + Invert Selection Kijelölés megfordítása + Jag Cakkos + Keep proportions Méretarány megtartása + Keep this tab open to retain your brushes. Ne zárd be ezt a lapot, ha meg akarod tartani az ecseteidet. + Keep view in sync Nézetek szinkronban tartása + Keeps the current texture when switching presets. Előbeállítás váltásakor megtartja a jelenlegi textúrát. + Last Modified Utolsó módosítás + Layer Styles Rétegstílusok + Learn More További információ + Lens Corrections Objektívkorrekciók + Lens Info Objektívadatok + Limit Playback Lejátszás korlátozása + Live Preview Élő előnézet + Loading… Betöltés… + Localize Color Clusters Helyi színcsoportok + Lock Image Pixels Képpixelek zárolása + Lock Position Pozíció zárolása + Lock Transparent Pixels Átlátszó pixelek zárolása + Log Errors To File Hibák naplózása fájlba + Luminance Smoothness Luminancia simasága + Mac Crisp Mac tiszta + Mac LCD Mac LCD + Mac Sharp Mac éles + Mac Smooth Mac sima + Mac Strong Mac erős + Metering Mode Fénymérési mód + Minimalistic Minimalista + Missing Profiles: Ask When Opening Hiányzó profilok: rákérdezés megnyitáskor + Modify Date Módosítás dátuma + Mouse Controls Egérvezérlés + Mouse Coordinates Egérkoordináták + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. A fájlok natív behúzása Waylanden még nem támogatott. Használd a Fájl › Megnyitás parancsot, vagy futtasd a PhotoCraftot XWaylanden: `WAYLAND_DISPLAY= photocraft`. + New channel only Csak új csatorna + New document… ⌘N Új dokumentum… ⌘N + New Layer from Brush Új réteg ecsetből + New Path Új útvonal + New Snapshot Új pillanatkép + New Video Group Új videócsoport + Newspaper Újságpapír + No Layer Comps Nincs rétegkompozíció + No Layers Nincs réteg + No recent files Nincs legutóbbi fájl + No Results Nincs találat + No Results Found Nincs találat + No Selection Nincs kijelölés + Nonlinear History Nemlineáris előzmények + Nothing to Redo Nincs mit újra végrehajtani + Nothing to Undo Nincs mit visszavonni + NTSC (1953) NTSC (1953) + Object Name / Title Objektum neve / cím + Opacity Mask Átlátszatlansági maszk + Open Image Kép megnyitása + Open… ⌘O Megnyitás… ⌘O + Operation canceled Művelet megszakítva + Operation cancelled Művelet megszakítva + Operation completed Művelet befejezve + Operation failed A művelet sikertelen + Overlay: Rule of Thirds Fedvény: harmadolási szabály + Override Action "Save" Commands A műveletsor „Mentés” parancsainak felülbírálása + Page Setup… Oldalbeállítás… + PAL/SECAM PAL/SECAM + Panel Options… Panelbeállítások… + Path to Illustrator… Útvonal Illustrator-fájlba… + PDF Presentation… PDF-bemutató… + Photo (2 × 3 in) Fotó (2 × 3 in) + Photo (4 × 6 in) Fotó (4 × 6 in) + Photo (5 × 7 in) Fotó (5 × 7 in) + Photo (8 × 10 in) Fotó (8 × 10 in) + Picture Package… Képcsomag… + Play Once Lejátszás egyszer + Please wait… Kis türelmet… + Preset Name: Előbeállítás neve: + Profile Mismatches: Ask When Opening Profileltérés: rákérdezés megnyitáskor + Province / State Megye / állam + Quick Sync Gyors szinkronizálás + Quit PhotoCraft Kilépés a PhotoCraftból + Recent Items Legutóbbi elemek + Reference point location Viszonyítási pont helye + Rendering… Renderelés… + Report a Bug Hiba bejelentése + Reset GPU Backend GPU-háttérrendszer alaphelyzetbe állítása + Restart Újraindítás + Restart Required Újraindítás szükséges + Restore last viewed comp Utoljára megtekintett rétegkompozíció visszaállítása + Retry saving presets Előbeállítások mentésének újrapróbálása + Roman Hanging Punctuation Latin függő írásjelek + Rotate Auto Forgatás: automatikus + Rotate Fixed Forgatás: rögzített + Ruler Units Vonalzó mértékegysége + Rulers (⌘R) Vonalzók (⌘R) + Save before closing? Mented bezárás előtt? + Save Changes Módosítások mentése + Save Image Kép mentése + Saved Multichannel Többcsatornásként mentve + Saving brush presets… Keep this tab open until saving finishes. Ecset-előbeállítások mentése… Ne zárd be ezt a lapot, amíg a mentés tart. + Saving… Mentés… + Scale Horizontally Vízszintes méretezés + Scale Vertically Függőleges méretezés + Search commands (⌘K) Parancsok keresése (⌘K) + Second Color Readout Második színkijelzés + Select Folder… Mappa kiválasztása… + Select Plane Sík kijelölése + Select Similar Colors Hasonló színek kijelölése + Set New Snapshot as Default Új pillanatkép beállítása alapértelmezettként + Set Start Timecode… Kezdő időkód beállítása… + Show Guides (⌘;) Segédvonalak megjelenítése (⌘;) + Show New Snapshot Dialog by Default Új pillanatkép ablakának megjelenítése alapértelmezés szerint + Show the layer's effects (⌥-click: all layers) Rétegeffektusok megjelenítése (⌥-kattintás: minden réteg) + Shutter Speed Záridő + Simple Egyszerű + Single Word Justification Egyszavas sorkizárás + Smart Object Layer Intelligensobjektum-réteg + SMPTE C SMPTE C + Snap to Grid Illesztés a rácshoz + Snapshot 1 Pillanatkép 1 + Some brush presets could not be loaded Egyes ecset-előbeállítások nem tölthetők be + Spacing: Térköz: + Split at Playhead Felosztás a lejátszófejnél + Split Toning Osztott tónusozás + Status Information Állapotinformáció + Step Forward Lépés előre + Stop For Errors Leállás hiba esetén + Subdivisions Felosztások + Subject Distance Tárgytávolság + Subscript Alsó index + Subtract from selection (⌥) Kivonás a kijelölésből (⌥) + Superscript Felső index + Supplemental Categories Kiegészítő kategóriák + Switch theme Témaváltás + Technology Previews Technológiai előzetesek + Technology Previews… Technológiai előzetesek… + The current gradient (pick one in Window › Gradients) A jelenlegi színátmenet (másikat az Ablak › Színátmenetek panelen választhatsz) + The file could not be opened. A fájl nem nyitható meg. + The file could not be saved. A fájl nem menthető. + Timecode Időkód + Toggle reference point Viszonyítási pont be/ki + Toolbars Eszköztárak + Total Ink Teljes festékmennyiség + Transmission Reference Továbbítási azonosító + Turbulent Turbulens + UI font size Felület betűmérete + Ultra Bold Extrakövér + Ultra Light Extravékony + Uncoated CMYK Bevonat nélküli CMYK + Unlocked Zárolás feloldva + Unsaved Changes Nem mentett módosítások + Unsupported file format Nem támogatott fájlformátum + Unsupported format Nem támogatott formátum + Urgency Sürgősség + Use File › Open to open an image. Kép megnyitásához használd a Fájl › Megnyitás parancsot. + Uses the foreground and background colors. Az előtér- és a háttérszínt használja. + Violet, Orange Ibolya, narancs + Watercolour look: lighter interior, darker rims. Akvarellhatás: világosabb belső rész, sötétebb szélek. + Web Photo Gallery… Webes fotógaléria… + WebP (lossless) WebP (veszteségmentes) + Wide Gamut RGB Wide Gamut RGB + Word Spacing Szótávolság + Working Gray Plate Szürke munkalemez + Working Space Munkaszíntér + Working Spaces Munkaszínterek + {path}: the path is not valid Unicode; rename the file and open it again. {path}: az elérési út nem érvényes Unicode-szöveg. Nevezd át a fájlt, és nyisd meg újra. + {zoom}% {zoom}% + ↑↓ navigate ↵ run esc close ↑↓ léptetés ↵ futtatás esc bezárás + Amount: Mérték: + Apply Vanishing Point ({key}) Enyészpont alkalmazása ({key}) + Apply the removal ({key}) Eltávolítás alkalmazása ({key}) + Drag to draw the curve · {key}-click draws a straight line Húzással rajzolja meg a görbét · {key}-kattintás egyenes vonalat húz + Draw to modify the curve Rajzoljon a görbe módosításához + Edit points to modify the curve Szerkessze a pontokat a görbe módosításához + Fix the red plane first: its corners must form a convex quad Először javítsa a piros síkot: sarkainak konvex négyszöget kell alkotniuk + Grid Size: Rácsméret: + Light Blue Világoskék + Light Red Világospiros + Medium Blue Középkék + Merge Group Csoport egyesítése + Protect skin tones Bőrtónusok védelme + Protect: Védelem: + Remove after each stroke Eltávolítás minden ecsetvonás után + Saving {percent} Mentés: {percent} + Scale or rotate the moved area, then press {key} Méretezze vagy forgassa el az áthelyezett területet, majd nyomja meg: {key} + Smooth the curve values A görbeértékek simítása + Strokes to remove: {n} (Enter applies, Esc cancels) Eltávolítandó ecsetvonások: {n} (Enter: alkalmazás, Esc: mégse) + The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzza újra a maszkot a Kukába. + Transform On Drop Átalakítás elengedéskor + Vanishing Point is not active Az Enyészpont nem aktív + Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index be8cb5c15e..fe6e8e680d 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -39,7 +39,7 @@ pub struct LangInfo { /// Catalog file contents, raw-deflated by build.rs (empty for the built-in English); see /// [`LangInfo::text`]. pub source: &'static [u8], - /// Plural form index for a count (English: 0 = one, 1 = other; Japanese and Chinese: always 0; + /// Plural form index for a count (English: 0 = one, 1 = other; Japanese, Chinese and Hungarian: always 0; /// Czech: 0 = one, 1 = few (2–4), 2 = other; French: 0 = one (0 and 1), 1 = other). A catalog's `@plural` entries list one form per /// index. pub plural: fn(u64) -> usize, @@ -141,7 +141,7 @@ fn inflate(source: &[u8]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 19] = [ +pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -164,6 +164,8 @@ pub static LANGUAGES: [LangInfo; 19] = [ // Dutch; `nl-NL` and `nl-BE` locales both resolve here. LangInfo { code: "nl", name: "Nederlands", source: catalog!("nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "it", name: "Italiano", source: catalog!("it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Hungarian: a count takes the singular noun ("3 réteg"), so one plural form. + LangInfo { code: "hu", name: "Magyar", source: catalog!("hu"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Turkish; `tr`, `tr-TR` and `tr_TR` locales resolve here. LangInfo { code: "tr", name: "Türkçe", source: catalog!("tr"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, // Vietnamese; `vi-VN` resolves here. @@ -420,6 +422,8 @@ mod tests { assert_eq!(lang_from_tag("it-CH"), Lang::from_code("it")); assert_eq!(lang_from_tag("nl_NL.UTF-8"), Lang::from_code("nl")); assert_eq!(lang_from_tag("nl-BE"), Lang::from_code("nl")); + assert_eq!(lang_from_tag("hu_HU.UTF-8"), Lang::from_code("hu")); + assert_eq!(lang_from_tag("hu-HU"), Lang::from_code("hu")); assert_eq!(lang_from_tag("tr_TR.UTF-8"), Lang::from_code("tr")); assert_eq!(lang_from_tag("tr-TR"), Lang::from_code("tr")); assert_eq!(lang_from_tag("tr"), Lang::from_code("tr")); @@ -518,6 +522,22 @@ mod tests { assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos"); } + #[test] + fn hungarian_resolves_and_keeps_the_singular_after_counts() { + let hu = Lang::from_code("hu").expect("hu registered"); + for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] { + assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); + } + assert!(hu.complete_menus()); + assert_eq!(hu.name(), "Magyar"); + assert_eq!(tr(hu, "Layer"), "Réteg"); + for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] { + assert_eq!(trn(hu, n, "{n} layer", "{n} layers"), expected); + } + assert_eq!(tr_ctx(hu, "layerLabel", "Red"), "Piros"); + assert_eq!(tr(hu, "Red"), "Vörös"); + } + #[test] fn brazilian_portuguese_resolves_and_pluralises() { let pt = Lang::from_code("pt-br").expect("pt-br registered"); diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 68afea8d85..0b9118386a 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -96,6 +96,7 @@ mod tests { (vec!["pt-PT"], "pt-br"), (vec!["it-IT", "en-US"], "it"), (vec!["nl-BE", "fr-BE"], "nl"), + (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), (vec![], "en"), ] { diff --git a/crates/ui-egui/tests/crop_default_mode.rs b/crates/ui-egui/tests/crop_default_mode.rs index 19e885a967..b16e628214 100644 --- a/crates/ui-egui/tests/crop_default_mode.rs +++ b/crates/ui-egui/tests/crop_default_mode.rs @@ -24,6 +24,8 @@ fn harness() -> Option { egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 640.0)).with_max_steps(64).wgpu().build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + // The queries below use English labels; don't follow the system locale. + app.session.prefs.edit(|p| p.interface.language = "en".into()); if let Some(rs) = cc.wgpu_render_state.as_ref() { app.set_wgpu(rs.clone()); } diff --git a/docs/localization.md b/docs/localization.md index 31737bf7a8..f05b261b36 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -25,6 +25,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Polski | `pl` | one / few (2–4, not 12–14) / many | | Ελληνικά | `el` | one / other | | Nederlands | `nl` | one / other | +| Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 2be43cb538..1cb64f1782 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -579,6 +579,7 @@ mod tests { ("el", "el.tsv"), ("uk", "uk.tsv"), ("nl", "nl.tsv"), + ("hu", "hu.tsv"), ("tr", "tr.tsv"), ("vi", "vi.tsv"), ] { From 3d6050b9318f27fd00e44c4b6813837b1a2ae7d2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:08:34 -0400 Subject: [PATCH 151/523] Release workflow: pass the Azure signing secrets so Windows builds are signed (#3794) Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85d0cd2c81..bfdca9e3e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,3 +35,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From 2db6b7d8f9ddb5be8d29f61b918133b22e41c5bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:11:02 -0400 Subject: [PATCH 152/523] Release 0.8.1: rebuild with the fixed shared release workflow The previous release build failed in craft-common-actions, not in this repo: riscv64 packaging got --formats deb,rpm,tar (fixed in craft-common-actions#3), Linux jobs lacked the ALSA headers (craft-common-actions#5), and Windows signing needed the AZURE_* secrets listed in the caller. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 58 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f17edd1171..be740d0ede 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.0" +version = "0.8.1" dependencies = [ "arboard", "async-io", @@ -4225,7 +4225,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.0" +version = "0.8.1" dependencies = [ "crc32fast", "flate2", @@ -4235,7 +4235,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "cap-std", @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4284,7 +4284,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.0" +version = "0.8.1" dependencies = [ "moxcms", "rayon", @@ -4292,7 +4292,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.0" +version = "0.8.1" dependencies = [ "exr", "flate2", @@ -4313,7 +4313,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-cms", "serde", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.0" +version = "0.8.1" dependencies = [ "criterion", "photocraft-color", @@ -4336,7 +4336,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.0" +version = "0.8.1" dependencies = [ "blake3", "exr", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.0" +version = "0.8.1" dependencies = [ "blake3", "crc32fast", @@ -4401,14 +4401,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.8.0" +version = "0.8.1" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.0" +version = "0.8.1" dependencies = [ "half", "log", @@ -4425,14 +4425,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.0" +version = "0.8.1" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.0" +version = "0.8.1" dependencies = [ "blake3", "crc32fast", @@ -4461,14 +4461,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4479,7 +4479,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4494,7 +4494,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.0" +version = "0.8.1" dependencies = [ "flate2", "photocraft-psd", @@ -4505,7 +4505,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4515,7 +4515,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.8.0" +version = "0.8.1" dependencies = [ "flate2", "image", @@ -4530,7 +4530,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.0" +version = "0.8.1" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4542,7 +4542,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.0" +version = "0.8.1" dependencies = [ "memory-stats", "photocraft-color", @@ -4553,7 +4553,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.0" +version = "0.8.1" dependencies = [ "flate2", "parley", @@ -4567,7 +4567,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.8.0" +version = "0.8.1" dependencies = [ "base64", "craft-ui", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.8.0" +version = "0.8.1" dependencies = [ "photocraft-color", "photocraft-doc", @@ -4617,7 +4617,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.0" +version = "0.8.1" dependencies = [ "eframe", "egui", @@ -7450,7 +7450,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.0" +version = "0.8.1" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 87e0a378ce..10bbfc3b4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.8.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 184c226a76c9ca6234d473912d438feaa48c1cb0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 11:48:28 -0400 Subject: [PATCH 153/523] Fix main: wasm size cap, Windows Export As test, filter preview window (scope and a crash), CLI duplicate font warnings (#4147) * test(ui-egui): Export As artboards test accepts the Windows path separator The test added in #3909 compared the second file's path with a literal '/', but the exporter joins the sibling name with the platform separator, so Windows CI saw '/out\boards-2-Capsule.jpg' and failed. Co-Authored-By: Claude Opus 5.5 * fix(ui): only Filter menu dialogs get the in-dialog preview window #3877 drew the preview window in every schema dialog with a Preview checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate Canvas and adjustment dialogs have no preview window, and the taller dialog covered the canvas, which broke arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center on main. The window and its reserved height now apply to filter.* commands only. Co-Authored-By: Claude Opus 5.5 * build(web): opt-level z for large cold crates in the wasm release profile The browser wasm sat at the 24 MiB gate. The document model, file parsers (psd, format, affinity), serde_json, usvg and naga are off the per-frame and per-pixel paths; opt-level "z" for them takes the release wasm from 25,186,233 to 24,829,840 bytes (-356 KB) with no behaviour change. Co-Authored-By: Claude Opus 5.5 * fix(cli): run prints each missing-font warning once Since #3630 the headless open and save also report missing fonts, so `run --out` and `run --cmd ... --out` printed the same warning two or three times and missing_typeface_is_reported_by_run_info_and_convert failed on main. Warnings from the open and the save now go through the same already-reported set as the per-command ones. Co-Authored-By: Claude Opus 5.5 * fix(ui): the filter preview window never uploads a texture past the GPU limit With low-resolution previews off, the preview window from #3877 uploaded the whole document as one texture. egui panics on a texture bigger than max_texture_side, so opening a filter dialog on a large image crashed (full_res_preview::filter_dialog_previews_at_full_resolution_when_low_res_is_off). The texture is now reduced to fit; the view maps through UV fractions, so it still lines up. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.toml | 18 +++++++++ apps/photocraft-cli/src/lib.rs | 9 ++++- crates/ui-egui/src/file_dialog/tests.rs | 4 +- crates/ui-egui/src/filter_preview_ui.rs | 52 ++++++++++++++++++++++++- docs/development.md | 2 +- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 10bbfc3b4a..6b13954fd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,24 @@ opt-level = "z" [profile.wasm-release.package.photocraft-web] opt-level = "z" +# Large crates off the per-frame and per-pixel paths: the document model, file parsers run once +# per open/save, JSON (settings, presets, control channel), SVG parsing, and shader translation +# (runs when a pipeline is created). "z" keeps the wasm well under the size gate. +[profile.wasm-release.package.photocraft-doc] +opt-level = "z" +[profile.wasm-release.package.photocraft-psd] +opt-level = "z" +[profile.wasm-release.package.photocraft-format] +opt-level = "z" +[profile.wasm-release.package.photocraft-affinity] +opt-level = "z" +[profile.wasm-release.package.serde_json] +opt-level = "z" +[profile.wasm-release.package.usvg] +opt-level = "z" +[profile.wasm-release.package.naga] +opt-level = "z" + [profile.wasm-release.package.photocraft-raster] opt-level = 3 [profile.wasm-release.package.photocraft-color] diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 560eaf71fc..93090610a5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -292,11 +292,15 @@ fn command_list(a: &Args) -> Result, String> { fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let opts = export_opts(a)?; let mut h = Headless::trusted_local(); + // Each missing-font warning is printed once, wherever it is first detected (open, a command or + // the save). + let mut reported_fonts = std::collections::HashSet::::new(); match (a.positional.as_slice(), a.get("--new")) { ([file], None) => { let o = h.open(Path::new(file)).map_err(|e| e.to_string())?; let ws: Vec = serde_json::from_value(o["warnings"].clone()).unwrap_or_default(); warn_all(err, &ws); + reported_fonts.extend(ws); } ([], Some(new)) => { let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?; @@ -308,7 +312,6 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { if cmds.is_empty() && a.get("--out").is_none() { return Err("run needs at least one --cmd (or --out)".into()); } - let mut reported_fonts = std::collections::HashSet::::new(); for (id, params) in cmds { let r = h.command_run(&id, params).map_err(|e| format!("`{id}`: {e}"))?; if let Some(e) = photocraft_engine::actions_cmds::nested_failure(&id, &r) { @@ -341,7 +344,9 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { warn_all(err, &remaining); if let Some(o) = a.get("--out") { let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?; - let ws: Vec = serde_json::from_value(r["warnings"].clone()).unwrap_or_default(); + // The save repeats the document's missing-font warnings; each was already reported above. + let ws: Vec = + serde_json::from_value::>(r["warnings"].clone()).unwrap_or_default().into_iter().filter(|w| !reported_fonts.contains(w)).collect(); warn_all(err, &ws); } Ok(()) diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 3081de03f7..202cc73888 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -719,7 +719,9 @@ fn export_as_with_several_artboards_selected_writes_one_file_each() { assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(written.borrow().len(), 2); assert_eq!(written.borrow()[0], "/out/boards.jpg"); - assert!(written.borrow()[1].starts_with("/out/boards-2-") && written.borrow()[1].ends_with(".jpg"), "{:?}", written.borrow()); + // The sibling path is joined with the platform separator (`/out\boards-2-…` on Windows). + let second = written.borrow()[1].replace('\\', "/"); + assert!(second.starts_with("/out/boards-2-") && second.ends_with(".jpg"), "{:?}", written.borrow()); let mut got = sizes.borrow().iter().map(|(_, w, h)| (*w, *h)).collect::>(); got.sort_unstable(); assert_eq!(got, [(10, 25), (20, 15)], "each board at its own size, scaled to 50 %"); diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index 5f4ac13924..bf2cb47b58 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -30,7 +30,7 @@ const CENTER_Y_FIELD: &str = "__pvCy"; /// Draw the filter dialog's in-dialog preview window below its parameters (its `fields` `f`). A /// no-op for dialogs without a preview and for Select and Mask, which has its own view. pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mut Map) { - if !f.contains_key("__preview") || crate::select_mask_view::owns(f) { + if !shows(f) { return; } let Some(cmd) = f.get("__command").and_then(Value::as_str).map(str::to_owned) else { return }; @@ -145,7 +145,14 @@ const ZOOM_STEPS: [f32; 7] = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; /// The vertical space the preview window takes below the parameters: the image, the zoom row and /// their spacing. `0` when the dialog has no preview (so the caller reserves nothing). pub(crate) fn reserved_height(f: &Map) -> f32 { - if f.contains_key("__preview") && !crate::select_mask_view::owns(f) { PREVIEW_H + 32.0 } else { 0.0 } + if shows(f) { PREVIEW_H + 32.0 } else { 0.0 } +} + +/// Whether the dialog with fields `f` gets the preview window: Filter menu dialogs with a Preview +/// checkbox, as in Photoshop. Image › Rotation › Arbitrary and the adjustment dialogs keep only +/// the on-canvas preview, and Select and Mask has its own view. +fn shows(f: &Map) -> bool { + f.contains_key("__preview") && !crate::select_mask_view::owns(f) && f.get("__command").and_then(Value::as_str).is_some_and(|c| c.starts_with("filter.")) } /// The next zoom level up or down from `zoom`, clamped to [`ZOOM_STEPS`]' ends. @@ -194,12 +201,29 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { /// A composite as monitor pixels, matching the canvas (`canvas::display_image`). fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { + // Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution + // previews off the proxy is the full document. The view maps through UV fractions, so a reduced + // texture still lines up. + let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); + let reduced; + let doc = if k > 1 { + reduced = crate::proxy::proxy_document(doc, k); + &reduced + } else { + doc + }; let buf = photocraft_compose::flatten(doc); let (display, _) = crate::canvas::canvas_display(app, doc, None); let img = crate::canvas::display_image(display.as_deref(), &buf); ctx.load_texture(name, img, egui::TextureOptions::LINEAR) } +/// The reduction that fits a `w`×`h` image inside a `max_side` texture (1: it already fits). +fn texture_factor(w: u32, h: u32, max_side: usize) -> u32 { + let max_side = u32::try_from(max_side).unwrap_or(u32::MAX).max(1); + w.max(h).div_ceil(max_side).max(1) +} + /// What a cached texture was built from, so it is rebuilt only when its source changes. #[derive(Clone, PartialEq)] enum TexKey { @@ -238,6 +262,30 @@ mod tests { assert_eq!(step_zoom(f32::NAN, false), MIN_ZOOM); } + #[test] + fn only_filter_dialogs_get_the_preview_window() { + let fields = |cmd: &str| json!({"__preview": true, "__command": cmd}).as_object().cloned().unwrap(); + assert!(shows(&fields("filter.blur.gaussianBlur"))); + assert!(reserved_height(&fields("filter.blur.gaussianBlur")) > 0.0); + // Photoshop's Rotate Canvas and adjustment dialogs have no preview window. + assert!(!shows(&fields("image.rotation.arbitrary"))); + assert_eq!(reserved_height(&fields("image.rotation.arbitrary")), 0.0); + assert!(!shows(&fields("adjust.levels"))); + // No Preview checkbox or no command: no window. + assert!(!shows(json!({"__command": "filter.blur.gaussianBlur"}).as_object().unwrap())); + assert!(!shows(json!({"__preview": true}).as_object().unwrap())); + } + + #[test] + fn textures_are_reduced_to_fit_the_gpu() { + assert_eq!(texture_factor(3000, 2000, 2048), 2); + assert_eq!(texture_factor(2048, 100, 2048), 1); + assert_eq!(texture_factor(9000, 9000, 2048), 5); + // A zero or absurd limit never divides by zero. + assert_eq!(texture_factor(3000, 2000, 0), 3000); + assert_eq!(texture_factor(0, 0, 2048), 1); + } + #[test] fn the_centre_is_clamped_to_the_document() { assert_eq!(clamp_center(-5.0, 99.0, 100.0, 50.0), (0.0, 50.0)); diff --git a/docs/development.md b/docs/development.md index 1236dd65c5..74b61de1d5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -352,7 +352,7 @@ trunk build --release # writes ../../dist/web (index.html, .js glue trunk serve --release # dev server on http://127.0.0.1:8765 ``` -Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for `photocraft-ui-egui`, "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). +Any static file server works for `dist/web`, for example `python3 -m http.server 8765` run inside that directory. Trunk downloads the matching `wasm-bindgen` and `wasm-opt` itself. `trunk build --release` uses the `wasm-release` Cargo profile (`data-cargo-profile` in `index.html`: fat LTO, opt-level "z" for the UI, engine, I/O and web shells and for large crates off the hot paths (document model, file parsers, JSON, SVG parsing, shader translation), "s" for other crates except pixel processing, which keeps level 3). The UI override reduces browser code size while retaining the speed-oriented settings for filters and compositing. Serve the `.wasm` with compression; its raw size varies with the source and compiler. Keep it under 24 MiB (`packaging/web/package.sh` enforces this; Cloudflare's per-file cap is 25 MiB). Pull-request CI also builds the actual browser app with the same pinned Trunk version as the release job and runs `packaging/web/package.sh`, including the relative-URL and 24 MiB checks. The `web build and wasm size gate` check runs on main and on PRs that change something the web build compiles or packages (`apps/photocraft-web`, `packaging/web`, crate sources and manifests, `Cargo.toml`/`Cargo.lock`, the CI workflow), and reports the resulting wasm size in the Actions job summary. The web build never embeds craft-fonts (see Fonts above). To see where the bytes go, run `twiggy top -n 40` on `target/wasm32-unknown-unknown/wasm-release/photocraft-web.wasm` (before wasm-opt strips the names). URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. From d43db3e153c76aefce9b32cef9099e458eaf17a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:57:00 -0400 Subject: [PATCH 154/523] Release 0.8.2: web build back under the wasm size gate 0.8.1's Web job failed: photocraft-web's wasm was 25,587,059 bytes, over packaging/web/package.sh's 24 MiB gate. #4253 merged with #4147 brings it to 24,161,781. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 58 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7bcedc1ace..ec1ddff119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "photocraft" -version = "0.8.1" +version = "0.8.2" dependencies = [ "arboard", "async-io", @@ -4225,7 +4225,7 @@ dependencies = [ [[package]] name = "photocraft-affinity" -version = "0.8.1" +version = "0.8.2" dependencies = [ "crc32fast", "flate2", @@ -4235,7 +4235,7 @@ dependencies = [ [[package]] name = "photocraft-algo" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "photocraft-automation" -version = "0.8.1" +version = "0.8.2" dependencies = [ "base64", "cap-std", @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "photocraft-cli" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-automation", "photocraft-codecs", @@ -4284,7 +4284,7 @@ dependencies = [ [[package]] name = "photocraft-cms" -version = "0.8.1" +version = "0.8.2" dependencies = [ "moxcms", "rayon", @@ -4292,7 +4292,7 @@ dependencies = [ [[package]] name = "photocraft-codecs" -version = "0.8.1" +version = "0.8.2" dependencies = [ "exr", "flate2", @@ -4313,7 +4313,7 @@ dependencies = [ [[package]] name = "photocraft-color" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-cms", "serde", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "photocraft-compose" -version = "0.8.1" +version = "0.8.2" dependencies = [ "criterion", "photocraft-color", @@ -4336,7 +4336,7 @@ dependencies = [ [[package]] name = "photocraft-doc" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "photocraft-engine" -version = "0.8.1" +version = "0.8.2" dependencies = [ "blake3", "exr", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "photocraft-format" -version = "0.8.1" +version = "0.8.2" dependencies = [ "blake3", "crc32fast", @@ -4401,14 +4401,14 @@ dependencies = [ [[package]] name = "photocraft-geom" -version = "0.8.1" +version = "0.8.2" dependencies = [ "serde", ] [[package]] name = "photocraft-gpu" -version = "0.8.1" +version = "0.8.2" dependencies = [ "half", "log", @@ -4425,14 +4425,14 @@ dependencies = [ [[package]] name = "photocraft-heif" -version = "0.8.1" +version = "0.8.2" dependencies = [ "heic-rs", ] [[package]] name = "photocraft-io" -version = "0.8.1" +version = "0.8.2" dependencies = [ "blake3", "crc32fast", @@ -4461,14 +4461,14 @@ dependencies = [ [[package]] name = "photocraft-ops" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-doc", ] [[package]] name = "photocraft-paint" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4479,7 +4479,7 @@ dependencies = [ [[package]] name = "photocraft-plugins" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4494,7 +4494,7 @@ dependencies = [ [[package]] name = "photocraft-psd" -version = "0.8.1" +version = "0.8.2" dependencies = [ "flate2", "photocraft-psd", @@ -4505,7 +4505,7 @@ dependencies = [ [[package]] name = "photocraft-raster" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-geom", @@ -4515,7 +4515,7 @@ dependencies = [ [[package]] name = "photocraft-raw" -version = "0.8.1" +version = "0.8.2" dependencies = [ "flate2", "image", @@ -4530,7 +4530,7 @@ dependencies = [ [[package]] name = "photocraft-tablet" -version = "0.8.1" +version = "0.8.2" dependencies = [ "block2 0.6.2", "objc2 0.6.4", @@ -4542,7 +4542,7 @@ dependencies = [ [[package]] name = "photocraft-testkit" -version = "0.8.1" +version = "0.8.2" dependencies = [ "memory-stats", "photocraft-color", @@ -4553,7 +4553,7 @@ dependencies = [ [[package]] name = "photocraft-text" -version = "0.8.1" +version = "0.8.2" dependencies = [ "flate2", "parley", @@ -4567,7 +4567,7 @@ dependencies = [ [[package]] name = "photocraft-ui-egui" -version = "0.8.1" +version = "0.8.2" dependencies = [ "base64", "craft-ui", @@ -4607,7 +4607,7 @@ dependencies = [ [[package]] name = "photocraft-vector" -version = "0.8.1" +version = "0.8.2" dependencies = [ "photocraft-color", "photocraft-doc", @@ -4618,7 +4618,7 @@ dependencies = [ [[package]] name = "photocraft-web" -version = "0.8.1" +version = "0.8.2" dependencies = [ "eframe", "egui", @@ -7451,7 +7451,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" -version = "0.8.1" +version = "0.8.2" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f075c89d03..811ee7cbee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"] [workspace.package] -version = "0.8.1" +version = "0.8.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 91b08488c85b7cb255df82735725df664d358560 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Mon, 12 Oct 2026 20:55:44 +0200 Subject: [PATCH 155/523] Validate paired purge availability in MCP resource tests --- crates/automation/tests/mcp.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index af5459af1f..ca34d9f915 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -1236,11 +1236,16 @@ async fn convention_resources_are_live_json() { assert_eq!(read("photocraft://document").await["width"], 8); // Edit › Purge › All is enabled while the process-wide effect, mask and stack caches hold // anything, and the other tests in this binary fill and purge those caches concurrently, so - // its `enabled` flag can flip between the two reads. Every other field must match exactly. + // its `enabled` flag and paired `disabledReason` can flip between the two reads. Validate + // each availability pair; every stable field and every other command must match exactly. let stable = |mut v: Value| { for c in v.as_array_mut().into_iter().flatten() { if c["id"] == "edit.purge.all" { - c.as_object_mut().map(|o| o.remove("enabled")); + let reason = if c["enabled"].as_bool().unwrap() { Value::Null } else { json!("nothing to purge") }; + assert_eq!(c["disabledReason"], reason, "purge availability stays internally consistent"); + let command = c.as_object_mut().unwrap(); + command.remove("enabled"); + command.remove("disabledReason"); } } v From 5dac5c6d61964fd5387eb5fc3462b024d4b3c6f9 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 23:33:30 +0200 Subject: [PATCH 156/523] Tests: allow-list language-invariant bit-depth labels, add missing tr/hu 'Enable animations' (#4359) (#4362) * Tests: language-invariant bit-depth labels are allow-listed; add missing tr/hu 'Enable animations' (#4359) Turkish writes '8 bit'/'16 bit' exactly like the English source, so the translated-differs assertion needs an explicit allow-list (the entry must still exist). hu and tr had no 'Enable animations' preference label. Co-Authored-By: Claude Opus 5.5 * Merge main; drop the Enable animations rows main already has Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/new_doc_ui.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index e3f00c6c9c..4af4755085 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -774,9 +774,16 @@ mod tests { #[test] fn new_document_depth_labels_and_tooltips_are_translated() { + // Bit-depth labels are unit/number strings that some languages legitimately write exactly + // like the English source (Turkish "8 bit"); they only need a catalog entry there. + const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit")]; for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { for (_, label, tooltip) in DEPTH_OPTIONS { - assert_ne!(crate::i18n::tr(lang, label), *label, "{}: {label}", lang.code()); + if SAME_AS_SOURCE.contains(&(lang.code(), *label)) { + assert!(crate::i18n::has(lang, label), "{}: {label}", lang.code()); + } else { + assert_ne!(crate::i18n::tr(lang, label), *label, "{}: {label}", lang.code()); + } assert_ne!(crate::i18n::tr(lang, tooltip), *tooltip, "{}: {tooltip}", lang.code()); } } From e753a718514ae89fe0c7977b8223828f9e53fb91 Mon Sep 17 00:00:00 2001 From: buivanvu94 <125793064+buivanvu94@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:03:32 +0700 Subject: [PATCH 157/523] feat(automation): allow Crop and Straighten Photos through automation (#1052) (#3917) --- crates/automation/src/workspace.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 76a14c6e34..d34219e762 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -252,6 +252,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati | "file.fileInfo" | "file.automate.fitImage" | "file.automate.conditionalModeChange" + | "file.automate.cropAndStraightenPhotos" | "file.scripts.flattenAllLayerEffects" | "file.scripts.flattenAllMasks" | "file.scripts.deleteAllEmptyLayers" @@ -695,6 +696,8 @@ mod tests { assert!(authorize_engine_command(id, &serde_json::json!({})).is_err()); } assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok()); + assert!(authorize_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok()); + assert!(authorize_desktop_engine_command("file.automate.cropAndStraightenPhotos", &serde_json::json!({})).is_ok()); for id in ["layer.smartObjects.editContents", "layer.smartObjects.convertToLayers", "layer.smartObjects.saveContents"] { assert!(authorize_desktop_engine_command(id, &serde_json::json!({"layer": 19})).is_ok(), "{id}"); } From ddf6696dce5aa809ef43c6da31fda57750573213 Mon Sep 17 00:00:00 2001 From: John Alister Date: Mon, 12 Oct 2026 19:10:29 -0300 Subject: [PATCH 158/523] Pencil: no 8-bit dither on aliased strokes, one flat colour at partial opacity (#3990) The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including aliased ones. An aliased stroke paints each pixel by the same amount, so it has no gradient to band; dithering only scattered it over neighbouring levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a foreground between 8-bit levels did the same at 100%. Photoshop's Pencil paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or Paint Bucket and Indexed Color palettes trip on the grain. Composite now skips the dither when the brush is aliased (Pencil, Eraser Pencil and Block modes). Soft brushes keep it. Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine 1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean. Co-authored-by: cloudalister Co-authored-by: Claude Opus 5.5 --- crates/paint/src/brush_tests.rs | 15 +++++++++++++++ crates/paint/src/render.rs | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 664aca9c06..04f74ed840 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1400,3 +1400,18 @@ fn dithering_never_moves_an_exact_colour_or_an_untouched_pixel() { } assert_eq!(s.rgba(50, 5), [1.0, 1.0, 1.0, 1.0], "far from the stroke: untouched"); } + +#[test] +fn an_aliased_stroke_at_partial_opacity_is_one_flat_colour() { + // 30% of red 219/41/61 over white lands between 8-bit levels (244.2, 190.7, 196.8); dithering + // used to scatter the Pencil's line over several neighbouring levels. + let mut s = Surface::with_default(PixelFormat::RGBA8, &[1.0, 1.0, 1.0, 1.0]); + let color = [219.0 / 255.0, 41.0 / 255.0, 61.0 / 255.0, 1.0]; + let b = BrushSettings { size: 1.0, aliased: true, opacity: 0.3, color, pressure_size: false, ..Default::default() }; + render_stroke(&mut s, &b, &line(2.0, 60.0, 5.0), None, false, 1.0); + let first = s.rgba(3, 5); + assert_ne!(first, [1.0, 1.0, 1.0, 1.0], "the line was painted"); + for x in 3..60 { + assert_eq!(s.rgba(x, 5), first, "pixel {x} of the Pencil line"); + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 94ac63a793..688232c93e 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -970,8 +970,11 @@ impl StrokeRenderer { src[a] = b.color[3]; } // 8-bit targets: the pixels the stroke changes are dithered as they are quantised, so - // soft, low-flow strokes don't band (see `dither8`). - let dither = fmt.sample == SampleType::U8; + // soft, low-flow strokes don't band (see `dither8`). Aliased strokes (the Pencil, the + // Eraser's Pencil and Block modes) paint every pixel by the same amount, so they have no + // gradient to band and are not dithered: a Pencil line at partial opacity is one flat + // colour, as in Photoshop, not grain that a tolerance-0 Magic Wand or Paint Bucket trips on. + let dither = fmt.sample == SampleType::U8 && !b.aliased; let mut dmg = Rect::EMPTY; let mut region = Vec::new(); for (tx, ty) in keys { From 50b97605fb1d6ce623ec34f760edc91c6a86deb2 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 06:13:21 +0800 Subject: [PATCH 159/523] Convert grayscale profiles before untagged exports (#3978) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/io/src/flat.rs | 5 ++- crates/io/tests/flat.rs | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index 129282e61b..bcb9422e89 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -489,11 +489,14 @@ pub(crate) fn export_flat_with(doc: &Document, format: Format, opts: &ExportOpti img = linear; } } else if !format.caps().icc { - // Untagged files read back as sRGB: convert to it (as Quick Export does) rather than + // Untagged files read back as sRGB/sGray: convert to it (as Quick Export does) rather than // write values that only mean something under the dropped profile. if let Some(srgb) = convert_rgb(&img, Builtin::Srgb.profile(), Intent::Perceptual, true, img.sample_type())? { img = srgb; warnings.push(format!("colours converted to sRGB; {format:?} can't embed the document's colour profile")); + } else if let Some(gray) = convert_gray(&img, Builtin::SGray.profile(), img.sample_type())? { + img = gray; + warnings.push(format!("colours converted to sGray; {format:?} can't embed the document's colour profile")); } } if format == Format::Tga { diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 4b184ad7fa..48d6944c72 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -503,6 +503,73 @@ fn non_srgb_rgb_is_converted_to_srgb_for_formats_without_a_profile() { } } +/// A linear gray document needs the same display encoding as linear RGB when its profile +/// cannot be embedded. Check decoded samples against the transfer function independently. +#[test] +fn linear_gray_exports_without_profiles_use_display_encoding() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 1.0], &[0.5, 1.0]]); + d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); + let stored = column_pixels(&d, 2); + for ext in ["tga", "bmp", "gif", "qoi"] { + let r = export(&d, ext, &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + let values = img.to_normalized(); + let stride = img.layout().channels(); + for (i, src) in stored.iter().enumerate() { + let expected = photocraft_color::convert::linear_to_srgb(src[0]); + assert!( + (values[16 * i * stride] - expected).abs() <= 1.5 / 255.0, + "{depth:?} {ext} pixel {i}: got {}, expected {expected}", + values[16 * i * stride] + ); + } + assert!(r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?} {ext}: {:?}", r.warnings); + } + } +} + +#[test] +fn default_gray_exports_without_profiles_keep_samples() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for icc in [None, Some(photocraft_cms::Builtin::SGray.profile().to_bytes())] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 1.0], &[0.5, 1.0]]); + d.icc_profile = icc; + let stored = column_pixels(&d, 2); + for ext in ["tga", "bmp", "gif", "qoi"] { + let r = export(&d, ext, &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + let values = img.to_normalized(); + let stride = img.layout().channels(); + for (i, src) in stored.iter().enumerate() { + assert!((values[16 * i * stride] - src[0]).abs() <= 1.0 / 255.0, "{depth:?} {ext} pixel {i}"); + } + assert!(!r.warnings.iter().any(|w| w.contains("converted to sGray")), "{depth:?} {ext}: {:?}", r.warnings); + } + } + } +} + +#[test] +fn linear_gray_export_preserves_alpha_and_embeddable_profiles() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut d = columns(ColorMode::Grayscale, depth, &[&[0.25, 0.5]]); + d.icc_profile = Some(photocraft_cms::builtin::linear_gray().to_bytes()); + let stored = column_pixels(&d, 1); + let r = export(&d, "tga", &ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap(); + assert_eq!(img.layout(), photocraft_codecs::ChannelLayout::GrayA); + let values = img.to_normalized(); + let expected = photocraft_color::convert::linear_to_srgb(stored[0][0]); + assert!((values[0] - expected).abs() <= 1.5 / 255.0, "{depth:?}: gray {} vs {expected}", values[0]); + assert!((values[1] - stored[0][1]).abs() <= 1.0 / 255.0, "{depth:?}: alpha changed"); + let r = export(&d, "png", &ExportOptions::default()).unwrap(); + let back = import("gray.png", &r.bytes).unwrap().document; + assert_eq!(back.icc_profile, d.icc_profile, "{depth:?}: PNG keeps linear gray ICC"); + assert_colors(&column_pixels(&back, 1), &stored, 1.0 / 255.0, "PNG keeps gray samples"); + } +} + /// #518: a JPEG cut off inside its image data opens with a warning, never silently. #[test] fn truncated_jpeg_imports_with_a_warning() { From d32d3f41bb77ac481445790330bc71c7b25fe550 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 00:19:22 +0200 Subject: [PATCH 160/523] Save As: a saved .pcraft manifest reports the file it was saved under (#3312) (#3571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit photocraft_io::named_for_file(doc, name) serialises the document under the file it is being written to, so a .pcraft manifest carries the identity the save is about to adopt rather than the previous one — the same rule every other format follows on import, where document.name comes from the file it was read from. A bare extension names no file and changes nothing. Applied once, in the .pcraft branch of export_stages, which export and export_with both funnel through: the desktop Save (services.rs:242), Export (services.rs:384), the web build (web.rs:355) and the engine file command (file_cmds.rs:295) are covered by one site, and the previews rendered before the bytes describe the same document as the manifest. Scope per review: the automation funnels (files::save / save_bytes) already landed in #3319 with their own named_for_output, so this carries no crates/automation change; its scenarios are covered by crates/automation/tests/native_save_name.rs on main. --- crates/io/src/lib.rs | 23 +++++++++++++-- crates/io/tests/pcraft_manifest_name.rs | 38 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 crates/io/tests/pcraft_manifest_name.rs diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0a9acbc19a..c387dc4e30 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -310,6 +310,22 @@ fn has_extension(name: &str, expected: &str) -> bool { name.rsplit(['/', '\\']).next().and_then(|name| name.rsplit_once('.')).is_some_and(|(base, ext)| !base.is_empty() && ext.eq_ignore_ascii_case(expected)) } +/// The document as it is written to `name` (a file name, path or bare extension): a copy whose +/// name is that file's own, so a `.pcraft` manifest reports the file it lives in — the name the +/// session adopts on the save that wrote it, and the name every other format gives the document +/// when it is opened (#3312). A bare extension ("pcraft") names no file and changes nothing. +pub fn named_for_file<'a>(doc: &'a Document, name: &str) -> std::borrow::Cow<'a, Document> { + let file = name.contains(['/', '\\', '.']).then(|| name.rsplit(['/', '\\']).next().unwrap_or(name)); + match file { + Some(file) if !file.is_empty() && doc.name != file => { + let mut owned = (*doc).clone(); + owned.name = file.to_string(); + std::borrow::Cow::Owned(owned) + } + _ => std::borrow::Cow::Borrowed(doc), + } +} + /// Exports `doc` to the format named by `name_or_ext` (a file name, path or /// bare extension). pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result { @@ -355,14 +371,15 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return ora::export(doc); } if ext == photocraft_format::EXTENSION { - let thumbnail = Some(photocraft_compose::thumbnail(doc, 256)); + let doc = named_for_file(doc, name_or_ext); + let thumbnail = Some(photocraft_compose::thumbnail(doc.as_ref(), 256)); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_THUMBNAIL_DONE); - let composite = Some(photocraft_compose::thumbnail(doc, 1024)); + let composite = Some(photocraft_compose::thumbnail(doc.as_ref(), 1024)); ctl.check().map_err(|_| IoError::Cancelled)?; ctl.progress(PCRAFT_PREVIEWS_DONE); let previews = photocraft_format::SaveOptions { thumbnail, composite }; - return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc, &previews)?, warnings: Vec::new() }); + return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; diff --git a/crates/io/tests/pcraft_manifest_name.rs b/crates/io/tests/pcraft_manifest_name.rs new file mode 100644 index 0000000000..285fa02203 --- /dev/null +++ b/crates/io/tests/pcraft_manifest_name.rs @@ -0,0 +1,38 @@ +//! A `.pcraft` bundle's manifest reports the file it is in (#3312): writing a document to a new +//! name must not leave the previous one inside, on every path that produces `.pcraft` bytes. + +mod common; + +use common::*; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_format::zip::ZipReader; +use photocraft_io::*; + +fn doc() -> photocraft_doc::Document { + let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); + d.name = "FirstTest.psd".into(); + d +} + +/// `document.name` as the bundle actually stores it. +fn manifest_name(bytes: &[u8]) -> String { + let archive = ZipReader::new(bytes).expect("zip"); + let manifest: serde_json::Value = serde_json::from_slice(&archive.read_by_name("manifest.json", usize::MAX).expect("manifest")).expect("manifest json"); + manifest["document"]["name"].as_str().expect("document.name").to_string() +} + +/// Save As to a new name: the written file names itself, so reopening it agrees with the session +/// that wrote it instead of reporting the file it came from. +#[test] +fn export_names_the_manifest_after_the_file() { + let r = export(&doc(), "/tmp/somewhere/_nametest.pcraft", &ExportOptions::default()).expect("export"); + assert_eq!(manifest_name(&r.bytes), "_nametest.pcraft"); + assert_eq!(photocraft_format::load_from_bytes(&r.bytes).expect("load").name, "_nametest.pcraft"); +} + +/// A bare extension names no file and leaves the document's own name alone. +#[test] +fn a_bare_extension_names_no_file() { + let r = export(&doc(), "pcraft", &ExportOptions::default()).expect("export"); + assert_eq!(manifest_name(&r.bytes), "FirstTest.psd"); +} From 63fd5ec7b64da940288025837cf4a8f45e149e4c Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Tue, 13 Oct 2026 06:26:39 +0800 Subject: [PATCH 161/523] Selection: lasso polygons scan only the edges that cross each row (#3959) * Selection: lasso polygons scan only the edges that cross each row The polygon fill behind Lasso, Polygonal Lasso and Magnetic Lasso tested every edge on every sub-row of the whole document, O(height x 4 x points), on one thread. It now keeps an active edge list over the edges sorted by their top and fills bands of 64 rows in parallel. Crossings use the same formula and spans accumulate in the same sorted order, so the mask is bit identical; a test compares it with the previous fill. * Selection: the lasso fill bands go through par_rows; edges are a named struct --- crates/algo/src/selection.rs | 196 +++++++++++++++++++++++++++++------ 1 file changed, 167 insertions(+), 29 deletions(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 4ce834d209..ebe8a42701 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -766,47 +766,84 @@ pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec { } /// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal -/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). +/// coverage, 4 sub-rows per pixel row (or 1 when not anti-aliased). Bands of +/// rows are filled in parallel, and each sub-row tests only the edges that +/// cross it (an active edge list over the edges sorted by their top). pub fn polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec { let (w, h) = (area.width() as usize, area.height() as usize); let mut m = vec![0.0f32; w * h]; - if points.len() < 3 { + if points.len() < 3 || w == 0 { return m; } let sub = if anti_alias { 4 } else { 1 }; - let mut xs: Vec = Vec::new(); - for py in 0..h { - for s in 0..sub { - let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32; - xs.clear(); - for i in 0..points.len() { - let (a, b) = (points[i], points[(i + 1) % points.len()]); - if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) { - xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); - } + let sample_y = |py: usize, s: usize| area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32; + // A sub-row at y crosses the edge a→b iff top <= y < bottom; horizontal and NaN edges + // never cross one. + struct Edge { + top: f32, + bottom: f32, + a: (f32, f32), + b: (f32, f32), + } + let mut edges: Vec = points + .iter() + .zip(points.iter().cycle().skip(1)) + .filter_map(|(&a, &b)| { + if a.1 < b.1 { + Some(Edge { top: a.1, bottom: b.1, a, b }) + } else if b.1 < a.1 { + Some(Edge { top: b.1, bottom: a.1, a, b }) + } else { + None } - xs.sort_by(|a, b| a.total_cmp(b)); - for span in xs.as_chunks::<2>().0 { - let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32)); - if x1 <= x0 { + }) + .collect(); + edges.sort_by(|p, q| p.top.total_cmp(&q.top)); + const BAND: usize = 64; + par_rows(&mut m, w, BAND, |bi, rows| { + let py0 = bi * BAND; + let (first, last) = (sample_y(py0, 0), sample_y(py0 + rows.len() / w - 1, sub - 1)); + let reach = edges.partition_point(|e| e.top <= last); + let band_edges = edges.get(..reach).unwrap_or_default(); + let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable(); + let mut active: Vec<&Edge> = Vec::new(); + let mut xs: Vec = Vec::new(); + for (r, row) in rows.chunks_exact_mut(w).enumerate() { + for s in 0..sub { + // Sub-row ys never decrease, so an edge whose bottom is passed stays out. + let y = sample_y(py0 + r, s); + while let Some(e) = pending.next_if(|e| e.top <= y) { + active.push(e); + } + active.retain(|e| e.bottom > y); + if active.is_empty() { continue; } - let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w)); - for cx in c0..c1 { - let cov = if anti_alias { - ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) - } else { - let c = cx as f32 + 0.5; - if c >= x0 && c < x1 { 1.0 } else { 0.0 } - }; - m[py * w + cx] += cov.max(0.0) / sub as f32; + xs.clear(); + xs.extend(active.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0))); + xs.sort_by(|a, b| a.total_cmp(b)); + for span in xs.as_chunks::<2>().0 { + let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32)); + if x1 <= x0 { + continue; + } + let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w)); + for (cx, v) in row.iter_mut().enumerate().take(c1).skip(c0) { + let cov = if anti_alias { + ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) + } else { + let c = cx as f32 + 0.5; + if c >= x0 && c < x1 { 1.0 } else { 0.0 } + }; + *v += cov.max(0.0) / sub as f32; + } } } } - } - for v in &mut m { - *v = v.min(1.0); - } + for v in rows { + *v = v.min(1.0); + } + }); m } @@ -1161,6 +1198,107 @@ mod tests { assert!(polygon(&[(0.0, 0.0), (1.0, 1.0)], a, true).iter().all(|v| *v == 0.0)); } + /// The previous polygon fill (every edge tested on every sub-row), kept as the oracle. + fn old_polygon(points: &[(f32, f32)], area: Rect, anti_alias: bool) -> Vec { + let (w, h) = (area.width() as usize, area.height() as usize); + let mut m = vec![0.0f32; w * h]; + if points.len() < 3 { + return m; + } + let sub = if anti_alias { 4 } else { 1 }; + let mut xs: Vec = Vec::new(); + for py in 0..h { + for s in 0..sub { + let y = area.y0 as f32 + py as f32 + (s as f32 + 0.5) / sub as f32; + xs.clear(); + for i in 0..points.len() { + let (a, b) = (points[i], points[(i + 1) % points.len()]); + if (a.1 <= y && b.1 > y) || (b.1 <= y && a.1 > y) { + xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); + } + } + xs.sort_by(|a, b| a.total_cmp(b)); + for span in xs.as_chunks::<2>().0 { + let (x0, x1) = ((span[0] - area.x0 as f32).max(0.0), (span[1] - area.x0 as f32).min(w as f32)); + if x1 <= x0 { + continue; + } + let (c0, c1) = (x0.floor() as usize, (x1.ceil() as usize).min(w)); + for cx in c0..c1 { + let cov = if anti_alias { + ((cx + 1) as f32).min(x1) - (cx as f32).max(x0) + } else { + let c = cx as f32 + 0.5; + if c >= x0 && c < x1 { 1.0 } else { 0.0 } + }; + m[py * w + cx] += cov.max(0.0) / sub as f32; + } + } + } + } + for v in &mut m { + *v = v.min(1.0); + } + m + } + + #[test] + fn polygon_matches_the_every_edge_fill_bit_for_bit() { + let mut seed = 0x2545_f491_u32; + let mut rnd = move || { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed as f32 / u32::MAX as f32 + }; + // Taller than one 64-row band, offset, and a 1-pixel-wide area. + let areas = [Rect::new(0, 0, 90, 150), Rect::new(-13, 7, 60, 211), Rect::new(5, -40, 6, 30)]; + let mut cases: Vec> = vec![ + // Self-intersecting star, horizontal edges, repeated points, collinear runs. + (0..11) + .map(|i| { + let t = i as f32 * 4.0 * std::f32::consts::PI / 11.0; + (40.0 + 35.0 * t.cos(), 80.0 + 70.0 * t.sin()) + }) + .collect(), + vec![(10.0, 10.0), (50.0, 10.0), (50.0, 10.0), (50.0, 60.0), (30.0, 60.0), (30.0, 30.0), (10.0, 30.0)], + vec![(0.0, 0.0), (20.0, 0.0), (40.0, 0.0), (40.0, 0.0), (0.0, 0.0)], + // Vertices on the sub-row centres and the pixel edges. + vec![(3.125, 2.375), (70.5, 2.375), (70.875, 140.625), (3.0, 140.0), (35.0, 64.0), (35.0, 64.125)], + // Entirely outside, and straddling every side. + vec![(-500.0, -500.0), (-400.0, -500.0), (-450.0, -300.0)], + vec![(-80.0, -60.0), (300.0, 20.0), (60.0, 400.0)], + // Non-finite coordinates and a negative zero. + vec![(10.0, 10.0), (f32::NAN, 40.0), (60.0, 70.0), (20.0, f32::INFINITY), (-0.0, -0.0), (0.0, 0.0)], + ]; + for n in [3, 7, 40, 400] { + for spread in [1.0, 2.0] { + cases.push((0..n).map(|_| (rnd() * 100.0 * spread - 50.0 * (spread - 1.0), rnd() * 220.0 * spread - 60.0 * spread)).collect()); + // Lasso-like: a random walk snapped to quarter pixels. + let (mut x, mut y) = (45.0f32, 75.0f32); + cases.push( + (0..n) + .map(|_| { + x = ((x + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0; + y = ((y + (rnd() - 0.5) * 12.0 * spread) * 4.0).round() / 4.0; + (x, y) + }) + .collect(), + ); + } + } + for pts in &cases { + for &area in &areas { + for aa in [true, false] { + let (new, old) = (polygon(pts, area, aa), old_polygon(pts, area, aa)); + assert_eq!(new.len(), old.len()); + let diff = new.iter().zip(&old).position(|(a, b)| a.to_bits() != b.to_bits()); + assert!(diff.is_none(), "{pts:?} {area:?} aa={aa}: first difference at {diff:?}"); + } + } + } + } + #[test] fn combine_modes() { let a = Rect::new(0, 0, 4, 1); From 7f8b5816cab1e3f9d3e76786874b69e40fdfae89 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 06:30:20 +0800 Subject: [PATCH 162/523] Use whole group bounds for transforms with active selections (#3970) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex --- crates/engine/src/transform_cmds.rs | 88 ++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 2b35b18333..54358fe66c 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -29,18 +29,22 @@ pub fn moves_whole(layer: &Layer) -> bool { /// content within the selection, or all of it for a layer that [`moves_whole`] (#2630). /// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag. pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect { - let content = match &layer.content { - LayerContent::Group(g) => g.children.iter().map(|l| transform_bounds(doc, l)).fold(Rect::EMPTY, |a, b| a.union(&b)), - _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), - }; - let content = - if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content }; + let content = whole_layer_bounds(layer); match &doc.selection { Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()), _ => content, } } +/// A group moves all its descendants, so its frame must not clip raster children to the selection. +fn whole_layer_bounds(layer: &Layer) -> Rect { + let content = match &layer.content { + LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), + }; + if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content } +} + /// Warp a surface whose pixels outside the content read as `default` (masks, selections): /// warp the content with alpha, then flatten back onto the default value. pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Result { @@ -526,8 +530,12 @@ mod tests { use super::*; fn session() -> Session { + session_depth(8) + } + + fn session_depth(depth: u32) -> Session { let mut s = Session::new(); - s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); s.edit("paint", |doc, active| { let l = doc.layer_mut(active.unwrap()).unwrap(); @@ -689,7 +697,11 @@ mod tests { /// `session()` plus a reveal-all mask with `hide` painted black (linked unless `linked` is false). fn masked(hide: &[Rect], linked: bool) -> Session { - let mut s = session(); + masked_depth(hide, linked, 8) + } + + fn masked_depth(hide: &[Rect], linked: bool, depth: u32) -> Session { + let mut s = session_depth(depth); s.execute("layer.layerMask.revealAll", json!({})).unwrap(); s.edit("mask", |doc, active| { let m = doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap(); @@ -887,6 +899,66 @@ mod tests { assert_eq!(transform_bounds(&st.doc, st.doc.layer(st.active_layer.unwrap()).unwrap()), Rect::new(10, 10, 15, 20)); } + #[test] + fn group_transform_ignores_selections_inside_its_children() { + group_transform_with_selection(Rect::new(10, 10, 15, 15)); + } + + #[test] + fn group_transform_ignores_selections_outside_its_children() { + group_transform_with_selection(Rect::new(70, 70, 80, 80)); + } + + #[test] + fn group_transform_presets_rotate_about_the_whole_group() { + for depth in [8, 16, 32] { + for x in [10, 70] { + let mut s = masked_depth(&[Rect::new(10, 10, 15, 20)], true, depth); + let child = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + s.execute("select.rect", json!({"x": x, "y": 10, "width": 5, "height": 5})).unwrap(); + s.execute("edit.transform.rotate180", json!({})).unwrap(); + let layer = s.active().unwrap().doc.layer(child).unwrap(); + assert_eq!(layer.surface().unwrap().content_bounds(), Rect::new(10, 10, 30, 20), "rotate about the group's centre, not the selection's"); + let mask = &layer.mask.as_ref().unwrap().surface; + assert_eq!(mask.sample_channel(27, 15, 0), 0.0); + assert_eq!(mask.sample_channel(12, 15, 0), 1.0); + } + } + } + + fn group_transform_with_selection(selection: Rect) { + for depth in [8, 16, 32] { + for nested in [false, true] { + let mut s = masked_depth(&[Rect::new(10, 10, 15, 20)], true, depth); + let child = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.groupLayers", json!({})).unwrap(); + if nested { + s.execute("layer.groupLayers", json!({})).unwrap(); + } + s.execute("select.rect", json!({"x": selection.x0, "y": selection.y0, "width": selection.width(), "height": selection.height()})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + let result = s.execute("edit.transform", json!({"quad": [[10, 10], [50, 10], [50, 30], [10, 30]], "interpolation": "nearest"})).unwrap(); + assert_eq!(result["rect"], json!([10.0, 10.0, 30.0, 20.0]), "frame must include the whole group (nested={nested})"); + let st = s.active().unwrap(); + let layer = st.doc.layer(child).unwrap(); + assert_eq!(layer.surface().unwrap().content_bounds(), Rect::new(10, 10, 50, 30)); + let mask = &layer.mask.as_ref().unwrap().surface; + assert_eq!(mask.sample_channel(19, 20, 0), 0.0, "the linked mask scales with its complete layer"); + assert_eq!(mask.sample_channel(21, 20, 0), 1.0); + assert_eq!(st.history.entries().len(), steps + 1); + assert!(s.undo()); + let st = s.active().unwrap(); + let layer = st.doc.layer(child).unwrap(); + assert_eq!(layer.surface().unwrap().content_bounds(), Rect::new(10, 10, 30, 20)); + assert_eq!(layer.mask.as_ref().unwrap().surface.sample_channel(19, 15, 0), 1.0); + assert_eq!(st.doc.selection.as_ref().unwrap().content_bounds(), selection); + assert!(s.redo()); + assert_eq!(s.active().unwrap().doc.layer(child).unwrap().surface().unwrap().content_bounds(), Rect::new(10, 10, 50, 30)); + } + } + } + #[test] fn feathered_selection_identity_transform_preserves_pixels() { let mut s = Session::new(); From dd992c7e57d6a77965a3fec51b5b34aaa3045d5d Mon Sep 17 00:00:00 2001 From: zenjabba Date: Mon, 12 Oct 2026 18:34:59 -0400 Subject: [PATCH 163/523] fix(color): validate RGB working profiles atomically (#3924) Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com> --- crates/engine/src/color_cmds.rs | 14 +- crates/engine/src/prefs.rs | 37 ++- crates/engine/tests/working_rgb_profile.rs | 263 +++++++++++++++++++++ 3 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/working_rgb_profile.rs diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index a68188bd3f..794b1b5507 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -172,13 +172,18 @@ impl ColorSettings { } } -/// Check that each working space resolves to a profile of the right colour space. +/// Check that each working space resolves to the right colour space and RGB can be both a +/// source and editing destination for the configured rendering intent. pub fn validate_settings(c: &ColorSettings) -> std::result::Result<(), String> { for (space, spec) in [(ColorSpace::Rgb, &c.working_rgb), (ColorSpace::Cmyk, &c.working_cmyk), (ColorSpace::Gray, &c.working_gray)] { let p = resolve_profile(spec, None, space_mode(space)).map_err(|e| e.to_string())?; if p.color_space != space { return Err(format!("working space `{spec}` is {:?}, not {space:?}", p.color_space)); } + if space == ColorSpace::Rgb { + p.pcs_to_device(c.intent()).map_err(|e| format!("working RGB space `{spec}` cannot be a destination for {}: {e}", c.intent().id()))?; + p.device_to_pcs(c.intent()).map_err(|e| format!("working RGB space `{spec}` cannot be a source for {}: {e}", c.intent().id()))?; + } } let m = c.monitor_profile.as_str(); if !(m.is_empty() || m == "auto") { @@ -215,13 +220,16 @@ pub struct ColorState { impl ColorState { /// The working profile of a mode from Color Settings (the built-in default when the - /// setting does not resolve). + /// setting does not resolve or cannot be both an RGB source and editing destination). pub fn working(&self, mode: ColorMode) -> Arc { let space = mode_space(mode); self.settings .working_spec(space) .and_then(|spec| resolve_profile(spec, None, Some(mode)).ok()) - .filter(|p| p.color_space == space) + .filter(|p| { + p.color_space == space + && (space != ColorSpace::Rgb || (p.pcs_to_device(self.settings.intent()).is_ok() && p.device_to_pcs(self.settings.intent()).is_ok())) + }) .unwrap_or_else(|| working_profile(mode)) } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index cffc39bccf..074835fd03 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1574,7 +1574,9 @@ impl Session { } /// Set one dotted path, routing `colorSettings.*` to the colour state. - fn set_pref(&mut self, path: &str, value: Value) -> std::result::Result<(), String> { + /// Batches defer colour validation until their complete candidate is staged. Returns + /// whether colour settings were touched, including whole-section/root-object updates. + fn set_pref(&mut self, path: &str, value: Value, validate_color: bool) -> std::result::Result { if let Some(rest) = path.strip_prefix("colorSettings") { let mut cur = serde_json::to_value(&self.color.settings).map_err(|e| e.to_string())?; let rest = rest.trim_start_matches('.'); @@ -1583,19 +1585,23 @@ impl Session { } else { set_path(&mut cur, rest, value)?; } - self.color.settings = serde_json::from_value(cur).map_err(|e| format!("invalid value for `{path}`: {e}"))?; - crate::color_cmds::validate_settings(&self.color.settings)?; + let next = serde_json::from_value(cur).map_err(|e| format!("invalid value for `{path}`: {e}"))?; + if validate_color { + crate::color_cmds::validate_settings(&next)?; + } + self.color.settings = next; self.prefs.edit(|_| ()); - return Ok(()); + return Ok(true); } let mut next = self.prefs().clone(); + let mut color_changed = false; if path.is_empty() || SECTIONS.iter().any(|(id, _)| *id == path) || matches!(path, "shortcuts" | "menus" | "toolbar") { // Whole section (or everything): merge the object key by key so each value is checked. let obj = value.as_object().ok_or_else(|| format!("`{path}` needs an object"))?; for (k, v) in obj { let p = if path.is_empty() { k.clone() } else { format!("{path}.{k}") }; if path.is_empty() && k == "colorSettings" { - self.set_pref("colorSettings", v.clone())?; + color_changed |= self.set_pref("colorSettings", v.clone(), validate_color)?; continue; } if v.is_object() && !p.starts_with("shortcuts") && !p.ends_with(".colors") && next.get(&p).is_some_and(|c| c.is_object()) { @@ -1664,7 +1670,7 @@ impl Session { } } self.prefs.edit(|p| *p = next); - Ok(()) + Ok(color_changed) } } @@ -1698,13 +1704,22 @@ fn prefs_set(s: &mut Session, p: &Value) -> Result { } // All or nothing: validate against a scratch copy first. let (saved_prefs, saved_color) = (s.prefs().clone(), s.color.settings.clone()); + let mut color_changed = false; for (path, v) in &changes { - if let Err(e) = s.set_pref(path, v.clone()) { - s.prefs.edit(|p| *p = saved_prefs); - s.color.settings = saved_color; - return Err(bad(cmd, e)); + match s.set_pref(path, v.clone(), false) { + Ok(touched) => color_changed |= touched, + Err(e) => { + s.prefs.edit(|p| *p = saved_prefs); + s.color.settings = saved_color; + return Err(bad(cmd, e)); + } } } + if color_changed && let Err(e) = crate::color_cmds::validate_settings(&s.color.settings) { + s.prefs.edit(|p| *p = saved_prefs); + s.color.settings = saved_color; + return Err(bad(cmd, e)); + } // `colorSettings.blendTextGamma` lives in the compositor too, as `edit.colorSettings` sets it. photocraft_compose::psblend::set_text_gamma(s.color.settings.blend_text_gamma); s.apply_prefs(); @@ -1723,7 +1738,7 @@ fn prefs_reset(s: &mut Session, p: &Value) -> Result { let defaults = serde_json::to_value(crate::color_cmds::ColorSettings::default()).map_err(|e| bad("prefs.reset", e.to_string()))?; let key = path.strip_prefix("colorSettings.").unwrap_or(path); let def = get_path(&defaults, key).cloned().ok_or_else(|| bad("prefs.reset", format!("unknown preference `{path}`")))?; - s.set_pref(path, def).map_err(|e| bad("prefs.reset", e))?; + s.set_pref(path, def, true).map_err(|e| bad("prefs.reset", e))?; } None => { s.color.settings = Default::default(); diff --git a/crates/engine/tests/working_rgb_profile.rs b/crates/engine/tests/working_rgb_profile.rs new file mode 100644 index 0000000000..82405575da --- /dev/null +++ b/crates/engine/tests/working_rgb_profile.rs @@ -0,0 +1,263 @@ +//! Working RGB must support reading and editing for the configured intent. + +use photocraft_cms::{ + Builtin, Curve, Intent, Profile, ProfileClass, + pipeline::Stage, + profile::{Lut, LutKind}, +}; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Layer}; +use photocraft_engine::Session; +use photocraft_geom::Size; +use serde_json::json; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Repository-built, serialized profiles test capabilities without proprietary ICC fixtures +/// or an invented inverse. The synthetic BToA table is not an appearance oracle. +fn directional_profile(source: bool, destination: bool) -> Profile { + let mut p = Builtin::DisplayP3.profile().clone(); + p.class = ProfileClass::Input; + p.description = "Synthetic directional RGB".into(); + let curves = p.trc.take().unwrap().to_vec(); + let matrix = p.matrix.take().unwrap(); + let matrix = Stage::Matrix { rows: 3, cols: 3, m: matrix.iter().flatten().map(|v| v * 32768.0 / 65535.0).collect(), offset: vec![0.0; 3] }; + let a2b = Lut { kind: LutKind::Ab, inputs: 3, outputs: 3, stages: vec![Stage::Curves(curves), matrix, Stage::Curves(vec![Curve::Identity; 3])] }; + let b2a = Lut { kind: LutKind::Ab, inputs: 3, outputs: 3, stages: vec![Stage::Curves(vec![Curve::Identity; 3])] }; + p.a2b = if source { [Some(a2b.clone()), Some(a2b.clone()), Some(a2b)] } else { Default::default() }; + p.b2a = if destination { [Some(b2a.clone()), Some(b2a.clone()), Some(b2a)] } else { Default::default() }; + p +} + +struct ProfileFile { + path: std::path::PathBuf, + profile: Profile, +} + +impl ProfileFile { + fn new(profile: Profile) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let path = std::env::temp_dir().join(format!("photocraft-working-rgb-{}-{}.icc", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + let bytes = profile.with_encoded_bytes().to_bytes(); + let profile = Profile::parse(&bytes).unwrap(); + std::fs::write(&path, &*bytes).unwrap(); + Self { path, profile } + } + fn spec(&self) -> &str { + self.path.to_str().unwrap() + } +} + +impl Drop for ProfileFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn reject_one_way_setting(source: bool, destination: bool, missing_direction: &str) { + let file = ProfileFile::new(directional_profile(source, destination)); + for intent in Intent::ALL { + assert_eq!(file.profile.device_to_pcs(intent).is_ok(), source); + assert_eq!(file.profile.pcs_to_device(intent).is_ok(), destination); + let mut s = Session::new(); + let before = s.color.settings.clone(); + let error = s.execute("edit.colorSettings", json!({"workingRgb": file.spec(), "intent": intent.id(), "bpc": false})).unwrap_err(); + assert!(error.to_string().contains(missing_direction), "{error}"); + assert_eq!(s.color.settings, before, "reject the full settings update before any mutation"); + assert!(s.execute("prefs.set", json!({"path": "colorSettings.workingRgb", "value": file.spec()})).is_err()); + assert_eq!(s.color.settings, before, "individual preference setter also rejects atomically"); + } +} + +#[test] +fn a_source_only_rgb_working_space_is_rejected_atomically() { + reject_one_way_setting(true, false, "destination"); +} + +#[test] +fn a_destination_only_rgb_working_space_is_rejected_atomically() { + reject_one_way_setting(false, true, "source"); +} + +fn legacy_session(file: &ProfileFile, intent: Intent) -> Session { + let mut s = Session::new(); + s.load_prefs_json(&json!({"colorSettings": {"workingRgb": file.spec(), "intent": intent.id()}}).to_string()).unwrap(); + assert_eq!(s.color.settings.working_rgb, file.spec(), "legacy preferences still load"); + s +} + +fn display_and_copy(s: &mut Session, depth: SampleType) { + let target = s.active().unwrap().doc.clone(); + let display = s.color.display_transform(&target).expect("the destination document must also be a readable display source"); + let mut displayed = [0.0; 3]; + display.eval(&[0.35, 0.65, 0.45], &mut displayed); + assert!(displayed.iter().all(|v| v.is_finite())); + let mut source = Document::new("Copy source", Size::new(8, 6), ColorMode::Rgb, SampleType::F32); + source.icc_profile = Some(Builtin::AdobeRgbCompat.profile().to_bytes()); + let mut layer = Layer::raster("Incoming", source.pixel_format()); + layer.surface_mut().unwrap().fill_rect(source.bounds(), &[0.35, 0.65, 0.45, 0.75]); + source.layers.push(layer); + s.add_document(source, None); + let source_before = s.documents()[1].doc.clone(); + s.execute("layer.copyToDocument", json!({"document": 0})).expect("an untagged or new document must accept a converted layer"); + assert_eq!(s.documents()[0].doc.depth, depth); + assert_eq!(s.documents()[0].doc.layers.len(), target.layers.len() + 1); + assert!(s.documents()[1].doc.as_ref() == source_before.as_ref()); + assert!(s.undo()); + assert!(s.active().unwrap().doc.as_ref() == target.as_ref()); +} + +fn legacy_untagged_workflow(source: bool, destination: bool) { + let file = ProfileFile::new(directional_profile(source, destination)); + for intent in Intent::ALL { + for depth in SampleType::ALL { + let mut s = legacy_session(&file, intent); + let doc = Document::with_background("Untagged image", Size::new(8, 6), ColorMode::Rgb, depth, Color::rgba(0.35, 0.65, 0.45, 0.75)); + assert!(doc.icc_profile.is_none()); + s.open_document(doc, None); + display_and_copy(&mut s, depth); + assert_eq!(s.color.working(ColorMode::Rgb).content_hash(), Builtin::Srgb.profile().content_hash()); + assert!(s.documents()[0].doc.icc_profile.is_none(), "the default working fallback keeps untagged documents untagged"); + } + } +} + +#[test] +fn legacy_source_only_working_rgb_keeps_untagged_display_and_copy_usable() { + legacy_untagged_workflow(true, false); +} + +#[test] +fn legacy_destination_only_working_rgb_keeps_untagged_display_and_copy_usable() { + legacy_untagged_workflow(false, true); +} + +#[test] +fn legacy_one_way_working_spaces_keep_new_document_controls_usable() { + for (source, destination) in [(true, false), (false, true)] { + let file = ProfileFile::new(directional_profile(source, destination)); + for intent in Intent::ALL { + for depth in SampleType::ALL { + let mut s = legacy_session(&file, intent); + let bits = match depth { + SampleType::U8 => 8, + SampleType::U16 => 16, + SampleType::F32 => 32, + }; + s.execute("file.new", json!({"width": 8, "height": 6, "depth": bits, "background": "transparent"})).unwrap(); + display_and_copy(&mut s, depth); + } + } + } +} + +#[test] +fn valid_input_matrix_and_lut_working_profiles_use_the_configured_intent() { + let mut matrix = Builtin::DisplayP3.profile().clone(); + matrix.class = ProfileClass::Input; + let mut colorimetric_lut = directional_profile(true, true); + colorimetric_lut.b2a[0] = None; + colorimetric_lut.b2a[2] = None; + for (profile, colorimetric_only) in [(matrix, false), (directional_profile(true, true), false), (colorimetric_lut, true)] { + let file = ProfileFile::new(profile); + for intent in Intent::ALL { + let supported = !colorimetric_only || matches!(intent, Intent::RelativeColorimetric | Intent::AbsoluteColorimetric); + let mut s = Session::new(); + let before = s.color.settings.clone(); + let result = s.execute("edit.colorSettings", json!({"workingRgb": file.spec(), "intent": intent.id()})); + assert_eq!(result.is_ok(), supported, "{intent:?}/colorimetric_only={colorimetric_only}"); + if !supported { + assert_eq!(s.color.settings, before); + } + let s = legacy_session(&file, intent); + let expected = if supported { &file.profile } else { Builtin::Srgb.profile() }; + assert_eq!(s.color.working(ColorMode::Rgb).content_hash(), expected.content_hash()); + } + } +} + +fn intent_lut(intent: Intent) -> ProfileFile { + let mut profile = directional_profile(true, true); + let i = match intent { + Intent::Perceptual => 0, + Intent::RelativeColorimetric | Intent::AbsoluteColorimetric => 1, + Intent::Saturation => 2, + }; + for (index, lut) in profile.b2a.iter_mut().enumerate() { + if index != i { + *lut = None; + } + } + ProfileFile::new(profile) +} + +fn colorimetric_session(file: &ProfileFile) -> Session { + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb": file.spec(), "intent": "relative"})).unwrap(); + s +} + +#[test] +fn valid_cross_field_profile_and_intent_changes_validate_the_final_batch() { + let file = intent_lut(Intent::RelativeColorimetric); + let cases = [ + ("edit.colorSettings", json!({"workingRgb": "display-p3", "intent": "perceptual"})), + ("prefs.set", json!({"values": {"colorSettings.intent": "perceptual", "colorSettings.workingRgb": "display-p3"}})), + ( + "prefs.set", + json!({"path": "colorSettings", "value": {"workingRgb": file.spec(), "intent": "perceptual"}, "values": {"colorSettings.workingRgb": "display-p3"}}), + ), + ( + "prefs.set", + json!({"path": "", "value": {"colorSettings": {"workingRgb": file.spec(), "intent": "perceptual"}}, "values": {"colorSettings.workingRgb": "display-p3"}}), + ), + ]; + for (command, params) in cases { + let mut s = colorimetric_session(&file); + s.execute(command, params.clone()).unwrap_or_else(|e| panic!("final valid batch must succeed: {command}/{params}: {e}")); + assert_eq!(s.color.settings.working_rgb, "display-p3"); + assert_eq!(s.color.settings.intent, "perceptual"); + } +} + +#[test] +fn invalid_batches_restore_colour_and_unrelated_preferences_for_both_apis() { + let working = intent_lut(Intent::RelativeColorimetric); + let invalid = ProfileFile::new(directional_profile(true, false)); + let cases = [ + ("edit.colorSettings", json!({"workingRgb": invalid.spec(), "intent": "perceptual", "bpc": false})), + ( + "prefs.set", + json!({"values": {"colorSettings.bpc": false, "colorSettings.intent": "perceptual", "colorSettings.workingRgb": invalid.spec(), "performance.historyStates": 17}}), + ), + ( + "prefs.set", + json!({"values": {"colorSettings.bpc": false, "colorSettings.intent": "perceptual", "colorSettings.workingRgb": "display-p3", "performance.historyStates": 0}}), + ), + ]; + for (command, params) in cases { + let mut s = colorimetric_session(&working); + let before = s.prefs_to_json(); + assert!(s.execute(command, params).is_err()); + assert!(s.prefs_to_json() == before, "failed batch must preserve both preference stores"); + } +} + +#[test] +fn incompatible_single_intent_reset_preserves_the_original_colour_settings() { + let file = intent_lut(Intent::Saturation); + let mut s = Session::new(); + s.execute("edit.colorSettings", json!({"workingRgb": file.spec(), "intent": "saturation"})).unwrap(); + let before = s.prefs_to_json(); + assert!(s.execute("prefs.reset", json!({"path": "colorSettings.intent"})).is_err()); + assert!(s.prefs_to_json() == before, "single-setting reset must validate before assignment"); +} + +#[test] +fn unrelated_preference_batches_still_work_with_legacy_invalid_colour_settings() { + let invalid = ProfileFile::new(directional_profile(true, false)); + let mut s = legacy_session(&invalid, Intent::RelativeColorimetric); + let before = s.color.settings.clone(); + s.execute("prefs.set", json!({"values": {"cursors.painting": "precise", "performance.historyStates": 17}})).unwrap(); + assert_eq!(s.color.settings, before); + assert_eq!(s.execute("prefs.get", json!({"path": "performance.historyStates"})).unwrap(), json!(17)); +} From 3204231d7dae696d340db02ce8cfa8a820686350 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 17:39:57 -0500 Subject: [PATCH 164/523] Load and save Photoshop Actions (.atn) (#4356, #219) (#4434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Load and save Photoshop Actions (.atn) (#219) - photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip, truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and `Idnt` reference items carry a class before the number (real action files failed to parse). - photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters, adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/ hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop). Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at playback by name); tool selections load switched off. Commands `actions.load`, `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs` and `set`. `layer` params accept a layer name, so loaded steps replay in any document. - UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled. - CLI: `batch --actions set.atn`. Automation policy covers the new path params. - Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2 partial, development guide. * Translate the Actions panel's Load/Save strings in every catalog The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label) were missing from all 19 catalogs; the languages that were complete are back at 100%. * Show the Actions entries in the default dock's panel menu The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so Load Actions… and Save Actions… were missing there. --- apps/photocraft-cli/src/lib.rs | 43 +- apps/photocraft-cli/tests/cli.rs | 30 + crates/automation/src/workspace.rs | 8 +- crates/automation/tests/agent_tasks.rs | 1 + crates/engine/src/actions_cmds.rs | 69 +- crates/engine/src/atn_cmds.rs | 2470 +++++++++++++++++ crates/engine/src/atn_cmds/tests.rs | 488 ++++ crates/engine/src/automate_cmds/tests.rs | 16 +- crates/engine/src/commands.rs | 29 +- crates/engine/src/cutout_cmds.rs | 7 +- crates/engine/src/layer_multi_cmds.rs | 10 +- crates/engine/src/layer_style.rs | 10 +- crates/engine/src/lib.rs | 1 + crates/engine/src/transform_cmds.rs | 5 +- crates/psd/README.md | 1 + crates/psd/examples/atn_dump.rs | 84 + crates/psd/fuzz/Cargo.toml | 7 + crates/psd/fuzz/fuzz_targets/atn.rs | 14 + crates/psd/src/atn.rs | 426 +++ crates/psd/src/descriptor.rs | 40 +- crates/psd/src/lib.rs | 1 + crates/ui-egui/src/actions.rs | 152 +- crates/ui-egui/src/control.rs | 3 + crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/i18n/cs.tsv | 10 + crates/ui-egui/src/i18n/de.tsv | 10 + crates/ui-egui/src/i18n/el.tsv | 10 + crates/ui-egui/src/i18n/es.tsv | 10 + crates/ui-egui/src/i18n/fr.tsv | 10 + crates/ui-egui/src/i18n/hu.tsv | 10 + crates/ui-egui/src/i18n/id.tsv | 10 + crates/ui-egui/src/i18n/it.tsv | 10 + crates/ui-egui/src/i18n/ja.tsv | 10 + crates/ui-egui/src/i18n/ko.tsv | 10 + crates/ui-egui/src/i18n/nl.tsv | 10 + crates/ui-egui/src/i18n/pl.tsv | 10 + crates/ui-egui/src/i18n/pt-br.tsv | 10 + crates/ui-egui/src/i18n/ru.tsv | 10 + crates/ui-egui/src/i18n/tr.tsv | 10 + crates/ui-egui/src/i18n/uk.tsv | 10 + crates/ui-egui/src/i18n/vi.tsv | 10 + crates/ui-egui/src/i18n/zh-hans.tsv | 10 + crates/ui-egui/src/i18n/zh-hant.tsv | 10 + crates/ui-egui/src/panel_docking.rs | 4 + crates/ui-egui/src/shortcut_dispatch/tests.rs | 4 + docs/development.md | 15 + docs/file-format-parity.md | 5 +- docs/gaps.md | 14 +- docs/scorecard.md | 4 +- docs/target-app-parity.md | 11 +- scorecard/automation.toml | 4 +- 51 files changed, 4091 insertions(+), 79 deletions(-) create mode 100644 crates/engine/src/atn_cmds.rs create mode 100644 crates/engine/src/atn_cmds/tests.rs create mode 100644 crates/psd/examples/atn_dump.rs create mode 100644 crates/psd/fuzz/fuzz_targets/atn.rs create mode 100644 crates/psd/src/atn.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 93090610a5..7bf7f8164d 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -26,8 +26,9 @@ USAGE: photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] - Apply an action list to every image in a directory. Steps are [id, params] pairs, + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] + Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a + set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them (a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An Actions set (the Actions panel's actions.json, {\"actions\": [{\"name\", \"steps\"}…]}) plays the action --action names (needed @@ -392,6 +393,38 @@ pub fn load_actions(text: &str, name: Option<&str>) -> Result<(Steps, Vec) -> Result<(Steps, Vec, Vec), String> { + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + let is_atn = Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("atn")) || bytes.starts_with(&[0, 0, 0, 16]); + if !is_atn { + let text = String::from_utf8(bytes).map_err(|e| format!("{path}: {e}"))?; + let (steps, set) = load_actions(&text, name)?; + return Ok((steps, set, Vec::new())); + } + let imp = photocraft_engine::atn_cmds::import(&bytes).map_err(|e| format!("{path}: {e}"))?; + let names = || imp.actions.iter().map(|a| format!("`{}`", a.name)).collect::>().join(", "); + let action = match (name, imp.actions.as_slice()) { + (Some(n), _) => { + imp.actions.iter().find(|a| a.name == n).ok_or_else(|| format!("no action named `{n}` in the set `{}` (it holds {})", imp.set, names()))? + } + (None, [only]) => only, + (None, []) => return Err(format!("{path}: the set holds no actions")), + (None, _) => return Err(format!("the set `{}` holds {} actions: pick one with --action ({})", imp.set, imp.actions.len(), names())), + }; + let warnings = imp + .unsupported + .iter() + .filter(|u| u.action == action.name) + .map(|u| format!("`{}` step {}: Photoshop's \"{}\" ({}) has no PhotoCraft equivalent yet", u.action, u.step + 1, u.name, u.event)) + .chain(imp.warnings.iter().cloned()) + .collect(); + let play = vec![("actions.play".to_string(), json!({"action": action.name}))]; + Ok((play, imp.actions, warnings)) +} + /// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a /// trailing separator, through a symbolic link). fn same_dir(a: &Path, b: &Path) -> bool { @@ -410,8 +443,10 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R { let actions_path = a.get("--actions").ok_or("batch needs --actions ")?; let in_dir = PathBuf::from(a.get("--in").ok_or("batch needs --in ")?); let out_dir = PathBuf::from(a.get("--out").ok_or("batch needs --out ")?); - let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?; - let (actions, set) = load_actions(&text, a.get("--action"))?; + let (actions, set, warnings) = load_actions_file(actions_path, a.get("--action"))?; + for w in &warnings { + let _ = writeln!(err, "warning: {w}"); + } let opts = export_opts(a)?; // Results are saved as `/.`, so an `--out` that is the `--in` folder would // replace the originals (#492). diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index ea807e9882..162483bcf9 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -684,3 +684,33 @@ fn a_non_unicode_argument_is_a_usage_error_not_a_panic() { assert!(!err.contains("panicked"), "{err}"); } } + +/// `batch --actions set.atn`: a Photoshop Actions file plays like an Actions set, and its steps +/// PhotoCraft can't play are warned about up front. +#[test] +fn batch_plays_a_photoshop_actions_file() { + use photocraft_engine::actions_cmds::Action; + let d = tmp("atn"); + let input = d.join("in"); + std::fs::create_dir_all(&input).unwrap(); + write_png(&input.join("a.png"), 8, 4, 3); + let invert = Action::new("Negative", vec![("image.adjustments.invert".into(), json!({})), ("select.all".into(), json!({}))]); + let odd = Action::new("Odd", vec![(photocraft_engine::atn_cmds::PHOTOSHOP_STEP.into(), json!({"event": "syntheticFill", "name": "Generative Fill"}))]); + let (bytes, skipped) = photocraft_engine::atn_cmds::export("Set", &[&invert, &odd], &[], false).unwrap(); + assert!(skipped.is_empty()); + let atn = d.join("Set.atn"); + std::fs::write(&atn, bytes).unwrap(); + let out_dir = d.join("out"); + // Two actions: the set needs --action. + let o = bin().args(["batch", "--actions"]).arg(&atn).arg("--in").arg(&input).arg("--out").arg(&out_dir).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("--action"), "{}", String::from_utf8_lossy(&o.stderr)); + let (_, err) = ok(bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Negative", "--in"]).arg(&input).arg("--out").arg(&out_dir)); + assert!(out_dir.join("a.png").exists()); + assert!(!err.contains("warning"), "{err}"); + // The unsupported step is warned about, and fails the file as Photoshop would stop on it. + let o = bin().args(["batch", "--actions"]).arg(&atn).args(["--action", "Odd", "--in"]).arg(&input).arg("--out").arg(d.join("out2")).output().unwrap(); + let err = String::from_utf8_lossy(&o.stderr); + assert!(err.contains("Generative Fill"), "{err}"); + assert!(!o.status.success()); +} diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d34219e762..eaed0e1c65 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -363,12 +363,12 @@ fn preference_uses_ambient_filesystem(path: &str) -> bool { fn command_uses_ambient_path(id: &str, params: &Value) -> bool { match id { - "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { + "actions.load" | "brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" | "swatches.import" => { // `data` wins over `path` in these commands; any `path` without it reads the filesystem. params.get("data").is_none() && params.get("path").is_some() } // With a `path` the file is written there; without one the bytes come back as `data`. - "swatches.export" => params.get("path").is_some(), + "actions.save" | "swatches.export" => params.get("path").is_some(), "plugin.reload" => true, _ => false, } @@ -765,6 +765,8 @@ mod tests { ("plugin.install", serde_json::json!({"path": " "})), ("swatches.import", serde_json::json!({"path": "/outside/set.aco"})), ("swatches.export", serde_json::json!({"path": "/outside/set.ase"})), + ("actions.load", serde_json::json!({"path": "/outside/set.atn"})), + ("actions.save", serde_json::json!({"path": "/outside/set.atn"})), ] { assert!(authorize_engine_command(id, ¶ms).is_err(), "{id}: {params}"); } @@ -774,6 +776,8 @@ mod tests { ("plugin.install", serde_json::json!({"data": "QUJD"})), ("swatches.import", serde_json::json!({"data": "QUJD"})), ("swatches.export", serde_json::json!({"format": "ase"})), + ("actions.load", serde_json::json!({"data": "QUJD"})), + ("actions.save", serde_json::json!({"set": "S"})), ] { assert!(authorize_engine_command(id, ¶ms).is_ok(), "{id}: {params}"); } diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index b185e310a7..271993d2bb 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -331,6 +331,7 @@ async fn agent_play_refuses_a_recorded_file_open() { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let (c, server) = connect_headless(headless).await; let played = run(&c, "actions.play", json!({"action": "Open"})).await; diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index f6707c1b9f..2611853b2d 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -36,11 +36,26 @@ pub struct Action { /// Indices of steps excluded from playback (unchecked in the Actions panel). #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] pub disabled: BTreeSet, + /// Indices of steps whose dialog Photoshop shows during playback (the panel's dialog + /// toggle column). Headless playback runs them with their recorded params; the flag is + /// kept so Save Actions writes it back. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub dialogs: BTreeSet, + /// The action set the action belongs to: the folder Photoshop shows in the Actions panel, + /// filled by Load Actions (`actions.load`) and written by Save Actions (`actions.save`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set: Option, } impl Action { pub fn new(name: impl Into, steps: Vec<(String, Value)>) -> Self { - Self { name: name.into(), steps, disabled: BTreeSet::new() } + Self { name: name.into(), steps, disabled: BTreeSet::new(), dialogs: BTreeSet::new(), set: None } + } + + /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. + fn remap_steps(&mut self, f: impl Fn(usize) -> Option) { + self.disabled = self.disabled.iter().filter_map(|&i| f(i)).collect(); + self.dialogs = self.dialogs.iter().filter_map(|&i| f(i)).collect(); } pub fn step_enabled(&self, step: usize) -> bool { @@ -166,7 +181,7 @@ fn num(v: &Value) -> Option { v.as_i64().or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round() as i64)) } -fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { +pub(crate) fn resolve(list: &[Action], p: &Value, cmd: &str) -> Result { let Some(v) = p.get("action") else { return Err(bad(cmd, "needs \"action\" (a name or an index)")); }; @@ -210,7 +225,7 @@ fn from_step(p: &Value, len: usize) -> Result { } fn list(s: &mut Session, _p: &Value) -> Result { - let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len()})).collect(); + let actions: Vec = s.actions.list.iter().map(|a| json!({"name": a.name, "steps": a.steps.len(), "set": a.set})).collect(); let recording = s.actions.recording.and_then(|(_, i)| s.actions.list.get(i).map(|a| a.name.clone())); Ok(json!({"actions": actions, "recording": recording})) } @@ -219,7 +234,7 @@ fn get(s: &mut Session, p: &Value) -> Result { let idx = resolve(&s.actions.list, p, "actions.get")?; let action = &s.actions.list[idx]; let steps: Vec = action.steps.iter().map(|(id, params)| json!([id, params])).collect(); - Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled})) + Ok(json!({"name": action.name, "steps": steps, "disabled": action.disabled, "dialogs": action.dialogs, "set": action.set})) } fn record(s: &mut Session, p: &Value) -> Result { @@ -386,7 +401,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let action = &mut s.actions.list[idx]; let moved = action.steps.remove(step); action.steps.insert(to, moved); - action.disabled = action.disabled.iter().map(|&i| moved_index(i, step, to)).collect(); + action.remap_steps(|i| Some(moved_index(i, step, to))); let result = json!({"action": action.name, "step": to}); s.actions.touch(); Ok(result) @@ -434,7 +449,15 @@ fn delete(s: &mut Session, p: &Value) -> Result { } let action = &mut s.actions.list[idx]; action.steps.remove(step); - action.disabled = action.disabled.iter().filter(|&&i| i != step).map(|&i| if i > step { i - 1 } else { i }).collect(); + action.remap_steps(|i| { + if i == step { + None + } else if i > step { + Some(i - 1) + } else { + Some(i) + } + }); let result = json!({"action": action.name, "deletedStep": step, "steps": action.steps.len()}); s.actions.touch(); return Ok(result); @@ -546,7 +569,7 @@ pub fn specs() -> Vec { label: "List Actions", menu: &[], shortcut: None, - params: "{} → {actions:[{name, steps:count}], recording:name|null}", + params: "{} → {actions:[{name, steps:count, set:name|null}], recording:name|null}", enabled: always, run: list, journal: false, @@ -556,7 +579,7 @@ pub fn specs() -> Vec { label: "Get Action", menu: &[], shortcut: None, - params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…]} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips)"##, + params: r##"{"action":name|index} → {name, steps:[[id, params]…], disabled:[step…], dialogs:[step…], set:name|null} (steps is the shape file.automate.batch and droplets take; disabled lists the 0-based steps playback skips; dialogs the steps whose dialog Photoshop would show; set the action set from Load Actions)"##, enabled: always, run: get, journal: false, @@ -679,11 +702,17 @@ mod tests { #[test] fn rename_keeps_calls_and_function_key_and_rejects_bad_names() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "Action 1"}))], disabled: Default::default(), + ..Default::default() }); s.edit_prefs(|p| p.shortcuts.insert(format!("{PLAY_SHORTCUT_PREFIX}Action 1"), "F2".into())); assert_eq!(s.execute("actions.rename", json!({"action": 0, "name": "Sepia"})).unwrap()["action"], "Sepia"); @@ -950,7 +979,12 @@ mod tests { #[test] fn play_refuses_recursion() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Loop".into(), steps: vec![("actions.play".into(), json!({"action": "Loop"}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Loop".into(), + steps: vec![("actions.play".into(), json!({"action": "Loop"}))], + disabled: Default::default(), + ..Default::default() + }); let r = s.execute("actions.play", json!({"action": "Loop"})).unwrap(); assert_eq!(r["ran"], 0); assert_eq!(r["failed"]["id"], "actions.play"); @@ -969,6 +1003,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let r = s.execute("actions.play", json!({"action": "Open"})).unwrap(); assert_eq!(r["ran"], 0); @@ -1046,6 +1081,7 @@ mod tests { name: "Child".into(), steps: vec![("layer.new.layer".into(), json!({"name": "Child layer"}))], disabled: Default::default(), + ..Default::default() }); s.execute("actions.record", json!({"name": "Parent"})).unwrap(); s.execute("layer.new.layer", json!({"name": "Before"})).unwrap(); @@ -1075,8 +1111,14 @@ mod tests { name: "A".into(), steps: vec![("actions.play".into(), json!({"action": "B"})), ("file.new".into(), json!({"width": 2,"height": 2}))], disabled: Default::default(), + ..Default::default() + }); + s.actions.list.push(Action { + name: "B".into(), + steps: vec![("actions.play".into(), json!({"action": "A"}))], + disabled: Default::default(), + ..Default::default() }); - s.actions.list.push(Action { name: "B".into(), steps: vec![("actions.play".into(), json!({"action": "A"}))], disabled: Default::default() }); let r = s.execute("actions.play", json!({"action": "A"})).unwrap(); assert!(r["failed"]["error"].as_str().unwrap().contains("recursive")); assert!(s.documents().is_empty()); @@ -1092,6 +1134,7 @@ mod tests { name: format!("N{i}"), steps: if i < 16 { vec![("actions.play".into(), json!({"action":format!("N{}", i + 1)}))] } else { vec![] }, disabled: Default::default(), + ..Default::default() }); } let r = s.execute("actions.play", json!({"action": "N0"})).unwrap(); @@ -1136,7 +1179,7 @@ mod tests { #[test] fn new_names_do_not_collide_with_named_calls() { let mut s = Session::new(); - s.actions.list.push(Action { name: "Action 2".into(), steps: vec![], disabled: Default::default() }); + s.actions.list.push(Action { name: "Action 2".into(), steps: vec![], disabled: Default::default(), ..Default::default() }); assert_eq!(s.execute("actions.record", json!({})).unwrap()["action"], "Action 3"); s.execute("actions.stop", json!({})).unwrap(); assert!(s.execute("actions.record", json!({"name":"Action 2"})).is_err()); @@ -1148,7 +1191,7 @@ mod tests { let mut s = Session::new(); s.execute("file.new", json!({"width":4,"height":4})).unwrap(); for name in ["A", "B", "C"] { - s.actions.list.push(Action { name: name.into(), steps: vec![], disabled: Default::default() }); + s.actions.list.push(Action { name: name.into(), steps: vec![], disabled: Default::default(), ..Default::default() }); } s.execute("actions.record", json!({"action":"B"})).unwrap(); s.execute("layer.new.layer", json!({"name":"before"})).unwrap(); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs new file mode 100644 index 0000000000..4e5de06970 --- /dev/null +++ b/crates/engine/src/atn_cmds.rs @@ -0,0 +1,2470 @@ +//! Photoshop Actions files (`.atn`): Load Actions (`actions.load`) and Save Actions +//! (`actions.save`), plus the two step commands a Photoshop set needs: `actions.stopMessage` +//! (Photoshop's Insert Stop) and `actions.photoshopStep` (a step PhotoCraft has no command for +//! yet, kept verbatim so Save Actions writes it back unchanged). +//! +//! Parsing and writing live in `photocraft-psd` (`atn`); this module maps Photoshop *events* +//! (`gaussianBlur` with `Rds`, `make` of an adjustment layer, `set` of the selection…) onto +//! PhotoCraft commands and back. An event is keyed by both its four-character code (`GsnB`, +//! older files) and its string name (`gaussianBlur`, Photoshop 2024 and later), as the two are +//! interchangeable in Photoshop. The table ([`EVENTS`]) is bidirectional: a step that was +//! imported exports to the same event. Events outside it become `actions.photoshopStep` steps, +//! which fail at playback with the step's Photoshop name, so an action never silently skips +//! work; tool selections and other steps with no document effect are imported switched off. +//! +//! Layer references by name (`select` of layer "Shadow") become `{"layer": "Shadow"}`, which +//! `commands::layer_param` resolves in whatever document the action plays on (#219). + +use photocraft_psd::atn::{self, AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Class, Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::{Map, Value, json}; + +use crate::actions_cmds::Action; +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +/// A step PhotoCraft cannot play yet, kept verbatim (`{"event", "name", "descriptor": base64}`). +pub const PHOTOSHOP_STEP: &str = "actions.photoshopStep"; +/// Photoshop's Insert Stop: `{"message", "allowContinue"}`. +pub const STOP_MESSAGE: &str = "actions.stopMessage"; +/// The set name Save Actions uses for actions that came from no set. +pub const DEFAULT_SET: &str = "PhotoCraft Actions"; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +// ------------------------------------------------------------------ descriptor access + +fn num(v: &D) -> Option { + match v { + D::Integer(i) => Some(f64::from(*i)), + D::LargeInteger(i) => Some(*i as f64), + D::Double(d) | D::UnitFloat { value: d, .. } => Some(*d), + _ => None, + } + .filter(|f| f.is_finite()) +} + +fn trimmed(id: &Id) -> String { + String::from_utf8_lossy(id.as_bytes()).trim_end().to_string() +} + +fn get_num(d: &Descriptor, k: &str) -> Option { + d.get(k).and_then(num) +} + +fn get_bool(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Boolean(b)) => Some(*b), + _ => None, + } +} + +fn get_text(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Text(t)) => Some(t.to_string_lossy()), + _ => None, + } +} + +/// An enumerated value's id, trailing spaces removed (`Ordn.Trgt` → `"Trgt"`). +fn get_enum(d: &Descriptor, k: &str) -> Option { + match d.get(k) { + Some(D::Enumerated { value, .. }) => Some(trimmed(value)), + _ => None, + } +} + +fn get_obj<'a>(d: &'a Descriptor, k: &str) -> Option<&'a Descriptor> { + match d.get(k) { + Some(D::Descriptor(o) | D::GlobalObject(o)) => Some(o), + _ => None, + } +} + +fn get_list<'a>(d: &'a Descriptor, k: &str) -> Option<&'a [D]> { + match d.get(k) { + Some(D::List(l)) => Some(l), + _ => None, + } +} + +fn get_ref<'a>(d: &'a Descriptor, k: &str) -> Option<&'a [ReferenceItem]> { + match d.get(k) { + Some(D::Reference(r)) => Some(r), + _ => None, + } +} + +/// The class of a reference's first item, trimmed (`Lyr`, `Chnl`, `AdjL`…). +fn ref_class(r: &[ReferenceItem]) -> Option { + Some(trimmed(match r.first()? { + ReferenceItem::Property { class, .. } + | ReferenceItem::Class(class) + | ReferenceItem::Enumerated { class, .. } + | ReferenceItem::Offset { class, .. } + | ReferenceItem::Identifier { class, .. } + | ReferenceItem::Index { class, .. } + | ReferenceItem::Name { class, .. } => &class.class_id, + })) +} + +/// How a reference names its target: the ordinal (`Trgt`, `Frnt`…), a name, a property… +enum Target { + Ordinal(String), + Name(String), + Property(String), + Class, + Offset(i32), + Other, +} + +fn target(r: &[ReferenceItem]) -> Target { + match r.first() { + Some(ReferenceItem::Enumerated { value, .. }) => Target::Ordinal(trimmed(value)), + Some(ReferenceItem::Name { name, .. }) => Target::Name(name.to_string_lossy()), + Some(ReferenceItem::Property { key, .. }) => Target::Property(trimmed(key)), + Some(ReferenceItem::Class(_)) => Target::Class, + Some(ReferenceItem::Offset { offset, .. }) => Target::Offset(*offset), + _ => Target::Other, + } +} + +/// `{"layer": name}` for a reference that names a layer, `{}` for the target layer, `None` +/// for anything else (an index or id: meaningless in another document). +fn layer_target(r: &[ReferenceItem]) -> Option> { + let mut m = Map::new(); + match target(r) { + Target::Ordinal(o) if o == "Trgt" => {} + Target::Name(n) => { + m.insert("layer".into(), json!(n)); + } + Target::Property(p) if p == "Bckg" => { + m.insert("layer".into(), json!("Background")); + } + _ => return None, + } + Some(m) +} + +// ------------------------------------------------------------------ descriptor building + +/// A Photoshop id: codes shorter than four characters are space-padded (`H `, `Rd `), +/// longer names stay strings. +fn code(s: &str) -> Id { + if s.len() < 4 { Id::new(&format!("{s:<4}")) } else { Id::new(s) } +} + +fn class(c: &str) -> Class { + Class { name: UnicodeString::new_nul(""), class_id: code(c) } +} + +/// The pixel unit of a `UntF` value. +const PX: [u8; 4] = *b"#Pxl"; + +fn px(v: f64) -> D { + D::UnitFloat { unit: PX, value: v } +} + +fn pct(v: f64) -> D { + D::UnitFloat { unit: *b"#Prc", value: v } +} + +fn ang(v: f64) -> D { + D::UnitFloat { unit: *b"#Ang", value: v } +} + +fn long(v: f64) -> D { + D::Integer(v.round().clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32) +} + +fn text(s: &str) -> D { + D::Text(UnicodeString::new_nul(s)) +} + +fn enm(ty: &str, v: &str) -> D { + D::Enumerated { type_id: code(ty), value: code(v) } +} + +fn target_ref(cls: &str) -> D { + D::Reference(vec![ReferenceItem::Enumerated { class: class(cls), type_id: Id::new("Ordn"), value: Id::new("Trgt") }]) +} + +fn name_ref(cls: &str, name: &str) -> D { + D::Reference(vec![ReferenceItem::Name { class: class(cls), name: UnicodeString::new_nul(name) }]) +} + +fn class_ref(cls: &str) -> D { + D::Reference(vec![ReferenceItem::Class(class(cls))]) +} + +/// The `null` reference for a command's `layer` param: by name, else the target layer. +fn layer_ref(p: &Value) -> D { + match p.get("layer") { + Some(Value::String(n)) => name_ref("Lyr ", n), + _ => target_ref("Lyr "), + } +} + +fn desc(cls: &str) -> Descriptor { + Descriptor::new(cls) +} + +// ------------------------------------------------------------------ JSON access + +fn jnum(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64).filter(|f| f.is_finite()) +} + +fn jbool(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_bool) +} + +fn jstr<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +// ------------------------------------------------------------------ colours and enums + +/// A Photoshop colour object as `#rrggbb`: `RGBC` (0..255), `HSBC`, `Grsc`. +fn color_hex(c: &Descriptor) -> Option { + let hex = |r: f64, g: f64, b: f64| { + format!("#{:02x}{:02x}{:02x}", r.round().clamp(0.0, 255.0) as u8, g.round().clamp(0.0, 255.0) as u8, b.round().clamp(0.0, 255.0) as u8) + }; + if let (Some(r), Some(g), Some(b)) = (get_num(c, "Rd "), get_num(c, "Grn "), get_num(c, "Bl ")) { + return Some(hex(r, g, b)); + } + if let (Some(h), Some(s), Some(v)) = (get_num(c, "H "), get_num(c, "Strt"), get_num(c, "Brgh")) { + let (s, v) = ((s / 100.0).clamp(0.0, 1.0), (v / 100.0).clamp(0.0, 1.0)); + let h = h.rem_euclid(360.0) / 60.0; + let i = h.floor(); + let f = h - i; + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let (r, g, b) = match i as i32 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + }; + return Some(hex(r * 255.0, g * 255.0, b * 255.0)); + } + get_num(c, "Gry ").map(|g| { + let v = 255.0 - g * 2.55; + hex(v, v, v) + }) +} + +fn color_desc(hex: &str) -> Option { + let h = hex.strip_prefix('#')?; + if h.len() != 6 || !h.is_ascii() { + return None; + } + let ch = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok().map(f64::from); + Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?))) +} + +/// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. +const BLEND_MODES: &[(&str, &str)] = &[ + ("Nrml", "Normal"), + ("Dslv", "Dissolve"), + ("Drkn", "Darken"), + ("Mltp", "Multiply"), + ("CBrn", "Color Burn"), + ("linearBurn", "Linear Burn"), + ("darkerColor", "Darker Color"), + ("Lghn", "Lighten"), + ("Scrn", "Screen"), + ("CDdg", "Color Dodge"), + ("linearDodge", "Linear Dodge (Add)"), + ("lighterColor", "Lighter Color"), + ("Ovrl", "Overlay"), + ("SftL", "Soft Light"), + ("HrdL", "Hard Light"), + ("vividLight", "Vivid Light"), + ("linearLight", "Linear Light"), + ("pinLight", "Pin Light"), + ("hardMix", "Hard Mix"), + ("Dfrn", "Difference"), + ("Xclu", "Exclusion"), + ("blendSubtraction", "Subtract"), + ("blendDivide", "Divide"), + ("H", "Hue"), + ("Strt", "Saturation"), + ("Clr", "Color"), + ("Lmns", "Luminosity"), + ("PsTh", "Pass Through"), +]; + +fn blend_label(ps: &str) -> Option<&'static str> { + BLEND_MODES.iter().find(|(k, _)| *k == ps).map(|(_, v)| *v) +} + +fn blend_ps(label: &str) -> Option<&'static str> { + crate::commands::blend_from_str(label).and_then(|m| BLEND_MODES.iter().find(|(_, v)| crate::commands::blend_from_str(v) == Some(m))).map(|(k, _)| *k) +} + +/// Photoshop ↔ PhotoCraft value tables; Photoshop codes compare without their space padding. +fn lookup<'a>(table: &'a [(&'a str, &'a str)], key: &str) -> Option<&'a str> { + table.iter().find(|(k, _)| k.trim_end() == key.trim_end()).map(|(_, v)| *v) +} + +fn lookup_back<'a>(table: &'a [(&'a str, &'a str)], value: &str) -> Option<&'a str> { + table.iter().find(|(_, v)| *v == value).map(|(k, _)| *k) +} + +/// `Chnl` enumeration ↔ the per-channel keys of the adjustment commands. +const CHANNELS: &[(&str, &str)] = &[ + ("Rd", "red"), + ("Grn", "green"), + ("Bl", "blue"), + ("Gry", "gray"), + ("Cyn", "cyan"), + ("Mgnt", "magenta"), + ("Yllw", "yellow"), + ("Blck", "black"), + ("Lmnc", "lightness"), + ("A", "a"), + ("B", "b"), +]; + +/// The channel an adjustment entry's `Chnl` reference names: `None` = the composite. +fn channel_key(d: &Descriptor) -> Option> { + let r = get_ref(d, "Chnl")?; + Some(match target(r) { + Target::Ordinal(o) if o == "Trgt" || o == "Cmps" => None, + Target::Ordinal(o) => Some(lookup(CHANNELS, &o)?), + _ => return None, + }) +} + +fn channel_ref(key: Option<&str>) -> D { + let value = key.and_then(|k| lookup_back(CHANNELS, k)); + D::Reference(vec![ReferenceItem::Enumerated { + class: class("Chnl"), + type_id: code(if value.is_some() { "Chnl" } else { "Ordn" }), + value: code(value.unwrap_or("Trgt")), + }]) +} + +// ------------------------------------------------------------------ field tables + +#[derive(Clone, Copy)] +enum Kind { + Px, + Pct, + Ang, + Long, + Dbl, + Bool, + /// An enumeration: its type id and the Photoshop ↔ PhotoCraft value pairs. + Enum(&'static str, &'static [(&'static str, &'static str)]), + /// A blend mode (`BlnM`). + Blend, + /// A colour object ↔ `#rrggbb`. + Color, +} + +/// One descriptor key ↔ one command param. +struct F(&'static str, &'static str, Kind); + +fn fields_from(d: &Descriptor, fields: &[F]) -> Option> { + let mut m = Map::new(); + for F(key, param, kind) in fields { + let v = match kind { + Kind::Px | Kind::Pct | Kind::Ang | Kind::Long | Kind::Dbl => get_num(d, key).map(Value::from), + Kind::Bool => get_bool(d, key).map(Value::from), + Kind::Enum(_, table) => match get_enum(d, key) { + Some(e) => Some(Value::from(lookup(table, &e)?)), + None => None, + }, + Kind::Blend => match get_enum(d, key) { + Some(e) => Some(Value::from(blend_label(&e)?)), + None => None, + }, + Kind::Color => match get_obj(d, key) { + Some(c) => Some(Value::from(color_hex(c)?)), + None => None, + }, + }; + if let Some(v) = v { + m.insert((*param).into(), v); + } + } + Some(m) +} + +fn fields_to(p: &Value, fields: &[F], mut d: Descriptor) -> Option { + for F(key, param, kind) in fields { + let Some(v) = p.get(param) else { continue }; + let out = match kind { + Kind::Px => px(v.as_f64()?), + Kind::Pct => pct(v.as_f64()?), + Kind::Ang => ang(v.as_f64()?), + Kind::Long => long(v.as_f64()?), + Kind::Dbl => D::Double(v.as_f64()?), + Kind::Bool => D::Boolean(v.as_bool()?), + Kind::Enum(ty, table) => enm(ty, lookup_back(table, v.as_str()?)?), + Kind::Blend => enm("BlnM", blend_ps(v.as_str()?)?), + Kind::Color => D::Descriptor(color_desc(v.as_str()?)?), + }; + d = d.with(key, out); + } + Some(d) +} + +// ------------------------------------------------------------------ adjustments + +/// The `Adjs` entries of a per-channel adjustment (Levels, Curves): `(channel key, entry)`. +fn per_channel(d: &Descriptor) -> Option, &Descriptor)>> { + get_list(d, "Adjs")? + .iter() + .map(|v| match v { + D::Descriptor(e) => Some((channel_key(e)?, e)), + _ => None, + }) + .collect() +} + +fn levels_from(d: &Descriptor) -> Option { + if get_bool(d, "Auto") == Some(true) { + return None; + } + let mut out = Map::new(); + for (chan, e) in per_channel(d)? { + let mut m = Map::new(); + if let Some(i) = get_list(e, "Inpt") { + if let Some(v) = i.first().and_then(num) { + m.insert("inBlack".into(), v.into()); + } + if let Some(v) = i.get(1).and_then(num) { + m.insert("inWhite".into(), v.into()); + } + } + if let Some(o) = get_list(e, "Otpt") { + if let Some(v) = o.first().and_then(num) { + m.insert("outBlack".into(), v.into()); + } + if let Some(v) = o.get(1).and_then(num) { + m.insert("outWhite".into(), v.into()); + } + } + if let Some(g) = get_num(e, "Gmm ") { + m.insert("gamma".into(), g.into()); + } + match chan { + None => out.extend(m), + Some(k) => { + out.insert(k.into(), Value::Object(m)); + } + } + } + Some(Value::Object(out)) +} + +fn levels_entry(chan: Option<&str>, p: &Value) -> Descriptor { + let mut e = desc("LvlA").with("Chnl", channel_ref(chan)); + if jnum(p, "inBlack").is_some() || jnum(p, "inWhite").is_some() { + e = e.with("Inpt", D::List(vec![long(jnum(p, "inBlack").unwrap_or(0.0)), long(jnum(p, "inWhite").unwrap_or(255.0))])); + } + if let Some(g) = jnum(p, "gamma") { + e = e.with("Gmm ", D::Double(g)); + } + if jnum(p, "outBlack").is_some() || jnum(p, "outWhite").is_some() { + e = e.with("Otpt", D::List(vec![long(jnum(p, "outBlack").unwrap_or(0.0)), long(jnum(p, "outWhite").unwrap_or(255.0))])); + } + e +} + +fn levels_to(p: &Value) -> Option { + let mut entries = vec![D::Descriptor(levels_entry(None, p))]; + for (_, k) in CHANNELS { + if let Some(c) = p.get(*k).filter(|v| v.is_object()) { + entries.push(D::Descriptor(levels_entry(Some(k), c))); + } + } + Some(desc("Lvls").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) +} + +fn curve_points(e: &Descriptor) -> Option { + let pts: Option> = get_list(e, "Crv ")? + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + pts.map(Value::Array) +} + +fn curves_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (chan, e) in per_channel(d)? { + let pts = curve_points(e)?; + out.insert(chan.unwrap_or("points").into(), pts); + } + Some(Value::Object(out)) +} + +fn curve_entry(chan: Option<&str>, pts: &Value) -> Option { + let list: Option> = pts + .as_array()? + .iter() + .map(|pt| { + let a = pt.as_array()?; + Some(D::Descriptor(desc("Pnt ").with("Hrzn", D::Double(a.first()?.as_f64()?)).with("Vrtc", D::Double(a.get(1)?.as_f64()?)))) + }) + .collect(); + Some(desc("CrvA").with("Chnl", channel_ref(chan)).with("Crv ", D::List(list?))) +} + +fn curves_to(p: &Value) -> Option { + let mut entries = Vec::new(); + if let Some(pts) = p.get("points") { + entries.push(D::Descriptor(curve_entry(None, pts)?)); + } + for (_, k) in CHANNELS { + if let Some(pts) = p.get(*k) { + entries.push(D::Descriptor(curve_entry(Some(k), pts)?)); + } + } + Some(desc("Crvs").with("presetKind", enm("presetKindType", "presetKindCustom")).with("Adjs", D::List(entries))) +} + +const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"]; + +fn hue_sat_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + if let Some(c) = get_bool(d, "Clrz") { + out.insert("colorize".into(), c.into()); + } + for e in get_list(d, "Adjs").unwrap_or(&[]) { + let D::Descriptor(e) = e else { return None }; + let mut m = Map::new(); + for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + if let Some(v) = get_num(e, key) { + m.insert(param.into(), v.into()); + } + } + match get_num(e, "LclR") { + None => out.extend(m), + Some(r) => { + let name = *HUE_RANGES.get((r as usize).checked_sub(1)?)?; + if let (Some(a), Some(b), Some(c), Some(dd)) = (get_num(e, "BgnR"), get_num(e, "BgnS"), get_num(e, "EndS"), get_num(e, "EndR")) { + m.insert("range".into(), json!([a, b, c, dd])); + } + out.insert(name.into(), Value::Object(m)); + } + } + } + Some(Value::Object(out)) +} + +fn hue_entry(p: &Value, range: Option<(usize, &Value)>) -> Option { + let mut e = desc("Hst2"); + if let Some((i, r)) = range { + e = e.with("LclR", long(i as f64 + 1.0)); + if let Some(a) = r.get("range").and_then(Value::as_array) + && a.len() == 4 + { + for (k, v) in ["BgnR", "BgnS", "EndS", "EndR"].iter().zip(a) { + e = e.with(k, long(v.as_f64()?)); + } + } + } + for (key, param) in [("Hue ", "hue"), ("Strt", "saturation"), ("Lght", "lightness")] { + e = e.with(key, long(jnum(p, param).unwrap_or(0.0))); + } + Some(e) +} + +fn hue_sat_to(p: &Value) -> Option { + let mut entries = vec![D::Descriptor(hue_entry(p, None)?)]; + for (i, name) in HUE_RANGES.iter().enumerate() { + if let Some(r) = p.get(*name).filter(|v| v.is_object()) { + entries.push(D::Descriptor(hue_entry(r, Some((i, r)))?)); + } + } + Some( + desc("HStr") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Clrz", D::Boolean(jbool(p, "colorize").unwrap_or(false))) + .with("Adjs", D::List(entries)), + ) +} + +fn triple(d: &Descriptor, k: &str) -> Option { + let l = get_list(d, k)?; + let v: Option> = l.iter().map(num).collect(); + Some(json!(v?)) +} + +fn triple_to(p: &Value, k: &str) -> Option { + Some(D::List(p.get(k)?.as_array()?.iter().map(|v| v.as_f64().map(long)).collect::>>()?)) +} + +fn color_balance_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { + if let Some(v) = triple(d, key) { + out.insert(param.into(), v); + } + } + if let Some(b) = get_bool(d, "PrsL") { + out.insert("preserveLuminosity".into(), b.into()); + } + Some(Value::Object(out)) +} + +fn color_balance_to(p: &Value) -> Option { + let mut d = desc("ClrB"); + for (key, param) in [("ShdL", "shadows"), ("MdtL", "midtones"), ("HghL", "highlights")] { + d = d.with(key, triple_to(p, param).unwrap_or_else(|| D::List(vec![long(0.0), long(0.0), long(0.0)]))); + } + Some(d.with("PrsL", D::Boolean(jbool(p, "preserveLuminosity").unwrap_or(true)))) +} + +const BW: &[F] = &[ + F("Rd ", "reds", Kind::Long), + F("Yllw", "yellows", Kind::Long), + F("Grn ", "greens", Kind::Long), + F("Cyn ", "cyans", Kind::Long), + F("Bl ", "blues", Kind::Long), + F("Mgnt", "magentas", Kind::Long), + F("useTint", "tint", Kind::Bool), + F("tintColor", "tintColor", Kind::Color), +]; + +fn mixer_channel(e: &Descriptor) -> Option { + Some(json!([get_num(e, "Rd ").unwrap_or(0.0), get_num(e, "Grn ").unwrap_or(0.0), get_num(e, "Bl ").unwrap_or(0.0), get_num(e, "Cnst").unwrap_or(0.0)])) +} + +fn channel_mixer_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + for (key, param) in [("Rd ", "red"), ("Grn ", "green"), ("Bl ", "blue"), ("Gry ", "gray")] { + if let Some(e) = get_obj(d, key) { + out.insert(param.into(), mixer_channel(e)?); + } + } + if let Some(m) = get_bool(d, "Mnch") { + out.insert("monochrome".into(), m.into()); + } + Some(Value::Object(out)) +} + +fn channel_mixer_to(p: &Value) -> Option { + let mut d = desc("ChnM").with("presetKind", enm("presetKindType", "presetKindCustom")); + for (key, param) in [("Rd ", "red"), ("Grn ", "green"), ("Bl ", "blue"), ("Gry ", "gray")] { + if let Some(a) = p.get(param).and_then(Value::as_array) { + let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + d = d.with(key, D::Descriptor(desc("ChMx").with("Rd ", pct(v(0))).with("Grn ", pct(v(1))).with("Bl ", pct(v(2))).with("Cnst", pct(v(3))))); + } + } + Some(d.with("Mnch", D::Boolean(jbool(p, "monochrome").unwrap_or(false)))) +} + +const SELECTIVE_RANGES: &[(&str, &str)] = &[ + ("Rds", "reds"), + ("Yllw", "yellows"), + ("Grns", "greens"), + ("Cyns", "cyans"), + ("Bls", "blues"), + ("Mgnt", "magentas"), + ("Whts", "whites"), + ("Ntrl", "neutrals"), + ("Blks", "blacks"), +]; + +fn selective_from(d: &Descriptor) -> Option { + let mut out = Map::new(); + if let Some(m) = get_enum(d, "Mthd") { + out.insert("method".into(), (if m == "Absl" { "absolute" } else { "relative" }).into()); + } + for e in get_list(d, "ClrC").unwrap_or(&[]) { + let D::Descriptor(e) = e else { return None }; + let name = lookup(SELECTIVE_RANGES, &get_enum(e, "Clrs")?)?; + let v = |k: &str| get_num(e, k).unwrap_or(0.0); + out.insert(name.into(), json!([v("Cyn "), v("Mgnt"), v("Yllw"), v("Blck")])); + } + Some(Value::Object(out)) +} + +fn selective_to(p: &Value) -> Option { + let mut list = Vec::new(); + for (ps, name) in SELECTIVE_RANGES { + if let Some(a) = p.get(*name).and_then(Value::as_array) { + let v = |i: usize| a.get(i).and_then(Value::as_f64).unwrap_or(0.0); + list.push(D::Descriptor( + desc("ClrC").with("Clrs", enm("Clrs", ps)).with("Cyn ", pct(v(0))).with("Mgnt", pct(v(1))).with("Yllw", pct(v(2))).with("Blck", pct(v(3))), + )); + } + } + Some( + desc("SlcC") + .with("presetKind", enm("presetKindType", "presetKindCustom")) + .with("Mthd", enm("CrcM", if jstr(p, "method") == Some("absolute") { "Absl" } else { "Rltv" })) + .with("ClrC", D::List(list)), + ) +} + +/// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and +/// the converters between the adjustment descriptor and the command params. +struct Adjustment { + class: &'static str, + suffix: &'static str, + from: fn(&Descriptor) -> Option, + to: fn(&Value) -> Option, +} + +macro_rules! adj_fields { + ($class:literal, $suffix:literal, $fields:expr) => { + Adjustment { class: $class, suffix: $suffix, from: |d| fields_from(d, $fields).map(Value::Object), to: |p| fields_to(p, $fields, desc($class)) } + }; +} + +const ADJUSTMENTS: &[Adjustment] = &[ + Adjustment { class: "Lvls", suffix: "levels", from: levels_from, to: levels_to }, + Adjustment { class: "Crvs", suffix: "curves", from: curves_from, to: curves_to }, + Adjustment { class: "HStr", suffix: "hueSaturation", from: hue_sat_from, to: hue_sat_to }, + adj_fields!("BrgC", "brightnessContrast", &[F("Brgh", "brightness", Kind::Long), F("Cntr", "contrast", Kind::Long), F("useLegacy", "legacy", Kind::Bool)]), + adj_fields!("Thrs", "threshold", &[F("Lvl ", "level", Kind::Long)]), + adj_fields!("Pstr", "posterize", &[F("Lvls", "levels", Kind::Long)]), + adj_fields!("Invr", "invert", &[]), + Adjustment { class: "ClrB", suffix: "colorBalance", from: color_balance_from, to: color_balance_to }, + adj_fields!("Vbrn", "vibrance", &[F("vibrance", "vibrance", Kind::Long), F("Strt", "saturation", Kind::Long)]), + adj_fields!("Exps", "exposure", &[F("Exps", "exposure", Kind::Dbl), F("Ofst", "offset", Kind::Dbl), F("gammaCorrection", "gamma", Kind::Dbl)]), + adj_fields!("PhtF", "photoFilter", &[F("Clr ", "color", Kind::Color), F("Dnst", "density", Kind::Long), F("PrsL", "preserveLuminosity", Kind::Bool)]), + adj_fields!("BlnW", "blackWhite", BW), + Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, + Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, +]; + +fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { + ADJUSTMENTS.iter().find(|a| a.class.trim_end() == c.trim_end()) +} + +fn adjustment_by_suffix(suffix: &str) -> Option<&'static Adjustment> { + ADJUSTMENTS.iter().find(|a| a.suffix == suffix) +} + +// ------------------------------------------------------------------ the event table + +/// What one Photoshop step maps to. +enum Mapped { + /// One or more PhotoCraft steps (a `make` with a name becomes new layer + rename). + Steps(Vec<(String, Value)>), + /// No PhotoCraft equivalent; `harmless` steps (tool selections…) are imported switched off. + Unsupported { harmless: bool }, +} + +fn step(id: &str, params: Value) -> Mapped { + Mapped::Steps(vec![(id.to_string(), params)]) +} + +fn steps(v: Vec<(&str, Value)>) -> Mapped { + Mapped::Steps(v.into_iter().map(|(id, p)| (id.to_string(), p)).collect()) +} + +fn none(id: &str) -> Mapped { + step(id, json!({})) +} + +/// An event: code (empty when Photoshop only has a string name), string name, the panel +/// label, the command ids it exports (the first is the one simple events import to), and the +/// two converters. +struct Event { + code: &'static str, + name: &'static str, + label: &'static str, + ids: &'static [&'static str], + from: fn(&Descriptor) -> Mapped, + to: fn(&str, &Value) -> Option, +} + +macro_rules! ev { + ($code:literal, $name:literal, $label:literal, $id:literal, $fields:expr) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[$id], + from: |d| match fields_from(d, $fields) { + Some(m) => step($id, Value::Object(m)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| fields_to(p, $fields, desc("null")), + } + }; +} + +const INTERPOLATION: &[(&str, &str)] = &[ + ("Bcbc", "bicubic"), + ("Blnr", "bilinear"), + ("Nrst", "nearest"), + ("bicubicSharper", "bicubicSharper"), + ("bicubicSmoother", "bicubicSmoother"), + ("bicubicAutomatic", "bicubicAutomatic"), + ("automaticInterpolation", "automatic"), + ("preserveDetailsUpscale", "preserveDetails"), + ("deepUpscale", "preserveDetails"), +]; +const NOISE: &[(&str, &str)] = &[("Unfr", "uniform"), ("Gsn ", "gaussian")]; +const OFFSET_FILL: &[(&str, &str)] = &[("Wrp ", "wrap"), ("Rpt ", "repeat"), ("Bckg", "transparent")]; +const TRIM: &[(&str, &str)] = &[("Trns", "transparent"), ("topLeftPixelColor", "topLeft"), ("bottomRightPixelColor", "bottomRight")]; +const EXTENSION: &[(&str, &str)] = &[("Bckg", "background"), ("Frgd", "foreground"), ("Wht ", "white"), ("Blck", "black"), ("Trns", "transparent")]; +const FILL_CONTENTS: &[(&str, &str)] = &[ + ("FrgC", "foreground"), + ("BckC", "background"), + ("Wht ", "white"), + ("Blck", "black"), + ("Gry ", "gray"), + ("Clr ", "color"), + ("HstS", "history"), + ("contentAware", "contentAware"), +]; +const STROKE_LOCATION: &[(&str, &str)] = &[("Insd", "inside"), ("Cntr", "center"), ("OtsF", "outside")]; +const INTENT: &[(&str, &str)] = &[("Img ", "perceptual"), ("Clrm", "relative"), ("Grp ", "saturation"), ("AbsC", "absolute")]; +const PROFILES: &[(&str, &str)] = + &[("sRGB IEC61966-2.1", "srgb"), ("Display P3", "display-p3"), ("Adobe RGB (1998)", "adobe-rgb-compat"), ("ProPhoto RGB", "prophoto-compat")]; +const SMART_BLUR_QUALITY: &[(&str, &str)] = &[("Hgh ", "high"), ("Mdm ", "medium"), ("Low ", "low")]; +const SMART_BLUR_MODE: &[(&str, &str)] = &[("SBMN", "normal"), ("SBME", "edgeOnly"), ("SBMO", "overlayEdge")]; +const RADIAL_METHOD: &[(&str, &str)] = &[("Spn ", "spin"), ("Zm ", "zoom")]; +const RADIAL_QUALITY: &[(&str, &str)] = &[("Drft", "draft"), ("Gd ", "good"), ("Bst ", "best")]; +const LABEL_COLORS: &[(&str, &str)] = + &[("None", "none"), ("Rd", "red"), ("Orng", "orange"), ("Ylw", "yellow"), ("Grn", "green"), ("Bl", "blue"), ("Vlt", "violet"), ("Gry", "gray")]; +const ALIGN: &[(&str, &str)] = + &[("AdLf", "leftEdges"), ("AdCH", "horizontalCenters"), ("AdRg", "rightEdges"), ("AdTp", "topEdges"), ("AdCV", "verticalCenters"), ("AdBt", "bottomEdges")]; +const PURGE: &[(&str, &str)] = &[("Al", "all"), ("Clpb", "clipboard"), ("Hsty", "histories"), ("Undo", "undo")]; +const MODES: &[(&str, &str)] = &[("RGBM", "rgb"), ("Grys", "grayscale"), ("CMYM", "cmyk"), ("LbCM", "lab"), ("Mltc", "multichannel")]; + +// Events whose target depends on the descriptor (`make`, `set`, `select`…): custom converters. + +fn adjustment_steps(a: &Adjustment, prefix: &str, type_desc: Option<&Descriptor>) -> Mapped { + let params = match type_desc { + Some(t) if get_enum(t, "presetKind").as_deref() != Some("presetKindDefault") => match (a.from)(t) { + Some(p) => p, + None => return Mapped::Unsupported { harmless: false }, + }, + _ => json!({}), + }; + step(&format!("{prefix}{}", a.suffix), params) +} + +fn make_from(d: &Descriptor) -> Mapped { + if let Some(nw) = get_obj(d, "Nw ") { + // Layer via Copy / Cut (`Usng` = the selection), or a new document. + if get_enum(d, "Usng").as_deref() == Some("Slct") { + let id = if get_bool(d, "Cpy ") == Some(true) { "layer.new.layerViaCopy" } else { "layer.new.layerViaCut" }; + let mut v = vec![(id, json!({}))]; + if let Some(n) = get_text(nw, "Nm ") { + v.push(("layer.renameLayer", json!({"name": n}))); + } + return steps(v); + } + if get_text(nw, "preset").is_some() { + return none("file.newFromClipboard"); + } + if let (Some(w), Some(h)) = (get_num(nw, "Wdth"), get_num(nw, "Hght")) { + let res = get_num(nw, "Rslt").unwrap_or(72.0); + // Dimensions are in points (`#Rlt`) unless given in pixels. + let to_px = |k: &str, v: f64| match nw.get(k) { + Some(D::UnitFloat { unit: PX, .. }) => v, + _ => v * res / 72.0, + }; + let mut p = json!({"width": to_px("Wdth", w).round(), "height": to_px("Hght", h).round(), "resolution": res}); + if let Some(n) = get_text(nw, "Nm ") { + p["name"] = json!(n); + } + if let Some(D::Class(c)) = nw.get("Md ") + && let Some(m) = lookup(MODES, &trimmed(&c.class_id)) + { + p["mode"] = json!(m); + } + if let Some(depth) = get_num(nw, "Dpth") { + p["depth"] = json!(depth); + } + if let Some(f) = get_enum(nw, "Fl ") { + p["background"] = json!(match f.as_str() { + "Wht" => "white", + "BckC" => "backgroundColor", + "Trns" => "transparent", + _ => "white", + }); + } + return step("file.new", p); + } + return Mapped::Unsupported { harmless: false }; + } + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let using = get_obj(d, "Usng"); + let type_desc = using.and_then(|u| get_obj(u, "Type")); + match ref_class(r).as_deref() { + Some("Lyr") => { + let mut p = Map::new(); + if let Some(u) = using { + if let Some(n) = get_text(u, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(o) = get_num(u, "Opct") { + p.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(m) = get_enum(u, "Md ").and_then(|m| blend_label(&m)) { + p.insert("mode".into(), m.into()); + } + } + step("layer.new.layer", Value::Object(p)) + } + Some("AdjL") => match type_desc.and_then(|t| adjustment_by_class(&trimmed(&t.class_id))) { + Some(a) => adjustment_steps(a, "layer.newAdjustmentLayer.", type_desc), + None => Mapped::Unsupported { harmless: false }, + }, + Some("contentLayer") => match type_desc { + Some(t) if t.class_id.is("solidColorLayer") => match get_obj(t, "Clr ").and_then(color_hex) { + Some(c) => step("layer.newFillLayer.solidColor", json!({"color": c})), + None => none("layer.newFillLayer.solidColor"), + }, + _ => Mapped::Unsupported { harmless: false }, + }, + Some("layerSection") => { + let mut p = Map::new(); + if let Some(n) = using.and_then(|u| get_text(u, "Nm ")) { + p.insert("name".into(), n.into()); + } + let id = if get_ref(d, "From").is_some() { "layer.groupLayers" } else { "layer.new.group" }; + step(id, Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn make_to(id: &str, p: &Value) -> Option { + let with_name = |cls: &str| { + let mut u = desc(cls); + if let Some(n) = jstr(p, "name") { + u = u.with("Nm ", text(n)); + } + u + }; + Some(match id { + "layer.new.layer" => { + let mut u = with_name("Lyr "); + if let Some(o) = jnum(p, "opacity") { + u = u.with("Opct", pct(o * 100.0)); + } + if let Some(m) = jstr(p, "mode").and_then(blend_ps) { + u = u.with("Md ", enm("BlnM", m)); + } + desc("null").with("null", class_ref("Lyr ")).with("Usng", D::Descriptor(u)) + } + "layer.new.group" => desc("null").with("null", class_ref("layerSection")).with("Usng", D::Descriptor(with_name("layerSection"))), + "layer.groupLayers" => { + desc("null").with("null", class_ref("layerSection")).with("From", target_ref("Lyr ")).with("Usng", D::Descriptor(with_name("layerSection"))) + } + "layer.new.layerViaCopy" | "layer.new.layerViaCut" => { + let mut d = desc("null").with("Nw ", D::Descriptor(desc("Lyr "))).with("Usng", enm("ArSl", "Slct")); + if id == "layer.new.layerViaCopy" { + d = d.with("Cpy ", D::Boolean(true)); + } + d + } + "layer.newFillLayer.solidColor" => { + let mut t = desc("solidColorLayer"); + if let Some(c) = jstr(p, "color").and_then(color_desc) { + t = t.with("Clr ", D::Descriptor(c)); + } + desc("null").with("null", class_ref("contentLayer")).with("Usng", D::Descriptor(desc("contentLayer").with("Type", D::Descriptor(t)))) + } + "file.newFromClipboard" => desc("null").with("Nw ", D::Descriptor(desc("Dcmn").with("preset", text("Clipboard")))), + "file.new" => { + let mut n = desc("Dcmn"); + if let Some(name) = jstr(p, "name") { + n = n.with("Nm ", text(name)); + } + if let Some(m) = jstr(p, "mode").and_then(|m| lookup_back(MODES, if m == "gray" { "grayscale" } else { m })) { + n = n.with("Md ", D::Class(class(m))); + } + n = n.with("Wdth", px(jnum(p, "width").unwrap_or(1920.0))).with("Hght", px(jnum(p, "height").unwrap_or(1080.0))); + n = n.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: jnum(p, "resolution").unwrap_or(72.0) }); + let fill = match jstr(p, "background") { + Some("transparent") => "Trns", + Some("backgroundColor") => "BckC", + _ => "Wht ", + }; + n = n.with("Fl ", enm("Fl ", fill)).with("Dpth", long(jnum(p, "depth").unwrap_or(8.0))); + desc("null").with("Nw ", D::Descriptor(n)) + } + _ => { + let suffix = id.strip_prefix("layer.newAdjustmentLayer.")?; + let a = adjustment_by_suffix(suffix)?; + let t = if p.as_object().is_some_and(|o| o.is_empty()) { + desc(a.class).with("presetKind", enm("presetKindType", "presetKindDefault")) + } else { + (a.to)(p)? + }; + desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(t)))) + } + }) +} + +fn set_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let unsupported = Mapped::Unsupported { harmless: false }; + match (ref_class(r).as_deref(), target(r)) { + (Some("Chnl"), Target::Property(p)) if p == "fsel" => match d.get("T ") { + Some(D::Enumerated { value, .. }) => match trimmed(value).as_str() { + "Al" => none("select.all"), + "None" => none("select.deselect"), + "Prvs" => none("select.reselect"), + _ => unsupported, + }, + Some(D::Reference(t)) => match (ref_class(t).as_deref(), target(t)) { + (Some("Chnl"), Target::Ordinal(o)) if o == "Trsp" => step("select.loadSelection", json!({"channel": "transparency"})), + (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), + _ => unsupported, + }, + Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { + let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) + else { + return unsupported; + }; + let mut p = json!({"x": left.round(), "y": top.round(), "width": (right - left).round().max(0.0), "height": (bottom - top).round().max(0.0)}); + if t.class_id.is("Elps") { + p["ellipse"] = json!(true); + } + step("select.rect", p) + } + _ => unsupported, + }, + (Some("Lyr"), _) => { + let Some(mut p) = layer_target(r) else { return unsupported }; + let Some(t) = get_obj(d, "T ") else { return unsupported }; + let mut v = Vec::new(); + if let Some(c) = get_enum(t, "Clr ").and_then(|c| lookup(LABEL_COLORS, &c)) { + let mut lp = p.clone(); + lp.insert("color".into(), c.into()); + v.push(("layer.setLabelColor", Value::Object(lp))); + } + if let Some(n) = get_text(t, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(o) = get_num(t, "Opct") { + p.insert("opacity".into(), (o / 100.0).into()); + } + if let Some(f) = get_num(t, "fillOpacity") { + p.insert("fill".into(), (f / 100.0).into()); + } + if let Some(m) = get_enum(t, "Md ") { + let Some(label) = blend_label(&m) else { return unsupported }; + p.insert("blend".into(), label.into()); + } + if let Some(vis) = get_bool(t, "Vsbl") { + p.insert("visible".into(), vis.into()); + } + if p.keys().any(|k| k != "layer") { + v.push(("layer.setProps", Value::Object(p))); + } + if v.is_empty() { unsupported } else { steps(v) } + } + (Some("AdjL"), Target::Ordinal(o)) if o == "Trgt" => { + match get_obj(d, "T ").and_then(|t| adjustment_by_class(&trimmed(&t.class_id)).map(|a| (a, t))) { + Some((a, t)) => match (a.from)(t) { + Some(p) => step("layer.setAdjustment", p), + None => unsupported, + }, + None => unsupported, + } + } + _ => unsupported, + } +} + +fn set_to(id: &str, p: &Value) -> Option { + let fsel = D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }]); + let sel = |t: D| desc("null").with("null", fsel.clone()).with("T ", t); + Some(match id { + "select.all" => sel(enm("Ordn", "Al ")), + "select.deselect" => sel(enm("Ordn", "None")), + "select.reselect" => sel(enm("Ordn", "Prvs")), + "select.loadSelection" => match jstr(p, "channel")? { + "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), + name => sel(name_ref("Chnl", name)), + }, + "select.rect" => { + let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); + let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; + sel(D::Descriptor(desc(cls).with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))) + } + "layer.setProps" | "layer.renameLayer" | "layer.setLabelColor" => { + let mut t = desc("Lyr "); + if let Some(n) = jstr(p, "name") { + t = t.with("Nm ", text(n)); + } + if let Some(o) = jnum(p, "opacity") { + t = t.with("Opct", pct(o * 100.0)); + } + if let Some(f) = jnum(p, "fill") { + t = t.with("fillOpacity", pct(f * 100.0)); + } + if let Some(m) = jstr(p, "blend") { + t = t.with("Md ", enm("BlnM", blend_ps(m)?)); + } + if let Some(v) = jbool(p, "visible") { + t = t.with("Vsbl", D::Boolean(v)); + } + if let Some(c) = jstr(p, "color") { + t = t.with("Clr ", enm("Clr ", lookup_back(LABEL_COLORS, c)?)); + } + if t.items.is_empty() { + return None; + } + desc("null").with("null", layer_ref(p)).with("T ", D::Descriptor(t)) + } + "layer.setAdjustment" => { + // The adjustment kind isn't in the params: Photoshop needs it, so only steps that + // name one (`kind`) export. + let a = adjustment_by_suffix(jstr(p, "kind")?)?; + desc("null").with("null", target_ref("AdjL")).with("T ", D::Descriptor((a.to)(p)?)) + } + _ => return None, + }) +} + +/// Tool classes Photoshop records when an action switches tools: no document effect. +fn is_tool_class(c: &str) -> bool { + c.ends_with("Tl") || c.ends_with("Tool") +} + +fn select_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + match (ref_class(r).as_deref(), target(r)) { + (Some("Lyr"), Target::Name(n)) => { + let mut p = json!({"layer": n}); + if get_enum(d, "selectionModifier").as_deref() == Some("addToSelection") { + p["mode"] = json!("add"); + } + step("layer.select", p) + } + (Some("Lyr"), Target::Ordinal(o)) => { + // Shift+Alt+[ / ] add the next layer to the selection instead of replacing it. + let add = get_enum(d, "selectionModifier").as_deref() == Some("addToSelection"); + match (o.as_str(), add) { + ("Frwr", false) => none("layer.selectAbove"), + ("Frwr", true) => none("layer.addAboveToSelection"), + ("Bckw", false) => none("layer.selectBelow"), + ("Bckw", true) => none("layer.addBelowToSelection"), + ("Frnt", _) => none("layer.selectTop"), + ("Back", _) => none("layer.selectBottom"), + _ => Mapped::Unsupported { harmless: false }, + } + } + (Some(c), Target::Class) if is_tool_class(c) => Mapped::Unsupported { harmless: true }, + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn select_to(id: &str, p: &Value) -> Option { + let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); + Some(match id { + "layer.select" => { + let mut d = desc("null").with("null", name_ref("Lyr ", jstr(p, "layer")?)); + if jstr(p, "mode") == Some("add") { + d = d.with("selectionModifier", enm("selectionModifierType", "addToSelection")); + } + d.with("MkVs", D::Boolean(false)) + } + "layer.selectAbove" => desc("null").with("null", ord("Frwr")), + "layer.selectBelow" => desc("null").with("null", ord("Bckw")), + "layer.addAboveToSelection" => desc("null").with("null", ord("Frwr")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), + "layer.addBelowToSelection" => desc("null").with("null", ord("Bckw")).with("selectionModifier", enm("selectionModifierType", "addToSelection")), + "layer.selectTop" => desc("null").with("null", ord("Frnt")), + "layer.selectBottom" => desc("null").with("null", ord("Back")), + _ => return None, + }) +} + +fn delete_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null") { + None => none("edit.clear"), + Some(r) if ref_class(r).as_deref() == Some("Lyr") => match layer_target(r) { + Some(p) => step("layer.delete", Value::Object(p)), + None => Mapped::Unsupported { harmless: false }, + }, + Some(_) => Mapped::Unsupported { harmless: false }, + } +} + +fn delete_to(id: &str, p: &Value) -> Option { + Some(match id { + "edit.clear" => desc("null"), + "layer.delete" => desc("null").with("null", layer_ref(p)), + _ => return None, + }) +} + +/// `hide` / `show` take a list of references (or one). +fn visibility_from(d: &Descriptor, id: &str) -> Mapped { + let refs: Vec<&[ReferenceItem]> = match d.get("null") { + Some(D::List(l)) => l.iter().filter_map(|v| if let D::Reference(r) = v { Some(r.as_slice()) } else { None }).collect(), + Some(D::Reference(r)) => vec![r.as_slice()], + _ => return Mapped::Unsupported { harmless: false }, + }; + let mut v = Vec::new(); + for r in refs { + match layer_target(r) { + Some(p) => v.push((id, Value::Object(p))), + None => return Mapped::Unsupported { harmless: false }, + } + } + if v.is_empty() { Mapped::Unsupported { harmless: false } } else { steps(v) } +} + +fn visibility_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", D::List(vec![layer_ref(p)]))) +} + +fn move_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + if ref_class(r).as_deref() != Some("Lyr") { + return Mapped::Unsupported { harmless: false }; + } + let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + match d.get("T ") { + Some(D::Descriptor(t)) => { + let in_px = |k: &str| matches!(t.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(dx), Some(dy)) = (get_num(t, "Hrzn"), get_num(t, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + // Points at an unknown resolution. + return Mapped::Unsupported { harmless: false }; + } + p.insert("dx".into(), dx.round().into()); + p.insert("dy".into(), dy.round().into()); + step("layer.translate", Value::Object(p)) + } + Some(D::Reference(t)) => { + let id = match target(t) { + Target::Ordinal(o) if o == "Frnt" => "layer.arrange.bringToFront", + Target::Ordinal(o) if o == "Back" => "layer.arrange.sendToBack", + Target::Ordinal(o) if o == "Nxt" => "layer.arrange.bringForward", + Target::Ordinal(o) if o == "Prvs" => "layer.arrange.sendBackward", + Target::Offset(1) => "layer.arrange.bringForward", + Target::Offset(-1) => "layer.arrange.sendBackward", + _ => return Mapped::Unsupported { harmless: false }, + }; + step(id, Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn move_to(id: &str, p: &Value) -> Option { + let ord = |o: &str| D::Reference(vec![ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code(o) }]); + let t = match id { + "layer.translate" => D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0)))), + "layer.arrange.bringToFront" => ord("Frnt"), + "layer.arrange.sendToBack" => ord("Back"), + "layer.arrange.bringForward" => ord("Nxt "), + "layer.arrange.sendBackward" => ord("Prvs"), + _ => return None, + }; + Some(desc("null").with("null", layer_ref(p)).with("T ", t)) +} + +fn duplicate_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + match ref_class(r).as_deref() { + Some("Lyr") => { + let Some(mut p) = layer_target(r) else { return Mapped::Unsupported { harmless: false } }; + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + step("layer.duplicate", Value::Object(p)) + } + Some("Dcmn") => { + let mut p = Map::new(); + if let Some(n) = get_text(d, "Nm ") { + p.insert("name".into(), n.into()); + } + if let Some(m) = get_bool(d, "Mrgd") { + p.insert("mergedOnly".into(), m.into()); + } + step("image.duplicate", Value::Object(p)) + } + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn duplicate_to(id: &str, p: &Value) -> Option { + let mut d = match id { + "layer.duplicate" => desc("null").with("null", layer_ref(p)), + "image.duplicate" => { + desc("null").with("null", D::Reference(vec![ReferenceItem::Enumerated { class: class("Dcmn"), type_id: Id::new("Ordn"), value: Id::new("Frst") }])) + } + _ => return None, + }; + if let Some(n) = jstr(p, "name") { + d = d.with("Nm ", text(n)); + } + if let Some(m) = jbool(p, "mergedOnly") { + d = d.with("Mrgd", D::Boolean(m)); + } + Some(d) +} + +fn play_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Name(n)) => step("actions.play", json!({"action": n})), + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn play_to(_: &str, p: &Value) -> Option { + let name = jstr(p, "action")?; + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + Some(desc("null").with("null", D::Reference(items))) +} + +fn fill_from(d: &Descriptor) -> Mapped { + if d.get("From").is_some() { + // The Paint Bucket tool's click, not Edit › Fill. + return Mapped::Unsupported { harmless: false }; + } + let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"contents": contents}); + if contents == "color" { + match get_obj(d, "Clr ").and_then(color_hex) { + Some(c) => p["color"] = json!(c), + None => return Mapped::Unsupported { harmless: false }, + } + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + if let Some(m) = get_enum(d, "Md ") { + match blend_label(&m) { + Some(l) => p["mode"] = json!(l), + None => return Mapped::Unsupported { harmless: false }, + } + } + if let Some(t) = get_bool(d, "PrsT") { + p["preserveTransparency"] = json!(t); + } + step("edit.fill", p) +} + +fn fill_to(_: &str, p: &Value) -> Option { + let contents = jstr(p, "contents").unwrap_or("color"); + let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); + if contents == "color" { + d = d.with("Clr ", D::Descriptor(color_desc(jstr(p, "color")?)?)); + } + d = d.with("Opct", pct(jnum(p, "opacity").unwrap_or(100.0))).with("Md ", enm("BlnM", blend_ps(jstr(p, "mode").unwrap_or("Normal"))?)); + if let Some(t) = jbool(p, "preserveTransparency") { + d = d.with("PrsT", D::Boolean(t)); + } + Some(d) +} + +const STROKE: &[F] = &[ + F("Wdth", "width", Kind::Long), + F("Lctn", "location", Kind::Enum("StrL", STROKE_LOCATION)), + F("Opct", "opacity", Kind::Pct), + F("Md ", "mode", Kind::Blend), + F("Clr ", "color", Kind::Color), + F("PrsT", "preserveTransparency", Kind::Bool), +]; + +/// Whether an event's `null` reference targets a layer (Edit › Transform) rather than the +/// document (Image › Image Rotation). +fn targets_layer(d: &Descriptor) -> bool { + get_ref(d, "null").and_then(ref_class).as_deref() == Some("Lyr") +} + +fn rotate_from(d: &Descriptor) -> Mapped { + let Some(angle) = get_num(d, "Angl") else { return Mapped::Unsupported { harmless: false } }; + let a = angle.rem_euclid(360.0); + let (layer, doc) = if (a - 90.0).abs() < 1e-6 { + ("edit.transform.rotate90Cw", "image.imageRotation.90cw") + } else if (a - 270.0).abs() < 1e-6 { + ("edit.transform.rotate90Ccw", "image.imageRotation.90ccw") + } else if (a - 180.0).abs() < 1e-6 { + ("edit.transform.rotate180", "image.imageRotation.180") + } else if targets_layer(d) { + return Mapped::Unsupported { harmless: false }; + } else { + return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + }; + none(if targets_layer(d) { layer } else { doc }) +} + +fn rotate_to(id: &str, p: &Value) -> Option { + let (angle, layer) = match id { + "edit.transform.rotate90Cw" => (90.0, true), + "edit.transform.rotate90Ccw" => (-90.0, true), + "edit.transform.rotate180" => (180.0, true), + "image.imageRotation.90cw" => (90.0, false), + "image.imageRotation.90ccw" => (-90.0, false), + "image.imageRotation.180" => (180.0, false), + "image.rotation.arbitrary" => (jnum(p, "angle")? * if jstr(p, "direction") == Some("ccw") { -1.0 } else { 1.0 }, false), + _ => return None, + }; + let mut d = desc("null"); + if layer { + d = d.with("null", target_ref("Lyr ")); + } + Some(d.with("Angl", ang(angle))) +} + +fn flip_from(d: &Descriptor) -> Mapped { + let Some(axis) = get_enum(d, "Axis") else { return Mapped::Unsupported { harmless: false } }; + let horizontal = axis == "Hrzn"; + none(match (targets_layer(d), horizontal) { + (true, true) => "edit.transform.flipHorizontal", + (true, false) => "edit.transform.flipVertical", + (false, true) => "image.imageRotation.flipCanvasHorizontal", + (false, false) => "image.imageRotation.flipCanvasVertical", + }) +} + +fn flip_to(id: &str, _: &Value) -> Option { + let (layer, axis) = match id { + "edit.transform.flipHorizontal" => (true, "Hrzn"), + "edit.transform.flipVertical" => (true, "Vrtc"), + "image.imageRotation.flipCanvasHorizontal" => (false, "Hrzn"), + "image.imageRotation.flipCanvasVertical" => (false, "Vrtc"), + _ => return None, + }; + let mut d = desc("null"); + if layer { + d = d.with("null", target_ref("Lyr ")); + } + Some(d.with("Axis", enm("Ornt", axis))) +} + +fn convert_mode_from(d: &Descriptor) -> Mapped { + if let Some(depth) = get_num(d, "Dpth") { + return match depth as i64 { + 8 => none("image.mode.bits8"), + 16 => none("image.mode.bits16"), + 32 => none("image.mode.bits32"), + _ => Mapped::Unsupported { harmless: false }, + }; + } + match get_enum(d, "T ").and_then(|t| lookup(MODES, &t)) { + Some(m) => none(&format!("image.mode.{m}")), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn convert_mode_to(id: &str, _: &Value) -> Option { + Some(match id { + "image.mode.bits8" => desc("null").with("Dpth", long(8.0)), + "image.mode.bits16" => desc("null").with("Dpth", long(16.0)), + "image.mode.bits32" => desc("null").with("Dpth", long(32.0)), + _ => desc("null").with("T ", enm("ClrS", lookup_back(MODES, id.strip_prefix("image.mode.")?)?)), + }) +} + +fn canvas_size_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + for (k, param) in [("Wdth", "width"), ("Hght", "height")] { + match d.get(k) { + Some(D::UnitFloat { unit: PX, value }) => { + p.insert(param.into(), value.round().into()); + } + Some(_) => return Mapped::Unsupported { harmless: false }, + None => {} + } + } + if let Some(r) = get_bool(d, "Rltv") { + p.insert("relative".into(), r.into()); + } + let v = get_enum(d, "Vrtc").unwrap_or_else(|| "Cntr".into()); + let h = get_enum(d, "Hrzn").unwrap_or_else(|| "Cntr".into()); + let anchor = match (v.as_str(), h.as_str()) { + ("Top", "Left") => "topLeft", + ("Top", "Cntr") => "top", + ("Top", "Rght") => "topRight", + ("Cntr", "Left") => "left", + ("Cntr", "Cntr") => "center", + ("Cntr", "Rght") => "right", + ("Bttm", "Left") => "bottomLeft", + ("Bttm", "Cntr") => "bottom", + ("Bttm", "Rght") => "bottomRight", + _ => return Mapped::Unsupported { harmless: false }, + }; + p.insert("anchor".into(), anchor.into()); + if let Some(c) = get_enum(d, "canvasExtensionColorType") { + match lookup(EXTENSION, &c) { + Some(c) => { + p.insert("extensionColor".into(), c.into()); + } + None => match get_obj(d, "canvasExtensionColor").and_then(color_hex) { + Some(hex) => { + p.insert("extensionColor".into(), hex.into()); + } + None => return Mapped::Unsupported { harmless: false }, + }, + } + } + step("image.canvasSize", Value::Object(p)) +} + +fn canvas_size_to(_: &str, p: &Value) -> Option { + let anchor = jstr(p, "anchor").unwrap_or("center"); + let v = if anchor.starts_with("top") { + "Top " + } else if anchor.starts_with("bottom") { + "Bttm" + } else { + "Cntr" + }; + let h = if anchor.to_ascii_lowercase().ends_with("left") { + "Left" + } else if anchor.to_ascii_lowercase().ends_with("right") { + "Rght" + } else { + "Cntr" + }; + let mut d = desc("null"); + if let Some(r) = jbool(p, "relative") { + d = d.with("Rltv", D::Boolean(r)); + } + if let Some(w) = jnum(p, "width") { + d = d.with("Wdth", px(w)); + } + if let Some(hh) = jnum(p, "height") { + d = d.with("Hght", px(hh)); + } + d = d.with("Hrzn", enm("HrzL", h)).with("Vrtc", enm("VrtL", v)); + if let Some(c) = jstr(p, "extensionColor") { + d = match lookup_back(EXTENSION, c) { + Some(e) => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", e)), + None => d.with("canvasExtensionColorType", enm("canvasExtensionColorType", "Othr")).with("canvasExtensionColor", D::Descriptor(color_desc(c)?)), + }; + } + Some(d) +} + +fn image_size_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + for (k, param) in [("Wdth", "width"), ("Hght", "height")] { + match d.get(k) { + Some(D::UnitFloat { unit: PX, value }) => { + p.insert(param.into(), value.round().into()); + } + // Percent or physical units need the document's size: not replayable headlessly. + Some(_) => return Mapped::Unsupported { harmless: false }, + None => {} + } + } + if let Some(r) = get_num(d, "Rslt") { + p.insert("resolution".into(), r.into()); + } + if let Some(i) = get_enum(d, "Intr") { + match lookup(INTERPOLATION, &i) { + Some(i) => { + p.insert("resample".into(), i.into()); + } + None => return Mapped::Unsupported { harmless: false }, + } + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + p.insert("resample".into(), "none".into()); + } + if p.is_empty() { + return Mapped::Unsupported { harmless: false }; + } + step("image.imageSize", Value::Object(p)) +} + +fn image_size_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + if let Some(w) = jnum(p, "width") { + d = d.with("Wdth", px(w)); + } + if let Some(h) = jnum(p, "height") { + d = d.with("Hght", px(h)); + } + if let Some(r) = jnum(p, "resolution") { + d = d.with("Rslt", D::UnitFloat { unit: *b"#Rsl", value: r }); + } + if let Some(i) = jstr(p, "resample").filter(|r| *r != "none") { + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) +} + +fn crop_from(d: &Descriptor) -> Mapped { + let mut p = Map::new(); + if let Some(t) = get_obj(d, "To ") { + let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { + return Mapped::Unsupported { harmless: false }; + }; + p.insert("x".into(), left.round().into()); + p.insert("y".into(), top.round().into()); + p.insert("width".into(), (right - left).round().into()); + p.insert("height".into(), (bottom - top).round().into()); + } + if let Some(a) = get_num(d, "Angl") { + p.insert("angle".into(), a.into()); + } + if let Some(del) = get_bool(d, "Dlt ") { + p.insert("deleteCroppedPixels".into(), del.into()); + } + step("image.crop", Value::Object(p)) +} + +fn crop_to(_: &str, p: &Value) -> Option { + let mut d = desc("null"); + if let (Some(x), Some(y), Some(w), Some(h)) = (jnum(p, "x"), jnum(p, "y"), jnum(p, "width"), jnum(p, "height")) { + d = d.with("To ", D::Descriptor(desc("Rctn").with("Top ", px(y)).with("Left", px(x)).with("Btom", px(y + h)).with("Rght", px(x + w)))); + } + if let Some(a) = jnum(p, "angle") { + d = d.with("Angl", ang(a)); + } + Some(d.with("Dlt ", D::Boolean(jbool(p, "deleteCroppedPixels").unwrap_or(true)))) +} + +fn stop_from(d: &Descriptor) -> Mapped { + step(STOP_MESSAGE, json!({"message": get_text(d, "Msge").unwrap_or_default(), "allowContinue": get_bool(d, "Cntn").unwrap_or(false)})) +} + +fn stop_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("Msge", text(jstr(p, "message").unwrap_or(""))).with("Cntn", D::Boolean(jbool(p, "allowContinue").unwrap_or(false)))) +} + +fn paste_from(d: &Descriptor) -> Mapped { + none(if get_bool(d, "inPlace") == Some(true) { "edit.pasteSpecial.pasteInPlace" } else { "edit.paste" }) +} + +fn paste_to(id: &str, _: &Value) -> Option { + let mut d = desc("null"); + if id == "edit.pasteSpecial.pasteInPlace" { + d = d.with("inPlace", D::Boolean(true)); + } + Some(d.with("AntA", enm("Annt", "Anno"))) +} + +fn merge_visible_from(d: &Descriptor) -> Mapped { + none(if get_bool(d, "Dplc") == Some(true) { "layer.stampVisible" } else { "layer.mergeVisible" }) +} + +fn merge_visible_to(id: &str, _: &Value) -> Option { + Some(if id == "layer.stampVisible" { desc("null").with("Dplc", D::Boolean(true)) } else { desc("null") }) +} + +fn align_from(d: &Descriptor) -> Mapped { + match get_enum(d, "Usng").and_then(|u| lookup(ALIGN, &u)) { + Some(a) => step(&format!("layer.align.{a}"), if get_bool(d, "alignToCanvas") == Some(true) { json!({"to": "canvas"}) } else { json!({}) }), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn align_to(id: &str, p: &Value) -> Option { + let a = lookup_back(ALIGN, id.strip_prefix("layer.align.")?)?; + Some(desc("null").with("null", target_ref("Lyr ")).with("Usng", enm("ADSt", a)).with("alignToCanvas", D::Boolean(jstr(p, "to") == Some("canvas")))) +} + +fn purge_from(d: &Descriptor) -> Mapped { + match get_enum(d, "null").and_then(|u| lookup(PURGE, &u)) { + Some(p) => none(&format!("edit.purge.{p}")), + None => Mapped::Unsupported { harmless: true }, + } +} + +fn purge_to(id: &str, _: &Value) -> Option { + Some(desc("null").with("null", enm("PrgI", lookup_back(PURGE, id.strip_prefix("edit.purge.")?)?))) +} + +fn reset_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Property(p)) if p == "Clrs" => none("tools.defaultColors"), + _ => Mapped::Unsupported { harmless: true }, + } +} + +fn reset_to(_: &str, _: &Value) -> Option { + Some(desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Clr "), key: Id::new("Clrs") }]))) +} + +fn profile_from(d: &Descriptor, id: &str) -> Mapped { + let name = get_text(d, "T ").or_else(|| get_text(d, "profile")); + let Some(profile) = name.as_deref().and_then(|n| lookup(PROFILES, n)) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"profile": profile}); + if id == "edit.convertToProfile" { + if let Some(i) = get_enum(d, "Inte").and_then(|i| lookup(INTENT, &i)) { + p["intent"] = json!(i); + } + if let Some(b) = get_bool(d, "MpBl") { + p["bpc"] = json!(b); + } + } + step(id, p) +} + +fn profile_to(id: &str, p: &Value) -> Option { + let name = lookup_back(PROFILES, jstr(p, "profile")?)?; + let mut d = desc("null").with("null", target_ref("Dcmn")); + if id == "edit.convertToProfile" { + d = d + .with("T ", text(name)) + .with("Inte", enm("Inte", lookup_back(INTENT, jstr(p, "intent").unwrap_or("relative"))?)) + .with("MpBl", D::Boolean(jbool(p, "bpc").unwrap_or(true))); + } else { + d = d.with("profile", text(name)); + } + Some(d) +} + +fn levels_event_from(d: &Descriptor) -> Mapped { + if get_bool(d, "Auto") == Some(true) { + return none("image.autoTone"); + } + if get_bool(d, "AuCo") == Some(true) { + return none("image.autoContrast"); + } + match levels_from(d) { + Some(p) => step("image.adjustments.levels", p), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn levels_event_to(id: &str, p: &Value) -> Option { + Some(match id { + "image.autoTone" => desc("null").with("Auto", D::Boolean(true)), + "image.autoContrast" => desc("null").with("AuCo", D::Boolean(true)), + _ => levels_to(p)?, + }) +} + +/// A direct adjustment event (Image › Adjustments › …). +macro_rules! adjustment_event { + ($code:literal, $class:literal, $name:literal, $label:literal) => { + Event { + code: $code, + name: $name, + label: $label, + ids: &[concat!("image.adjustments.", $name)], + from: |d| match adjustment_by_class($class) { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class($class).and_then(|a| (a.to)(p)), + } + }; +} + +/// Every Photoshop event PhotoCraft maps. The order matters only for the panel label. +const EVENTS: &[Event] = &[ + // Filters. + ev!("GsnB", "gaussianBlur", "Gaussian Blur", "filter.blur.gaussianBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!( + "UnsM", + "unsharpMask", + "Unsharp Mask", + "filter.sharpen.unsharpMask", + &[F("Amnt", "amount", Kind::Pct), F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)] + ), + ev!( + "AdNs", + "addNoise", + "Add Noise", + "filter.noise.addNoise", + &[ + F("Dstr", "distribution", Kind::Enum("Dstr", NOISE)), + F("Nose", "amount", Kind::Pct), + F("Mnch", "monochromatic", Kind::Bool), + F("FlRs", "seed", Kind::Long) + ] + ), + ev!("Mdn ", "median", "Median", "filter.noise.median", &[F("Rds ", "radius", Kind::Px)]), + ev!("DstS", "dustAndScratches", "Dust & Scratches", "filter.noise.dustAndScratches", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!("Dspc", "despeckle", "Despeckle", "filter.noise.despeckle", &[]), + ev!("MtnB", "motionBlur", "Motion Blur", "filter.blur.motionBlur", &[F("Angl", "angle", Kind::Ang), F("Dstn", "distance", Kind::Px)]), + ev!("BoxB", "boxblur", "Box Blur", "filter.blur.boxBlur", &[F("Rds ", "radius", Kind::Px)]), + ev!("SrfB", "surfaceBlur", "Surface Blur", "filter.blur.surfaceBlur", &[F("Rds ", "radius", Kind::Px), F("Thsh", "threshold", Kind::Long)]), + ev!( + "SmrB", + "smartBlur", + "Smart Blur", + "filter.blur.smartBlur", + &[ + F("Rds ", "radius", Kind::Dbl), + F("Thsh", "threshold", Kind::Dbl), + F("SmBQ", "quality", Kind::Enum("SmBQ", SMART_BLUR_QUALITY)), + F("SmBM", "mode", Kind::Enum("SmBM", SMART_BLUR_MODE)) + ] + ), + ev!( + "RdlB", + "radialBlur", + "Radial Blur", + "filter.blur.radialBlur", + &[F("Amnt", "amount", Kind::Long), F("BlrM", "method", Kind::Enum("BlrM", RADIAL_METHOD)), F("BlrQ", "quality", Kind::Enum("BlrQ", RADIAL_QUALITY))] + ), + ev!("Blr ", "blur", "Blur", "filter.blur.blur", &[]), + ev!("BlrM", "blurMore", "Blur More", "filter.blur.blurMore", &[]), + ev!("Shrp", "sharpen", "Sharpen", "filter.sharpen.sharpen", &[]), + ev!("ShrM", "sharpenMore", "Sharpen More", "filter.sharpen.sharpenMore", &[]), + ev!("ShrE", "sharpenEdges", "Sharpen Edges", "filter.sharpen.sharpenEdges", &[]), + ev!("HghP", "highPass", "High Pass", "filter.other.highPass", &[F("Rds ", "radius", Kind::Px)]), + ev!("Mnm ", "minimum", "Minimum", "filter.other.minimum", &[F("Rds ", "radius", Kind::Px)]), + ev!("Mxm ", "maximum", "Maximum", "filter.other.maximum", &[F("Rds ", "radius", Kind::Px)]), + ev!( + "Ofst", + "offset", + "Offset", + "filter.other.offset", + &[F("Hrzn", "horizontal", Kind::Long), F("Vrtc", "vertical", Kind::Long), F("Fl ", "undefinedAreas", Kind::Enum("FlMd", OFFSET_FILL))] + ), + // Adjustments (Image › Adjustments). + Event { + code: "Lvls", + name: "levels", + label: "Levels", + ids: &["image.adjustments.levels", "image.autoTone", "image.autoContrast"], + from: levels_event_from, + to: levels_event_to, + }, + adjustment_event!("Crvs", "Crvs", "curves", "Curves"), + adjustment_event!("HStr", "HStr", "hueSaturation", "Hue/Saturation"), + Event { + code: "BrgC", + name: "brightnessEvent", + label: "Brightness/Contrast", + ids: &["image.adjustments.brightnessContrast"], + from: |d| match adjustment_by_class("BrgC") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("BrgC").and_then(|a| (a.to)(p)), + }, + adjustment_event!("Thrs", "Thrs", "threshold", "Threshold"), + adjustment_event!("Pstr", "Pstr", "posterize", "Posterize"), + adjustment_event!("Invr", "Invr", "invert", "Invert"), + adjustment_event!("ClrB", "ClrB", "colorBalance", "Color Balance"), + adjustment_event!("Vbrn", "Vbrn", "vibrance", "Vibrance"), + adjustment_event!("Exps", "Exps", "exposure", "Exposure"), + adjustment_event!("PhtF", "PhtF", "photoFilter", "Photo Filter"), + Event { + code: "BlnW", + name: "blackAndWhite", + label: "Black & White", + ids: &["image.adjustments.blackWhite"], + from: |d| match adjustment_by_class("BlnW") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), + }, + adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), + adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), + ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), + ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), + ev!("", "autoColor", "Auto Color", "image.autoColor", &[]), + // Image. + Event { code: "ImgS", name: "imageSize", label: "Image Size", ids: &["image.imageSize"], from: image_size_from, to: image_size_to }, + Event { code: "CnvS", name: "canvasSize", label: "Canvas Size", ids: &["image.canvasSize"], from: canvas_size_from, to: canvas_size_to }, + Event { + code: "Rtte", + name: "rotateEventEnum", + label: "Rotate", + ids: &[ + "image.imageRotation.90cw", + "image.imageRotation.90ccw", + "image.imageRotation.180", + "image.rotation.arbitrary", + "edit.transform.rotate90Cw", + "edit.transform.rotate90Ccw", + "edit.transform.rotate180", + ], + from: rotate_from, + to: rotate_to, + }, + Event { + code: "Flip", + name: "flip", + label: "Flip", + ids: &[ + "image.imageRotation.flipCanvasHorizontal", + "image.imageRotation.flipCanvasVertical", + "edit.transform.flipHorizontal", + "edit.transform.flipVertical", + ], + from: flip_from, + to: flip_to, + }, + Event { code: "Crop", name: "crop", label: "Crop", ids: &["image.crop"], from: crop_from, to: crop_to }, + ev!( + "", + "trim", + "Trim", + "image.trim", + &[ + F("trimBasedOn", "basedOn", Kind::Enum("trimBasedOn", TRIM)), + F("Top ", "top", Kind::Bool), + F("Btom", "bottom", Kind::Bool), + F("Left", "left", Kind::Bool), + F("Rght", "right", Kind::Bool) + ] + ), + ev!("RvlA", "revealAll", "Reveal All", "image.revealAll", &[]), + Event { + code: "CnvM", + name: "convertMode", + label: "Convert Mode", + ids: &[ + "image.mode.rgb", + "image.mode.grayscale", + "image.mode.cmyk", + "image.mode.lab", + "image.mode.multichannel", + "image.mode.bits8", + "image.mode.bits16", + "image.mode.bits32", + ], + from: convert_mode_from, + to: convert_mode_to, + }, + Event { code: "Dplc", name: "duplicate", label: "Duplicate", ids: &["layer.duplicate", "image.duplicate"], from: duplicate_from, to: duplicate_to }, + // Layers. + Event { + code: "Mk ", + name: "make", + label: "Make", + ids: &[ + "layer.new.layer", + "layer.new.group", + "layer.groupLayers", + "layer.new.layerViaCopy", + "layer.new.layerViaCut", + "layer.newFillLayer.solidColor", + "layer.newAdjustmentLayer.levels", + "layer.newAdjustmentLayer.curves", + "layer.newAdjustmentLayer.hueSaturation", + "layer.newAdjustmentLayer.brightnessContrast", + "layer.newAdjustmentLayer.threshold", + "layer.newAdjustmentLayer.posterize", + "layer.newAdjustmentLayer.invert", + "layer.newAdjustmentLayer.colorBalance", + "layer.newAdjustmentLayer.vibrance", + "layer.newAdjustmentLayer.exposure", + "layer.newAdjustmentLayer.photoFilter", + "layer.newAdjustmentLayer.blackWhite", + "layer.newAdjustmentLayer.channelMixer", + "layer.newAdjustmentLayer.selectiveColor", + "file.new", + "file.newFromClipboard", + ], + from: make_from, + to: make_to, + }, + Event { + code: "setd", + name: "set", + label: "Set", + ids: &[ + "select.all", + "select.deselect", + "select.reselect", + "select.loadSelection", + "select.rect", + "layer.setProps", + "layer.renameLayer", + "layer.setLabelColor", + "layer.setAdjustment", + ], + from: set_from, + to: set_to, + }, + Event { + code: "slct", + name: "select", + label: "Select", + ids: &[ + "layer.select", + "layer.selectAbove", + "layer.selectBelow", + "layer.addAboveToSelection", + "layer.addBelowToSelection", + "layer.selectTop", + "layer.selectBottom", + ], + from: select_from, + to: select_to, + }, + Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear"], from: delete_from, to: delete_to }, + Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, + Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, + Event { + code: "move", + name: "move", + label: "Move", + ids: &["layer.translate", "layer.arrange.bringToFront", "layer.arrange.sendToBack", "layer.arrange.bringForward", "layer.arrange.sendBackward"], + from: move_from, + to: move_to, + }, + ev!("Fltn", "flattenImage", "Flatten Image", "layer.flattenImage", &[]), + Event { + code: "MrgV", + name: "mergeVisible", + label: "Merge Visible", + ids: &["layer.mergeVisible", "layer.stampVisible"], + from: merge_visible_from, + to: merge_visible_to, + }, + ev!("Mrg2", "mergeLayersNew", "Merge Layers", "layer.mergeLayers", &[]), + ev!("CpTL", "copyToLayer", "Layer Via Copy", "layer.new.layerViaCopy", &[]), + ev!("CtTL", "cutToLayer", "Layer Via Cut", "layer.new.layerViaCut", &[]), + ev!("", "rasterizeLayer", "Rasterize", "layer.rasterize.layer", &[]), + ev!("", "selectAllLayers", "Select All Layers", "select.allLayers", &[]), + ev!("", "selectNoLayers", "Deselect Layers", "select.deselectLayers", &[]), + ev!("Dfrg", "defringe", "Defringe", "layer.matting.defringe", &[F("Wdth", "width", Kind::Px)]), + Event { + code: "Algn", + name: "align", + label: "Align", + ids: &[ + "layer.align.leftEdges", + "layer.align.horizontalCenters", + "layer.align.rightEdges", + "layer.align.topEdges", + "layer.align.verticalCenters", + "layer.align.bottomEdges", + ], + from: align_from, + to: align_to, + }, + // Selection. + ev!("Invs", "inverse", "Inverse", "select.inverse", &[]), + ev!("Fthr", "feather", "Feather", "select.modify.feather", &[F("Rds ", "radius", Kind::Px)]), + ev!("Expn", "expand", "Expand", "select.modify.expand", &[F("By ", "radius", Kind::Px)]), + ev!("Cntc", "contract", "Contract", "select.modify.contract", &[F("By ", "radius", Kind::Px)]), + ev!("Smth", "smoothness", "Smooth", "select.modify.smooth", &[F("Rds ", "radius", Kind::Px)]), + ev!("Brdr", "border", "Border", "select.modify.border", &[F("Wdth", "radius", Kind::Px)]), + ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), + ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), + // Edit. + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), + ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), + ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), + ev!("cut ", "cut", "Cut", "edit.cut", &[]), + Event { code: "past", name: "paste", label: "Paste", ids: &["edit.paste", "edit.pasteSpecial.pasteInPlace"], from: paste_from, to: paste_to }, + Event { + code: "Prge", + name: "purge", + label: "Purge", + ids: &["edit.purge.all", "edit.purge.clipboard", "edit.purge.histories", "edit.purge.undo"], + from: purge_from, + to: purge_to, + }, + Event { code: "Rset", name: "reset", label: "Reset", ids: &["tools.defaultColors"], from: reset_from, to: reset_to }, + ev!("Exch", "exchange", "Exchange", "tools.swapColors", &[]), + Event { + code: "", + name: "assignProfile", + label: "Assign Profile", + ids: &["edit.assignProfile"], + from: |d| profile_from(d, "edit.assignProfile"), + to: profile_to, + }, + Event { + code: "", + name: "convertToProfile", + label: "Convert to Profile", + ids: &["edit.convertToProfile"], + from: |d| profile_from(d, "edit.convertToProfile"), + to: profile_to, + }, + // File and actions. + ev!("Cls ", "close", "Close", "file.close", &[]), + ev!("save", "save", "Save", "file.save", &[]), + ev!("Rvrt", "revert", "Revert", "file.revert", &[]), + Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, + Event { code: "Stop", name: "stop", label: "Stop", ids: &[STOP_MESSAGE], from: stop_from, to: stop_to }, +]; + +fn event_for(item: &AtnEvent) -> Option<&'static Event> { + let id = item.id(); + EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) +} + +fn event_for_id(id: &str) -> Option<&'static Event> { + EVENTS.iter().find(|e| e.ids.contains(&id)) +} + +// ------------------------------------------------------------------ import + +/// A step with no PhotoCraft equivalent. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Unsupported { + pub action: String, + pub step: usize, + pub event: String, + pub name: String, +} + +/// What a `.atn` file imports to. +#[derive(Clone, Debug, Default)] +pub struct Imported { + pub set: String, + pub actions: Vec, + /// Function keys the file assigns, as `(action name, key)`. + pub function_keys: Vec<(String, String)>, + pub unsupported: Vec, + pub warnings: Vec, +} + +fn photoshop_step(item: &AtnItem) -> (String, Value) { + let descriptor = item.descriptor.as_ref().map(|d| photocraft_paint::tile::b64_encode(&d.to_bytes())); + (PHOTOSHOP_STEP.into(), json!({"event": item.event.id(), "name": item.dictionary, "descriptor": descriptor})) +} + +/// Integral floats become integers (`2.0` → `2`), as the commands' own docs and recordings +/// use, so an imported step reads and compares like a recorded one. +fn normalise(v: &mut Value) { + match v { + Value::Number(n) => { + if let Some(f) = n.as_f64() + && f.fract() == 0.0 + && f.abs() < 9.0e15 + && n.as_i64().is_none() + { + *v = Value::from(f as i64); + } + } + Value::Array(a) => a.iter_mut().for_each(normalise), + Value::Object(o) => o.values_mut().for_each(normalise), + _ => {} + } +} + +fn convert_action(a: &AtnAction, set: &str, unsupported: &mut Vec) -> Action { + let name = a.name.to_string_lossy(); + let mut action = Action::new(name.clone(), Vec::new()); + action.set = Some(set.to_string()); + for (i, item) in a.items.iter().enumerate() { + let mapped = match (event_for(&item.event), &item.descriptor) { + (Some(e), Some(d)) => (e.from)(d), + (Some(e), None) => (e.from)(&Descriptor::default()), + (None, _) => Mapped::Unsupported { harmless: false }, + }; + let (new_steps, enabled) = match mapped { + Mapped::Steps(s) => (s, item.enabled), + Mapped::Unsupported { harmless } => { + unsupported.push(Unsupported { action: name.clone(), step: i, event: item.event.id(), name: item.dictionary.clone() }); + (vec![photoshop_step(item)], item.enabled && !harmless) + } + }; + for mut s in new_steps { + normalise(&mut s.1); + let idx = action.steps.len(); + action.steps.push(s); + if !enabled { + action.disabled.insert(idx); + } + if item.with_dialog { + action.dialogs.insert(idx); + } + } + } + action +} + +/// Convert a `.atn` file. Fails only when the file is unreadable: every step imports, as a +/// command or as a kept Photoshop step. +pub fn import(bytes: &[u8]) -> std::result::Result { + let file = atn::parse(bytes).map_err(|e| e.to_string())?; + let set = file.name.to_string_lossy(); + let set = if set.trim().is_empty() { DEFAULT_SET.to_string() } else { set }; + let mut out = Imported { set: set.clone(), warnings: file.warnings.clone(), ..Default::default() }; + for a in &file.actions { + let action = convert_action(a, &set, &mut out.unsupported); + if a.function_key > 0 && !a.shift && !a.command { + out.function_keys.push((action.name.clone(), format!("F{}", a.function_key))); + } + out.actions.push(action); + } + Ok(out) +} + +// ------------------------------------------------------------------ export + +/// A step Save Actions could not express as a Photoshop event. +#[derive(Clone, Debug, PartialEq, serde::Serialize)] +pub struct Skipped { + pub action: String, + pub step: usize, + pub id: String, +} + +fn export_item(id: &str, params: &Value, set: &str) -> Option { + if id == PHOTOSHOP_STEP { + let event = jstr(params, "event")?; + let event = match event.as_bytes() { + [a, b, c, d] => AtnEvent::Code([*a, *b, *c, *d]), + _ => AtnEvent::Name(event.to_string()), + }; + let descriptor = match jstr(params, "descriptor") { + Some(b64) => Some(Descriptor::from_bytes(&photocraft_paint::tile::b64_decode(b64)?).ok()?), + None => None, + }; + return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); + } + let e = event_for_id(id)?; + let mut params = params.clone(); + if id == "actions.play" && params.get("set").is_none() { + params["set"] = json!(set); + } + let descriptor = (e.to)(id, ¶ms)?; + let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; + Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) +} + +/// The `.atn` bytes of `actions` as the set `set`. `function_keys` gives each action's key +/// (`F1`…`F15`). Steps with no Photoshop equivalent are an error unless `skip_unsupported`. +pub fn export( + set: &str, + actions: &[&Action], + function_keys: &[(String, String)], + skip_unsupported: bool, +) -> std::result::Result<(Vec, Vec), String> { + let mut skipped = Vec::new(); + let mut file = AtnFile { name: UnicodeString::new_nul(set), expanded: true, actions: Vec::new(), warnings: Vec::new() }; + for a in actions { + let mut out = AtnAction { name: UnicodeString::new_nul(&a.name), expanded: false, ..Default::default() }; + if let Some((_, key)) = function_keys.iter().find(|(n, _)| n == &a.name) + && let Some(n) = key.strip_prefix('F').and_then(|n| n.parse::().ok()) + && (1..=15).contains(&n) + { + out.function_key = n; + } + for (i, (id, params)) in a.steps.iter().enumerate() { + let Some(mut item) = export_item(id, params, set) else { + if skip_unsupported { + skipped.push(Skipped { action: a.name.clone(), step: i, id: id.clone() }); + continue; + } + return Err(format!("step {} of `{}` (`{id}`) has no Photoshop equivalent; pass \"skipUnsupported\": true to leave it out", i + 1, a.name)); + }; + item.enabled = a.step_enabled(i); + item.with_dialog = a.dialogs.contains(&i); + item.dialog_options = if item.with_dialog { 2 } else { 0 }; + out.items.push(item); + } + file.actions.push(out); + } + let bytes = atn::write(&file).map_err(|e| e.to_string())?; + Ok((bytes, skipped)) +} + +// ------------------------------------------------------------------ commands + +/// A name not used by any action in `taken`: `name`, `name 2`, `name 3`… +fn unique(name: &str, taken: &[String]) -> String { + if !taken.iter().any(|t| t == name) { + return name.to_string(); + } + (2..).map(|i| format!("{name} {i}")).find(|n| !taken.contains(n)).unwrap_or_else(|| name.to_string()) +} + +fn load(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.load"; + if s.actions.recording.is_some() || s.actions.playing > 0 { + return Err(bad(cmd, "stop recording or playing before loading actions")); + } + let (bytes, _) = crate::preset_import_cmds::file_bytes(p, cmd, ".atn file")?; + let mut imp = import(&bytes).map_err(|e| bad(cmd, e))?; + match p.get("set") { + None | Some(Value::Null) => {} + Some(Value::String(n)) if !n.trim().is_empty() => { + imp.set = n.trim().to_string(); + for a in &mut imp.actions { + a.set = Some(imp.set.clone()); + } + } + Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), + } + let replace = match p.get("replace") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`replace` must be true or false")), + }; + if replace { + s.actions.list.retain(|a| a.set.as_deref() != Some(imp.set.as_str())); + } + let mut taken: Vec = s.actions.list.iter().map(|a| a.name.clone()).collect(); + let mut names = Vec::with_capacity(imp.actions.len()); + let mut renamed = Vec::new(); + let mut steps = 0usize; + for mut a in imp.actions { + let name = unique(&a.name, &taken); + if name != a.name { + renamed.push((a.name.clone(), name.clone())); + } + a.name = name.clone(); + taken.push(name.clone()); + steps = steps.saturating_add(a.steps.len()); + names.push(name); + s.actions.list.push(a); + } + // A Play step calls its action by name: follow renames within the set. + for (from, to) in &renamed { + for a in s.actions.list.iter_mut().filter(|a| a.set.as_deref() == Some(imp.set.as_str())) { + for (id, params) in &mut a.steps { + if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(from) { + params["action"] = json!(to); + } + } + } + } + let keys: Vec<(String, String)> = + imp.function_keys.into_iter().map(|(n, k)| (renamed.iter().find(|(f, _)| f == &n).map_or(n, |(_, t)| t.clone()), k)).collect(); + if !keys.is_empty() { + s.edit_prefs(|prefs| { + for (name, key) in &keys { + // A function key belongs to one action. + prefs.shortcuts.retain(|k, v| !(k.starts_with(crate::actions_cmds::PLAY_SHORTCUT_PREFIX) && v == key)); + prefs.shortcuts.insert(format!("{}{name}", crate::actions_cmds::PLAY_SHORTCUT_PREFIX), key.clone()); + } + }); + } + s.actions.touch(); + Ok(json!({ + "set": imp.set, + "actions": names, + "steps": steps, + "unsupported": imp.unsupported, + "functionKeys": keys.iter().map(|(n, k)| json!({"action": n, "key": k})).collect::>(), + "warnings": imp.warnings, + })) +} + +fn save(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.save"; + let skip = match p.get("skipUnsupported") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`skipUnsupported` must be true or false")), + }; + let set_param = match p.get("set") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), + Some(_) => return Err(bad(cmd, "`set` must be a non-empty string")), + }; + let (set, actions): (String, Vec<&Action>) = if p.get("action").is_some() { + let idx = crate::actions_cmds::resolve(&s.actions.list, p, cmd)?; + let a = &s.actions.list[idx]; + (set_param.or_else(|| a.set.clone()).unwrap_or_else(|| a.name.clone()), vec![a]) + } else if let Some(set) = set_param { + let list: Vec<&Action> = s.actions.list.iter().filter(|a| a.set.as_deref() == Some(set.as_str())).collect(); + if list.is_empty() { + return Err(bad(cmd, format!("no actions in a set named `{set}`"))); + } + (set, list) + } else { + (DEFAULT_SET.to_string(), s.actions.list.iter().collect()) + }; + if actions.is_empty() { + return Err(bad(cmd, "there are no actions to save")); + } + let names: Vec = actions.iter().map(|a| a.name.clone()).collect(); + let keys: Vec<(String, String)> = + s.prefs().shortcuts.iter().filter_map(|(k, v)| k.strip_prefix(crate::actions_cmds::PLAY_SHORTCUT_PREFIX).map(|n| (n.to_string(), v.clone()))).collect(); + let (bytes, skipped) = export(&set, &actions, &keys, skip).map_err(|e| bad(cmd, e))?; + let steps: usize = actions.iter().map(|a| a.steps.len()).sum::().saturating_sub(skipped.len()); + let mut out = json!({"set": set, "actions": names, "steps": steps, "bytes": bytes.len(), "skipped": skipped}); + match p.get("path") { + None | Some(Value::Null) => { + out["data"] = json!(photocraft_paint::tile::b64_encode(&bytes)); + } + Some(Value::String(path)) if !path.trim().is_empty() => { + write_file(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + out["path"] = json!(path); + } + Some(_) => return Err(bad(cmd, "`path` must be a non-empty string")), + } + Ok(out) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_file(path: &str, bytes: &[u8]) -> std::io::Result<()> { + std::fs::write(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +fn write_file(path: &str, _bytes: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::other(format!("cannot write paths on the web ({path}); omit `path` to get the bytes back as `data`"))) +} + +fn stop_message(_: &mut Session, p: &Value) -> Result { + let message = jstr(p, "message").unwrap_or("Stop").to_string(); + if jbool(p, "allowContinue").unwrap_or(false) { + Ok(json!({"message": message, "continued": true})) + } else { + Err(EngineError::Other(format!("stopped by the action: {message}"))) + } +} + +fn photoshop_step_run(_: &mut Session, p: &Value) -> Result { + let name = jstr(p, "name").filter(|n| !n.is_empty()).unwrap_or("this step"); + let event = jstr(p, "event").unwrap_or("?"); + Err(EngineError::Other(format!("Photoshop step \"{name}\" ({event}) has no PhotoCraft equivalent yet; switch it off in the Actions panel to skip it"))) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "actions.load", + label: "Load Actions…", + menu: &[], + shortcut: None, + params: r##"{"path":".atn file"?,"data":base64 bytes?,"set":name? (override the file's set name),"replace":bool=true (replace the actions of a set of the same name)} → {set, actions:[names], steps, unsupported:[{action, step, event, name}], functionKeys:[{action, key}], warnings}. Photoshop steps PhotoCraft cannot play become actions.photoshopStep steps (kept for Save Actions; they fail at playback with the step's name); tool selections and other steps with no document effect are imported switched off. Layer references by name replay in any document."##, + enabled: always, + run: load, + journal: false, + }, + CommandSpec { + id: "actions.save", + label: "Save Actions…", + menu: &[], + shortcut: None, + params: r##"{"path":".atn file"? (else the bytes come back as base64 `data`),"set":name? (that set's actions; default: every action, as the set "PhotoCraft Actions"),"action":name|index? (one action),"skipUnsupported":bool=false (leave out steps with no Photoshop equivalent instead of failing)} → {set, actions:[names], steps, bytes, skipped:[{action, step, id}], path|data}"##, + enabled: always, + run: save, + journal: false, + }, + CommandSpec { + id: STOP_MESSAGE, + label: "Stop", + menu: &[], + shortcut: None, + params: r##"{"message":str,"allowContinue":bool=false} (Photoshop's Insert Stop: a step that shows the message; headless playback continues when allowContinue is set and otherwise stops the action with the message)"##, + enabled: always, + run: stop_message, + journal: true, + }, + CommandSpec { + id: PHOTOSHOP_STEP, + label: "Photoshop Step", + menu: &[], + shortcut: None, + params: r##"{"event":id,"name":str,"descriptor":base64?} (a step from a .atn file PhotoCraft has no command for: fails at playback, written back by actions.save)"##, + enabled: always, + run: photoshop_step_run, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs new file mode 100644 index 0000000000..ff7e040878 --- /dev/null +++ b/crates/engine/src/atn_cmds/tests.rs @@ -0,0 +1,488 @@ +use super::*; +use photocraft_psd::atn::{AtnAction, AtnEvent, AtnFile, AtnItem}; +use photocraft_psd::descriptor::{Descriptor, Id, ReferenceItem, UnicodeString, Value as D}; +use serde_json::json; + +fn item(event: &str, label: &str, d: Option) -> AtnItem { + let event = match event.as_bytes() { + [a, b, c, dd] => AtnEvent::Code([*a, *b, *c, *dd]), + _ => AtnEvent::Name(event.into()), + }; + AtnItem { event, dictionary: label.into(), descriptor: d, ..Default::default() } +} + +fn b64(bytes: &[u8]) -> String { + photocraft_paint::tile::b64_encode(bytes) +} + +/// A set with a mapped filter, an adjustment layer, selection steps, a layer by name, a Stop, +/// a Play, a tool selection (no document effect) and an event PhotoCraft has no command for. +fn sample() -> AtnFile { + let levels = desc("Lvls").with( + "Adjs", + D::List(vec![D::Descriptor( + desc("LvlA") + .with("Chnl", channel_ref(None)) + .with("Inpt", D::List(vec![long(10.0), long(240.0)])) + .with("Gmm ", D::Double(1.2)) + .with("Otpt", D::List(vec![long(0.0), long(250.0)])), + )]), + ); + let make_levels = desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(levels)))); + let select_all = + desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with("T ", enm("Ordn", "Al ")); + let select_layer = desc("null").with("null", name_ref("Lyr ", "Shadow")).with("MkVs", D::Boolean(false)); + let tool = desc("null").with("null", class_ref("MvTl")); + let play = desc("null").with("null", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("Finish") }])); + let stop = desc("null").with("Msge", text("Check the result")).with("Cntn", D::Boolean(true)); + let fancy = desc("null").with("prompt", text("a cat")).with("DocI", long(5.0)); + let mut blur = item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(2.5)))); + blur.with_dialog = true; + let mut off = item("Fltn", "Flatten Image", None); + off.enabled = false; + AtnFile { + name: UnicodeString::new_nul("Test Set"), + expanded: true, + actions: vec![ + AtnAction { + name: UnicodeString::new_nul("Soften"), + function_key: 3, + items: vec![ + blur, + item("make", "Make", Some(make_levels)), + item("setd", "Set", Some(select_all)), + item("select", "Select", Some(select_layer)), + item("slct", "Select", Some(tool)), + item("stop", "Stop", Some(stop)), + item("syntheticFill", "Generative Fill", Some(fancy)), + item("play", "Play", Some(play)), + off, + ], + ..Default::default() + }, + AtnAction { name: UnicodeString::new_nul("Finish"), items: vec![item("Invs", "Inverse", None)], ..Default::default() }, + ], + warnings: Vec::new(), + } +} + +#[test] +fn load_maps_events_keeps_flags_and_plays() { + let mut s = Session::new(); + let bytes = atn::write(&sample()).unwrap(); + let r = s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(r["set"], "Test Set"); + assert_eq!(r["actions"], json!(["Soften", "Finish"])); + assert_eq!(r["steps"], 10); + assert_eq!(r["functionKeys"], json!([{"action": "Soften", "key": "F3"}])); + assert_eq!(s.prefs().shortcuts.get("actions.play:Soften").map(String::as_str), Some("F3")); + let unsupported: Vec<(String, String)> = + r["unsupported"].as_array().unwrap().iter().map(|u| (u["event"].as_str().unwrap().into(), u["name"].as_str().unwrap().into())).collect(); + assert_eq!(unsupported, [("slct".to_string(), "Select".to_string()), ("syntheticFill".to_string(), "Generative Fill".to_string())]); + + let a = &s.actions.list[0]; + assert_eq!(a.set.as_deref(), Some("Test Set")); + let ids: Vec<&str> = a.steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + ids, + [ + "filter.blur.gaussianBlur", + "layer.newAdjustmentLayer.levels", + "select.all", + "layer.select", + PHOTOSHOP_STEP, + STOP_MESSAGE, + PHOTOSHOP_STEP, + "actions.play", + "layer.flattenImage" + ] + ); + assert_eq!(a.steps[0].1, json!({"radius": 2.5})); + assert_eq!(a.steps[1].1, json!({"inBlack": 10, "inWhite": 240, "gamma": 1.2, "outBlack": 0, "outWhite": 250})); + assert_eq!(a.steps[3].1, json!({"layer": "Shadow"})); + assert_eq!(a.steps[5].1, json!({"message": "Check the result", "allowContinue": true})); + assert_eq!(a.steps[6].1["event"], "syntheticFill"); + assert_eq!(a.steps[7].1, json!({"action": "Finish"})); + assert_eq!(a.dialogs.iter().copied().collect::>(), [0], "the dialog toggle is kept"); + assert_eq!(a.disabled.iter().copied().collect::>(), [4, 8], "tool selections are switched off, as is the step the file had off"); + assert_eq!(s.actions.list[1].steps, [("select.inverse".to_string(), json!({}))]); + + // Playback: the mapped steps run, the layer is found by name, and the kept Photoshop step + // stops the action with its name. + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Shadow"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + let played = s.execute("actions.play", json!({"action": "Soften"})).unwrap(); + assert_eq!(played["ran"], 5, "{played}"); + assert_eq!(played["failed"]["step"], 6); + assert!(played["failed"]["error"].as_str().unwrap().contains("Generative Fill"), "{played}"); + let d = s.active().unwrap(); + assert_eq!(d.doc.layer(d.active_layer.unwrap()).unwrap().name, "Shadow"); + assert!(d.doc.walk().iter().any(|(_, _, l)| l.name.starts_with("Levels"))); + + // Loading the same set again replaces it; a different set with the same action names + // gets unique names and its Play steps follow. + s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + assert_eq!(s.actions.list.len(), 2); + let r = s.execute("actions.load", json!({"data": b64(&bytes), "set": "Other"})).unwrap(); + assert_eq!(r["actions"], json!(["Soften 2", "Finish 2"])); + assert_eq!(s.actions.list[2].steps[7].1, json!({"action": "Finish 2"})); + assert_eq!(s.actions.list[2].set.as_deref(), Some("Other")); +} + +#[test] +fn save_round_trips_through_photoshop_events() { + let mut s = Session::new(); + let bytes = atn::write(&sample()).unwrap(); + s.execute("actions.load", json!({"data": b64(&bytes)})).unwrap(); + let first = s.actions.list.clone(); + let r = s.execute("actions.save", json!({"set": "Test Set"})).unwrap(); + assert_eq!(r["actions"], json!(["Soften", "Finish"])); + assert_eq!(r["skipped"], json!([])); + let saved = photocraft_paint::tile::b64_decode(r["data"].as_str().unwrap()).unwrap(); + let file = atn::parse(&saved).unwrap(); + assert_eq!(file.name.to_string_lossy(), "Test Set"); + assert_eq!(file.actions[0].function_key, 3); + let events: Vec = file.actions[0].items.iter().map(|i| i.event.id()).collect(); + assert_eq!(events, ["GsnB", "Mk ", "setd", "slct", "slct", "Stop", "syntheticFill", "Ply ", "Fltn"]); + assert!(file.actions[0].items[0].with_dialog); + assert!(!file.actions[0].items[8].enabled); + // The kept Photoshop step is written back byte for byte. + assert_eq!(file.actions[0].items[6].descriptor, sample().actions[0].items[6].descriptor); + // Play names the set too, as Photoshop does. + assert_eq!(get_ref(file.actions[0].items[7].descriptor.as_ref().unwrap(), "null").unwrap().len(), 2); + + // Loading what was saved gives the same actions. + let mut t = Session::new(); + t.execute("actions.load", json!({"data": b64(&saved)})).unwrap(); + assert_eq!(t.actions.list, first); + + // A step with no Photoshop equivalent fails unless skipped. + s.actions.list.push(Action::new("Ours", vec![("filter.render.clouds".into(), json!({}))])); + let e = s.execute("actions.save", json!({"action": "Ours"})).unwrap_err().to_string(); + assert!(e.contains("filter.render.clouds"), "{e}"); + let r = s.execute("actions.save", json!({"action": "Ours", "skipUnsupported": true})).unwrap(); + assert_eq!(r["skipped"], json!([{"action": "Ours", "step": 0, "id": "filter.render.clouds"}])); + assert_eq!(r["set"], "Ours"); +} + +#[test] +fn every_mapped_command_exports_and_imports_back() { + let cases: Vec<(&str, Value)> = vec![ + ("filter.blur.gaussianBlur", json!({"radius": 3.5})), + ("filter.sharpen.unsharpMask", json!({"amount": 120, "radius": 1.5, "threshold": 3})), + ("filter.noise.addNoise", json!({"amount": 12.5, "distribution": "gaussian", "monochromatic": true, "seed": 7})), + ("filter.noise.median", json!({"radius": 2})), + ("filter.blur.motionBlur", json!({"angle": 45, "distance": 20})), + ("filter.other.offset", json!({"horizontal": 10, "vertical": -5, "undefinedAreas": "wrap"})), + ("filter.blur.blur", json!({})), + ( + "image.adjustments.levels", + json!({"inBlack": 5, "inWhite": 250, "gamma": 0.9, "outBlack": 0, "outWhite": 255, "red": {"inBlack": 2, "inWhite": 255, "gamma": 1, "outBlack": 0, "outWhite": 255}}), + ), + ("image.adjustments.curves", json!({"points": [[0, 0], [128, 140], [255, 255]], "blue": [[0, 10], [255, 245]]})), + ( + "image.adjustments.hueSaturation", + json!({"colorize": false, "hue": 10, "saturation": -20, "lightness": 0, "reds": {"hue": 5, "saturation": 0, "lightness": 0, "range": [315, 345, 15, 45]}}), + ), + ("image.adjustments.brightnessContrast", json!({"brightness": 10, "contrast": -5, "legacy": false})), + ("image.adjustments.threshold", json!({"level": 100})), + ("image.adjustments.posterize", json!({"levels": 4})), + ("image.adjustments.invert", json!({})), + ("image.adjustments.colorBalance", json!({"shadows": [1, 2, 3], "midtones": [0, 0, 0], "highlights": [-5, 0, 5], "preserveLuminosity": true})), + ("image.adjustments.vibrance", json!({"vibrance": 30, "saturation": 5})), + ("image.adjustments.exposure", json!({"exposure": 0.5, "offset": 0.01, "gamma": 1.1})), + ("image.adjustments.photoFilter", json!({"color": "#ec8a00", "density": 25, "preserveLuminosity": true})), + ( + "image.adjustments.blackWhite", + json!({"reds": 40, "yellows": 60, "greens": 40, "cyans": 60, "blues": 20, "magentas": 80, "tint": true, "tintColor": "#e4d5b0"}), + ), + ("image.adjustments.channelMixer", json!({"red": [100, 0, 0, 0], "green": [0, 90, 10, 0], "blue": [0, 0, 100, 5], "monochrome": false})), + ("image.adjustments.selectiveColor", json!({"method": "absolute", "reds": [10, 0, -5, 0], "neutrals": [0, 0, 0, 3]})), + ("image.adjustments.desaturate", json!({})), + ("image.autoTone", json!({})), + ("image.imageSize", json!({"width": 800, "height": 600, "resolution": 72, "resample": "bicubicSharper"})), + ("image.canvasSize", json!({"width": 100, "height": 50, "relative": true, "anchor": "topLeft", "extensionColor": "white"})), + ("image.imageRotation.90cw", json!({})), + ("image.rotation.arbitrary", json!({"angle": 12.5, "direction": "ccw"})), + ("edit.transform.flipHorizontal", json!({})), + ("image.imageRotation.flipCanvasVertical", json!({})), + ("image.crop", json!({"x": 10, "y": 20, "width": 100, "height": 50, "deleteCroppedPixels": true})), + ("image.trim", json!({"basedOn": "transparent", "top": true, "bottom": true, "left": false, "right": true})), + ("image.mode.grayscale", json!({})), + ("image.mode.bits16", json!({})), + ("layer.duplicate", json!({"name": "copy"})), + ("image.duplicate", json!({"mergedOnly": true})), + ("layer.new.layer", json!({"name": "Ink", "opacity": 0.5, "mode": "Multiply"})), + ("layer.new.group", json!({"name": "G"})), + ("layer.groupLayers", json!({})), + ("layer.new.layerViaCopy", json!({})), + ("layer.newFillLayer.solidColor", json!({"color": "#336699"})), + ("layer.newAdjustmentLayer.hueSaturation", json!({})), + ("layer.newAdjustmentLayer.threshold", json!({"level": 77})), + ("file.new", json!({"width": 640, "height": 480, "resolution": 72, "mode": "rgb", "depth": 8, "background": "transparent", "name": "Doc"})), + ("select.all", json!({})), + ("select.deselect", json!({})), + ("select.loadSelection", json!({"channel": "transparency"})), + ("select.loadSelection", json!({"channel": "Alpha 1"})), + ("select.rect", json!({"x": 1, "y": 2, "width": 30, "height": 40})), + ("select.rect", json!({"x": 1, "y": 2, "width": 30, "height": 40, "ellipse": true})), + ("layer.setProps", json!({"name": "Renamed", "opacity": 0.4, "blend": "Linear Light"})), + ("layer.setProps", json!({"layer": "Shadow", "visible": false})), + ("layer.setLabelColor", json!({"color": "red"})), + ("layer.select", json!({"layer": "Shadow", "mode": "add"})), + ("layer.selectTop", json!({})), + ("layer.selectBelow", json!({})), + ("layer.addBelowToSelection", json!({})), + ("layer.addAboveToSelection", json!({})), + ("layer.delete", json!({})), + ("edit.clear", json!({})), + ("layer.hideLayers", json!({"layer": "Background"})), + ("layer.showLayers", json!({})), + ("layer.translate", json!({"dx": 3, "dy": -4})), + ("layer.arrange.sendToBack", json!({})), + ("layer.flattenImage", json!({})), + ("layer.stampVisible", json!({})), + ("layer.mergeVisible", json!({})), + ("layer.matting.defringe", json!({"width": 2})), + ("layer.align.leftEdges", json!({"to": "canvas"})), + ("select.inverse", json!({})), + ("select.modify.feather", json!({"radius": 4})), + ("select.modify.contract", json!({"radius": 1})), + ("edit.fill", json!({"contents": "color", "color": "#ff0080", "opacity": 50, "mode": "Multiply", "preserveTransparency": true})), + ("edit.fill", json!({"contents": "foreground", "opacity": 100, "mode": "Normal"})), + ("edit.stroke", json!({"width": 3, "location": "outside", "opacity": 100, "mode": "Normal", "color": "#000000", "preserveTransparency": false})), + ("edit.copyMerged", json!({})), + ("edit.pasteSpecial.pasteInPlace", json!({})), + ("edit.paste", json!({})), + ("edit.purge.histories", json!({})), + ("tools.defaultColors", json!({})), + ("edit.convertToProfile", json!({"profile": "display-p3", "intent": "perceptual", "bpc": false})), + ("edit.assignProfile", json!({"profile": "srgb"})), + ("file.close", json!({})), + ("file.save", json!({})), + ("actions.play", json!({"action": "Other", "set": "S"})), + (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), + ]; + for (id, params) in &cases { + let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id())); + match (e.from)(item.descriptor.as_ref().unwrap()) { + Mapped::Steps(steps) => { + assert_eq!(steps.len(), 1, "{id}: {steps:?}"); + assert_eq!(steps[0].0, *id, "{id} came back as another command"); + let mut back = steps[0].1.clone(); + normalise(&mut back); + if *id == "actions.play" { + back["set"] = json!("S"); + } + assert_eq!(back, *params, "{id}"); + } + Mapped::Unsupported { .. } => panic!("{id} does not import back"), + } + } + // Every command the table claims to export has a case above. + let covered: std::collections::BTreeSet<&str> = cases.iter().map(|(id, _)| *id).collect(); + let mut missing: Vec<&str> = EVENTS.iter().flat_map(|e| e.ids.iter().copied()).filter(|id| !covered.contains(id)).collect(); + missing.sort_unstable(); + let allowed = [ + "edit.transform.flipVertical", + "edit.transform.rotate180", + "edit.transform.rotate90Ccw", + "edit.transform.rotate90Cw", + "image.autoColor", + "image.autoContrast", + "image.imageRotation.180", + "image.imageRotation.90ccw", + "image.imageRotation.flipCanvasHorizontal", + "image.mode.bits32", + "image.mode.bits8", + "image.mode.cmyk", + "image.mode.lab", + "image.mode.multichannel", + "image.mode.rgb", + "layer.align.bottomEdges", + "layer.align.horizontalCenters", + "layer.align.rightEdges", + "layer.align.topEdges", + "layer.align.verticalCenters", + "layer.arrange.bringForward", + "layer.arrange.bringToFront", + "layer.arrange.sendBackward", + "layer.mergeLayers", + "layer.new.layerViaCut", + "layer.newAdjustmentLayer.blackWhite", + "layer.newAdjustmentLayer.brightnessContrast", + "layer.newAdjustmentLayer.channelMixer", + "layer.newAdjustmentLayer.colorBalance", + "layer.newAdjustmentLayer.curves", + "layer.newAdjustmentLayer.exposure", + "layer.newAdjustmentLayer.invert", + "layer.newAdjustmentLayer.levels", + "layer.newAdjustmentLayer.photoFilter", + "layer.newAdjustmentLayer.posterize", + "layer.newAdjustmentLayer.selectiveColor", + "layer.newAdjustmentLayer.vibrance", + "layer.rasterize.layer", + "layer.renameLayer", + "layer.selectAbove", + "layer.selectBottom", + "layer.setAdjustment", + "select.allLayers", + "select.deselectLayers", + "select.grow", + "select.modify.border", + "select.modify.expand", + "select.modify.smooth", + "select.reselect", + "select.similar", + "tools.swapColors", + "edit.cut", + "edit.copy", + "edit.purge.all", + "edit.purge.clipboard", + "edit.purge.undo", + "file.newFromClipboard", + "file.revert", + "image.adjustments.equalize", + "image.revealAll", + "filter.blur.blurMore", + "filter.blur.boxBlur", + "filter.blur.radialBlur", + "filter.blur.smartBlur", + "filter.blur.surfaceBlur", + "filter.noise.despeckle", + "filter.noise.dustAndScratches", + "filter.other.highPass", + "filter.other.maximum", + "filter.other.minimum", + "filter.sharpen.sharpen", + "filter.sharpen.sharpenEdges", + "filter.sharpen.sharpenMore", + ]; + let unexpected: Vec<&str> = missing.iter().copied().filter(|id| !allowed.contains(id)).collect(); + assert!(unexpected.is_empty(), "add round-trip cases for {unexpected:?}"); + // Every command the table names exists. + // (Save is the desktop shell's: recorded by it, replayed by it, written by Batch.) + for id in EVENTS.iter().flat_map(|e| e.ids.iter()) { + assert!(crate::commands::find(id).is_some() || crate::actions_cmds::shell_save_command(id), "the .atn table names an unknown command `{id}`"); + } +} + +#[test] +fn unsupported_shapes_become_photoshop_steps_not_errors() { + // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + let items = vec![ + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), + item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item( + "move", + "Move", + Some(desc("null").with("null", target_ref("Lyr ")).with( + "T ", + D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })), + )), + ), + item( + "make", + "Make", + Some(desc("null").with("null", class_ref("AdjL")).with("Usng", D::Descriptor(desc("AdjL").with("Type", D::Descriptor(desc("GdMp")))))), + ), + item("GsnB", "Gaussian Blur", None), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Odd"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let ids: Vec<&str> = imp.actions[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, [PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, PHOTOSHOP_STEP, "filter.blur.gaussianBlur"]); + assert_eq!(imp.unsupported.len(), 4); + assert!(imp.actions[0].disabled.is_empty(), "steps with a document effect stay on so playback reports them"); +} + +#[test] +fn step_commands_and_bad_params_fail_gracefully() { + let mut s = Session::new(); + for p in [ + json!({}), + json!({"path": ""}), + json!({"path": "/nonexistent/set.atn"}), + json!({"data": 42}), + json!({"data": "%%%"}), + json!({"data": b64(b"")}), + json!({"data": b64(&[0, 0, 0, 12, 0, 0, 0, 0])}), + json!({"data": b64(&atn::write(&sample()).unwrap()[..40])}), + json!({"data": b64(&atn::write(&sample()).unwrap()), "set": ""}), + json!({"data": b64(&atn::write(&sample()).unwrap()), "set": 3}), + json!({"data": b64(&atn::write(&sample()).unwrap()), "replace": "yes"}), + ] { + assert!(s.execute("actions.load", p.clone()).is_err(), "{p}"); + } + assert!(s.actions.list.is_empty()); + for p in [json!({}), json!({"set": "Nope"}), json!({"action": 3}), json!({"path": 7}), json!({"skipUnsupported": "no"}), json!({"set": ""})] { + assert!(s.execute("actions.save", p.clone()).is_err(), "{p}"); + } + s.actions.list.push(Action::new("One", vec![("select.all".into(), json!({}))])); + assert!(s.execute("actions.save", json!({"path": ""})).is_err()); + assert!(s.execute("actions.save", json!({})).unwrap()["data"].is_string()); + + assert!(s.execute(STOP_MESSAGE, json!({"message": "halt"})).unwrap_err().to_string().contains("halt")); + assert_eq!(s.execute(STOP_MESSAGE, json!({"message": "go on", "allowContinue": true})).unwrap()["continued"], true); + assert!(s.execute(STOP_MESSAGE, json!({"message": 5, "allowContinue": "x"})).is_err()); + for p in [json!({}), json!({"event": 1}), json!({"event": "GsnB", "name": "Gaussian Blur", "descriptor": "not base64"})] { + assert!(s.execute(PHOTOSHOP_STEP, p.clone()).is_err(), "{p}"); + } + // A Photoshop step with a damaged descriptor is left out of a save rather than crashing it. + s.actions.list.push(Action::new("Broken", vec![(PHOTOSHOP_STEP.into(), json!({"event": "GsnB", "name": "Blur", "descriptor": b64(b"\x00\x00\x00")}))])); + assert!(s.execute("actions.save", json!({"action": "Broken"})).is_err()); + assert_eq!(s.execute("actions.save", json!({"action": "Broken", "skipUnsupported": true})).unwrap()["skipped"].as_array().unwrap().len(), 1); +} + +#[test] +fn save_writes_the_file_and_the_set_of_the_selected_action() { + let mut s = Session::new(); + s.execute("actions.load", json!({"data": b64(&atn::write(&sample()).unwrap())})).unwrap(); + s.actions.list.push(Action::new("Loose", vec![("select.all".into(), json!({}))])); + let dir = std::env::temp_dir().join(format!("pc-atn-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Saved.atn"); + let r = s.execute("actions.save", json!({"action": "Finish", "path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some("Test Set"), Some(1))); + let file = atn::parse(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(file.actions.len(), 1); + assert_eq!(file.actions[0].name.to_string_lossy(), "Finish"); + // Without a set, everything goes out under the default name. + let r = s.execute("actions.save", json!({})).unwrap(); + assert_eq!((r["set"].as_str(), r["actions"].as_array().map(Vec::len)), (Some(DEFAULT_SET), Some(3))); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn layer_param_accepts_a_name() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "A"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "B"})).unwrap(); + s.execute("layer.select", json!({"layer": "A"})).unwrap(); + let d = s.active().unwrap(); + assert_eq!(d.doc.layer(d.active_layer.unwrap()).unwrap().name, "A"); + assert!(s.execute("layer.select", json!({"layer": "Nope"})).is_err()); + assert!(s.execute("layer.select", json!({"layer": true})).is_err()); + assert!(s.execute("layer.select", json!({"layer": -1})).is_err()); +} + +#[test] +fn colours_convert_both_ways() { + assert_eq!(color_hex(&desc("RGBC").with("Rd ", D::Double(255.0)).with("Grn ", D::Double(127.6)).with("Bl ", D::Double(0.0))).as_deref(), Some("#ff8000")); + assert_eq!(color_hex(&desc("HSBC").with("H ", ang(0.0)).with("Strt", D::Double(0.0)).with("Brgh", D::Double(100.0))).as_deref(), Some("#ffffff")); + assert_eq!(color_hex(&desc("HSBC").with("H ", ang(120.0)).with("Strt", D::Double(100.0)).with("Brgh", D::Double(100.0))).as_deref(), Some("#00ff00")); + assert_eq!(color_hex(&desc("Grsc").with("Gry ", D::Double(100.0))).as_deref(), Some("#000000")); + assert_eq!(color_hex(&desc("CMYC")), None); + assert_eq!(color_desc("#12ab9F").map(|d| color_hex(&d)), Some(Some("#12ab9f".into()))); + assert_eq!(color_desc("12ab9f"), None); + assert_eq!(color_desc("#12ab9"), None); + assert_eq!(color_desc("#ééééé"), None); +} diff --git a/crates/engine/src/automate_cmds/tests.rs b/crates/engine/src/automate_cmds/tests.rs index d4e0a6379b..7471f1cfd2 100644 --- a/crates/engine/src/automate_cmds/tests.rs +++ b/crates/engine/src/automate_cmds/tests.rs @@ -340,8 +340,18 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { images(&dir, 1, 8, 4); let mut s = Session::new(); s.execute("file.new", json!({"width": 8, "height": 6})).unwrap(); - s.actions.list.push(Action { name: "Rotate".into(), steps: vec![("image.imageRotation.90cw".into(), json!({}))], disabled: Default::default() }); - s.actions.list.push(Action { name: "Bad".into(), steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], disabled: Default::default() }); + s.actions.list.push(Action { + name: "Rotate".into(), + steps: vec![("image.imageRotation.90cw".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + s.actions.list.push(Action { + name: "Bad".into(), + steps: vec![("layer.delete".into(), json!({"layer": 999_999}))], + disabled: Default::default(), + ..Default::default() + }); // A script stops at the failed call and reports it. let r = s.execute("file.scripts.browse", json!({"steps": [["actions.play", {"action": "Bad"}], ["image.imageRotation.90cw", {}]]})).unwrap(); assert_eq!(r["ok"], false, "{r}"); @@ -358,6 +368,6 @@ fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() { assert!(r["errors"][0]["error"].as_str().unwrap().contains("no such layer"), "{r}"); // An action that batches itself stops as a recursive call instead of overflowing the stack. let inner = json!({"steps": [["actions.play", {"action": "Loop"}]], "input": dir.clone(), "output": format!("{dir}/inner"), "format": "png"}); - s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default() }); + s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)], disabled: Default::default(), ..Default::default() }); batch(&mut s, "Loop"); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5524388c71..6771b36f47 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -254,9 +254,33 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result { Ok(json!({"layers": ids.iter().map(|id| id.0).collect::>()})) } +/// The layer a command's `"layer"` param names: an id, or a name (the topmost layer of that +/// name, as Photoshop's "select layer by name" action step finds it, so a loaded action replays +/// in another document). `None` when the param is absent. +pub(crate) fn explicit_layer(s: &Session, p: &Value) -> Result> { + match p.get("layer") { + Some(Value::Number(n)) => { + n.as_u64().map(|id| Some(LayerId(id))).ok_or_else(|| EngineError::Other(format!("layer id {n} is not a non-negative integer"))) + } + Some(Value::String(name)) => { + let d = s.active().ok_or(EngineError::NoDocument)?; + d.doc + .walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == *name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| EngineError::Other(format!("no layer named \"{name}\""))) + } + Some(Value::Null) | None => Ok(None), + Some(other) => Err(EngineError::Other(format!("`layer` must be an id or a name, not {other}"))), + } +} + +/// The layer a command targets: [`explicit_layer`], else the active layer. pub(crate) fn layer_param(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(LayerId(id)), + match explicit_layer(s, p)? { + Some(id) => Ok(id), None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), } } @@ -1200,6 +1224,7 @@ fn build() -> Vec { v.extend(crate::brush_preset_cmds::specs()); v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); + v.extend(crate::atn_cmds::specs()); v.extend(crate::swatch_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::redeye_cmds::specs()); diff --git a/crates/engine/src/cutout_cmds.rs b/crates/engine/src/cutout_cmds.rs index 762830ee83..baee260bee 100644 --- a/crates/engine/src/cutout_cmds.rs +++ b/crates/engine/src/cutout_cmds.rs @@ -233,8 +233,11 @@ mod tests { let (mut s, _) = disc(8); let before = s.active().unwrap().revision; assert!(s.execute(CMD, json!({"layer": 999_999})).is_err()); - // Wrong types fall back to the defaults (the active layer, layer-only sampling, refined). - assert!(s.execute(CMD, json!({"layer": "x", "sampleAllLayers": 3, "refine": "no"})).is_ok()); + // A `layer` string names a layer; one that isn't there is an error, not the active layer. + assert!(s.execute(CMD, json!({"layer": "x"})).is_err()); + assert_eq!(s.active().unwrap().revision, before); + // Wrong types for the options fall back to the defaults (layer-only sampling, refined). + assert!(s.execute(CMD, json!({"sampleAllLayers": 3, "refine": "no"})).is_ok()); assert!(s.active().unwrap().revision > before); } diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index db96c3c1b4..0d5985e339 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -115,7 +115,7 @@ fn range_order(layers: &[Layer], include_collapsed: bool, out: &mut Vec /// `layer.select`: `{"layer": id, "mode": "replace|toggle|range|add"}`. pub fn select(s: &mut Session, p: &Value) -> Result { - let id = LayerId(p.get("layer").and_then(Value::as_u64).ok_or_else(|| bad("layer.select", "missing `layer`"))?); + let id = crate::commands::explicit_layer(s, p)?.ok_or_else(|| bad("layer.select", "missing `layer`"))?; let mode = p.get("mode").and_then(Value::as_str).unwrap_or("replace"); let st = s.active().ok_or(EngineError::NoDocument)?; if st.doc.layer(id).is_none() { @@ -293,8 +293,8 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { if dx == 0 && dy == 0 { return Ok(Value::Null); } - let roots = match p.get("layer").and_then(Value::as_u64) { - Some(id) => vec![LayerId(id)], + let roots = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => selected(s), }; if roots.is_empty() { @@ -1188,8 +1188,8 @@ pub(crate) fn check_group_depth(doc: &Document, what: &str) -> Result<()> { /// Group Layers (⌘G) / Group from Layers: the explicit layer, or every selected layer, moves /// into a new group placed where the top-most of them was. Bottom-to-top order is preserved. pub fn group_layers(s: &mut Session, p: &Value) -> Result { - let ids = match p.get("layer").and_then(Value::as_u64) { - Some(id) => vec![LayerId(id)], + let ids = match crate::commands::explicit_layer(s, p)? { + Some(id) => vec![id], None => selected(s), }; if ids.is_empty() { diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 878bcaca0a..b7fc8b7b01 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -837,10 +837,7 @@ const MAX_EFFECTS: usize = 256; /// mismatches and malformed snapshots are errors, never panics. fn replace_effects(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.layerStyle.replace"; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(id) => photocraft_doc::LayerId(id), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; let entries = p.get("effects").and_then(Value::as_array).ok_or_else(|| bad(CMD, "need `effects`: [{kind, params, fx?}]"))?; if entries.len() > MAX_EFFECTS { return Err(bad(CMD, format!("at most {MAX_EFFECTS} effects"))); @@ -891,10 +888,7 @@ fn set_effect(s: &mut Session, p: &Value, kind: &str) -> Result { let (fx, pattern) = crate::pattern_cmds::resolve_effect(s, fx)?; let label = format!("Layer Style: {}", fx.label()); let add = b(p, "add", false); - let id = match p.get("layer").and_then(Value::as_u64) { - Some(id) => photocraft_doc::LayerId(id), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, - }; + let id = crate::commands::layer_param(s, p)?; s.edit(&label, |doc, _| { if let Some(pat) = &pattern { crate::pattern_cmds::ensure_in_doc(doc, pat); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9cb725518..1115ab7e33 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod align_cmds; mod allocation; pub mod analysis_cmds; pub mod artboard_cmds; +pub mod atn_cmds; pub mod automate_cmds; pub mod background_cmds; pub mod brush_cmds; diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 54358fe66c..684d308de0 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -426,10 +426,7 @@ fn with_panel_target(s: &Session, p: &Value) -> Value { fn transform(s: &mut Session, p: &Value) -> Result { let p = &with_panel_target(s, p); let st = s.active().ok_or(EngineError::NoDocument)?; - let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => Some(LayerId(v)), - None => st.active_layer, - }; + let id = crate::commands::explicit_layer(s, p)?.or(st.active_layer); let lone = lone_target(&st.doc, id, p)?; let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { (_, true) => None, diff --git a/crates/psd/README.md b/crates/psd/README.md index 7de5529952..02b76e5a7f 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -109,6 +109,7 @@ testgen::{all_cases, merged_only, layered, small, sample_descriptor, pattern_pla | RGBA8 extraction | ✓ | – | – | RGB / gray / CMYK layers; merged image also supports indexed / bitmap / duotone | | Builder | – | ✓ | – | RGB / gray / CMYK at 8/16 bit, masks, groups, blend modes, opacity, fill, visibility, clipping | | Patterns | ✓ | ✓ | ✓ | `Patt` / `Pat2` / `Pat3`, `.pat`, and `.abr` embedded patterns; non-empty tiles allow in-bounds channel subrectangles, decoded size is capped and PackBits expansion is input-bounded | +| Actions (`.atn`) | ✓ | ✓ | ✓ | `atn::parse` / `atn::write`: version-16 sets (Photoshop 6+): actions with function key, modifiers and colour; steps with event code or string name, panel name, enabled/dialog flags and their descriptor. Exact round trip; version-12 (Photoshop 5) files are rejected as unsupported | Pattern channel subrectangles are padded with zeroes to the tile rectangle. All channel metadata and the total padded size are checked against `MAX_DECODED_BYTES` before any diff --git a/crates/psd/examples/atn_dump.rs b/crates/psd/examples/atn_dump.rs new file mode 100644 index 0000000000..8a39bbb121 --- /dev/null +++ b/crates/psd/examples/atn_dump.rs @@ -0,0 +1,84 @@ +//! Print the actions and steps of `.atn` files, one event per line, with the top-level keys of +//! each step's descriptor: `cargo run -p photocraft-psd --example atn_dump -- set.atn…` +//! +//! Handy when adding a Photoshop event to the engine's `.atn` import table: it shows the event +//! id, the panel name and the parameter keys Photoshop recorded. + +use photocraft_psd::atn; +use photocraft_psd::descriptor::{Descriptor, ReferenceItem, Value}; + +fn summary(v: &Value) -> String { + match v { + Value::Integer(i) => i.to_string(), + Value::Double(d) => format!("{d}"), + Value::UnitFloat { unit, value } => format!("{value}{}", String::from_utf8_lossy(unit)), + Value::Boolean(b) => b.to_string(), + Value::Text(t) => format!("{:?}", t.to_string_lossy()), + Value::Enumerated { type_id, value } => format!("{}.{}", String::from_utf8_lossy(type_id.as_bytes()), String::from_utf8_lossy(value.as_bytes())), + Value::Descriptor(d) | Value::GlobalObject(d) => format!("{{{}}}", keys(d)), + Value::List(l) => format!("[{}]", l.iter().map(summary).collect::>().join(", ")), + Value::Reference(r) => format!("ref[{}]", r.iter().map(reference).collect::>().join(" ")), + other => format!("{other:?}").chars().take(40).collect(), + } +} + +fn id(i: &photocraft_psd::descriptor::Id) -> String { + String::from_utf8_lossy(i.as_bytes()).trim().to_string() +} + +fn reference(item: &ReferenceItem) -> String { + match item { + ReferenceItem::Property { class, key } => format!("prop({},{})", id(&class.class_id), id(key)), + ReferenceItem::Class(c) => format!("Clss({})", id(&c.class_id)), + ReferenceItem::Enumerated { class, type_id, value } => format!("Enmr({},{}.{})", id(&class.class_id), id(type_id), id(value)), + ReferenceItem::Offset { class, offset } => format!("rele({},{offset})", id(&class.class_id)), + ReferenceItem::Identifier { class, id: v } => format!("Idnt({},{v})", id(&class.class_id)), + ReferenceItem::Index { class, index } => format!("indx({},{index})", id(&class.class_id)), + ReferenceItem::Name { class, name } => format!("name({},{:?})", id(&class.class_id), name.to_string_lossy()), + } +} + +fn keys(d: &Descriptor) -> String { + d.items.iter().map(|(k, v)| format!("{}={}", String::from_utf8_lossy(k.as_bytes()), summary(v))).collect::>().join(" ") +} + +fn main() { + let mut failed = false; + for path in std::env::args().skip(1) { + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{path}: {e}"); + failed = true; + continue; + } + }; + match atn::parse(&bytes) { + Ok(f) => { + println!( + "== {path}: set {:?}, {} actions{}", + f.name.to_string_lossy(), + f.actions.len(), + if f.warnings.is_empty() { String::new() } else { format!(" ({})", f.warnings.join("; ")) } + ); + for a in &f.actions { + println!(" action {:?} ({} steps)", a.name.to_string_lossy(), a.items.len()); + for item in &a.items { + let flags = format!("{}{}", if item.enabled { "" } else { " off" }, if item.with_dialog { " dialog" } else { "" }); + match &item.descriptor { + Some(d) => println!(" {:<22} {:<28}{flags} {}", item.event.id(), item.dictionary, keys(d)), + None => println!(" {:<22} {:<28}{flags}", item.event.id(), item.dictionary), + } + } + } + } + Err(e) => { + println!("== {path}: {e}"); + failed = true; + } + } + } + if failed { + std::process::exit(1); + } +} diff --git a/crates/psd/fuzz/Cargo.toml b/crates/psd/fuzz/Cargo.toml index 86f0f51c0c..1c874bf4f0 100644 --- a/crates/psd/fuzz/Cargo.toml +++ b/crates/psd/fuzz/Cargo.toml @@ -98,3 +98,10 @@ path = "fuzz_targets/resources.rs" test = false doc = false bench = false + +[[bin]] +name = "atn" +path = "fuzz_targets/atn.rs" +test = false +doc = false +bench = false diff --git a/crates/psd/fuzz/fuzz_targets/atn.rs b/crates/psd/fuzz/fuzz_targets/atn.rs new file mode 100644 index 0000000000..3bb1777c8a --- /dev/null +++ b/crates/psd/fuzz/fuzz_targets/atn.rs @@ -0,0 +1,14 @@ +//! Fuzz the `.atn` actions parser: any input must return `Ok` or `Err`, never panic, and +//! anything that parses must survive a write and parse back equal. +#![no_main] + +use libfuzzer_sys::fuzz_target; +use photocraft_psd::atn; + +fuzz_target!(|data: &[u8]| { + if let Ok(f) = atn::parse(data) { + let bytes = atn::write(&f).expect("a parsed set is writable"); + let again = atn::parse(&bytes).expect("a written set parses"); + assert_eq!(again.actions, f.actions); + } +}); diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs new file mode 100644 index 0000000000..2d8bf9990f --- /dev/null +++ b/crates/psd/src/atn.rs @@ -0,0 +1,426 @@ +//! Photoshop Actions files (`.atn`): one action set, its actions and their steps. +//! +//! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions* +//! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was +//! copied. All numbers are big-endian; strings are the Unicode strings of descriptors (u32 +//! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. +//! +//! ```text +//! file := version u32 (16), set name UnicodeString, expanded u8, count u32, count × action +//! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, +//! name UnicodeString, expanded u8, count u32, count × item +//! item := expanded u8, enabled u8, with dialog u8, dialog options u8, +//! event kind [4]: "TEXT" (u32 length + ASCII event name) | "long" (4-char event code), +//! dictionary name (u32 length + ASCII: the name the Actions panel shows), +//! has descriptor i32 (-1 = a descriptor follows, 0 = none), [descriptor] +//! ``` +//! +//! The descriptor is a plain ActionDescriptor body ([`Descriptor`], no version prefix): the +//! event's parameters, exactly as Photoshop's own "Make", "Set", "Gaussian Blur"… events +//! carry them. Photoshop 5-era files (version 12) use a different, undocumented layout and are +//! rejected as unsupported. +//! +//! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor +//! nesting is bounded, and allocations are capped. [`write`] produces the same layout, so +//! `parse(write(f)) == f`, and `write(parse(b)) == b` for files Photoshop wrote. + +use crate::descriptor::{Descriptor, UnicodeString}; +use crate::error::{PsdError, Result}; +use crate::io::{Reader, WriteExt}; + +/// The only version this module reads and writes (Photoshop 6 and every later release). +pub const VERSION: u32 = 16; +/// Most actions read from one set (Photoshop's own sets hold a few dozen). +pub const MAX_ACTIONS: usize = 10_000; +/// Most steps read from one action. +pub const MAX_ITEMS: usize = 100_000; + +/// A parsed action set. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnFile { + /// The set's name (the Actions panel folder). + pub name: UnicodeString, + /// Whether the set is shown expanded in the Actions panel. + pub expanded: bool, + /// The actions, in panel order. + pub actions: Vec, + /// Problems that did not stop the parse (trailing bytes…). + #[cfg_attr(feature = "serde", serde(default))] + pub warnings: Vec, +} + +/// One action of a set. +#[derive(Debug, Clone, PartialEq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnAction { + /// Function key that plays the action: 0 = none, `n` = F`n`. + pub function_key: u16, + /// Shift modifier of the function key. + pub shift: bool, + /// Command (Control on Windows) modifier of the function key. + pub command: bool, + /// Button Mode colour index (0 = none, then red, orange, yellow, green, blue, violet, gray). + pub color: u16, + /// The action's name. + pub name: UnicodeString, + /// Whether the action is shown expanded in the Actions panel. + pub expanded: bool, + /// The steps, in playback order. + pub items: Vec, +} + +/// One step of an action: a Photoshop event with its parameters. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AtnItem { + /// Whether the step's parameters are shown expanded in the Actions panel. + pub expanded: bool, + /// The panel's check mark: a switched-off step is skipped by playback. + pub enabled: bool, + /// The panel's dialog toggle: playback shows the step's dialog. + pub with_dialog: bool, + /// Dialog options (0 = none, 2 = show the dialog, 3 = don't show), kept verbatim. + pub dialog_options: u8, + /// The event. + pub event: AtnEvent, + /// The name the Actions panel shows for the step ("Gaussian Blur", "Make", …). + pub dictionary: String, + /// The event's parameters; `None` for events without any. + pub descriptor: Option, +} + +impl Default for AtnItem { + fn default() -> Self { + AtnItem { + expanded: false, + enabled: true, + with_dialog: false, + dialog_options: 0, + event: AtnEvent::Code(*b" "), + dictionary: String::new(), + descriptor: None, + } + } +} + +/// A Photoshop event id: a four-character code (`GsnB`) or a string name (`trim`). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum AtnEvent { + /// Stored as `long` + 4 bytes. + Code([u8; 4]), + /// Stored as `TEXT` + length-prefixed ASCII. + Name(String), +} + +impl AtnEvent { + /// The event id as text: the code's four characters, or the name. + pub fn id(&self) -> String { + match self { + AtnEvent::Code(c) => String::from_utf8_lossy(c).into_owned(), + AtnEvent::Name(n) => n.clone(), + } + } + /// `true` if the event is the code or name `s`. + pub fn is(&self, s: &str) -> bool { + match self { + AtnEvent::Code(c) => c == s.as_bytes(), + AtnEvent::Name(n) => n == s, + } + } +} + +/// Reads a u32 length + ASCII bytes (event and dictionary names). +fn read_ascii(r: &mut Reader<'_>) -> Result { + let n = r.u32()?; + Ok(String::from_utf8_lossy(r.bytes_u64(u64::from(n))?).into_owned()) +} + +fn write_ascii(out: &mut Vec, s: &str) -> Result<()> { + let n = u32::try_from(s.len()).map_err(|_| PsdError::LimitExceeded("action name too long"))?; + out.put_u32(n); + out.put(s.as_bytes()); + Ok(()) +} + +fn read_item(r: &mut Reader<'_>) -> Result { + let expanded = r.u8()? != 0; + let enabled = r.u8()? != 0; + let with_dialog = r.u8()? != 0; + let dialog_options = r.u8()?; + let event = match &r.array::<4>()? { + b"TEXT" => AtnEvent::Name(read_ascii(r)?), + b"long" => AtnEvent::Code(r.array()?), + other => return Err(PsdError::invalid(format!("unknown event kind {:?}", String::from_utf8_lossy(other)))), + }; + let dictionary = read_ascii(r)?; + let descriptor = match r.i32()? { + 0 => None, + -1 => Some(Descriptor::read(r)?), + n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), + }; + Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) +} + +fn read_action(r: &mut Reader<'_>) -> Result { + let function_key = r.u16()?; + let shift = r.u8()? != 0; + let command = r.u8()? != 0; + let color = r.u16()?; + let name = UnicodeString::read(r)?; + let expanded = r.u8()? != 0; + let n = r.u32()?; + // Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag. + r.check_count(u64::from(n), 20)?; + if n as usize > MAX_ITEMS { + return Err(PsdError::LimitExceeded("too many steps in one action")); + } + let mut items = Vec::with_capacity((n as usize).min(1024)); + for _ in 0..n { + items.push(read_item(r)?); + } + Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) +} + +/// Parses an `.atn` file. +pub fn parse(data: &[u8]) -> Result { + let mut r = Reader::new(data); + let version = r.u32()?; + if version != VERSION { + return Err(PsdError::Unsupported(format!("actions file version {version} (only version 16, Photoshop 6 and later, is supported)"))); + } + let name = UnicodeString::read(&mut r)?; + let expanded = r.u8()? != 0; + let n = r.u32()?; + // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. + r.check_count(u64::from(n), 15)?; + if n as usize > MAX_ACTIONS { + return Err(PsdError::LimitExceeded("too many actions in one set")); + } + let mut actions = Vec::with_capacity((n as usize).min(256)); + for _ in 0..n { + actions.push(read_action(&mut r)?); + } + let mut warnings = Vec::new(); + if !r.is_empty() { + warnings.push(format!("{} trailing bytes ignored", r.remaining())); + } + Ok(AtnFile { name, expanded, actions, warnings }) +} + +fn write_item(out: &mut Vec, item: &AtnItem) -> Result<()> { + out.put_u8(u8::from(item.expanded)); + out.put_u8(u8::from(item.enabled)); + out.put_u8(u8::from(item.with_dialog)); + out.put_u8(item.dialog_options); + match &item.event { + AtnEvent::Name(n) => { + out.put(b"TEXT"); + write_ascii(out, n)?; + } + AtnEvent::Code(c) => { + out.put(b"long"); + out.put(c); + } + } + write_ascii(out, &item.dictionary)?; + match &item.descriptor { + None => out.put_i32(0), + Some(d) => { + out.put_i32(-1); + d.write(out); + } + } + Ok(()) +} + +fn count(n: usize, what: &'static str) -> Result { + u32::try_from(n).map_err(|_| PsdError::LimitExceeded(what)) +} + +/// Writes an `.atn` file (version 16). +pub fn write(f: &AtnFile) -> Result> { + let mut out = Vec::new(); + out.put_u32(VERSION); + f.name.write(&mut out); + out.put_u8(u8::from(f.expanded)); + out.put_u32(count(f.actions.len(), "too many actions")?); + for a in &f.actions { + out.put_u16(a.function_key); + out.put_u8(u8::from(a.shift)); + out.put_u8(u8::from(a.command)); + out.put_u16(a.color); + a.name.write(&mut out); + out.put_u8(u8::from(a.expanded)); + out.put_u32(count(a.items.len(), "too many steps")?); + for item in &a.items { + write_item(&mut out, item)?; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::Value; + + fn px(v: f64) -> Value { + Value::UnitFloat { unit: *b"#Pxl", value: v } + } + + fn set() -> AtnFile { + let blur = AtnItem { + expanded: true, + enabled: true, + with_dialog: true, + dialog_options: 2, + event: AtnEvent::Code(*b"GsnB"), + dictionary: "Gaussian Blur".into(), + descriptor: Some(Descriptor::new("null").with("Rds ", px(2.5))), + }; + let trim = AtnItem { + event: AtnEvent::Name("trim".into()), + dictionary: "Trim".into(), + descriptor: Some( + Descriptor { name: UnicodeString::new_nul("Trim"), class_id: crate::descriptor::Id::Str(b"trim".to_vec()), items: Vec::new() } + .with( + "trimBasedOn", + Value::Enumerated { type_id: crate::descriptor::Id::new("trimBasedOn"), value: crate::descriptor::Id::new("topLeftPixelColor") }, + ) + .with("Top ", Value::Boolean(true)), + ), + ..Default::default() + }; + let flatten = AtnItem { enabled: false, event: AtnEvent::Code(*b"Fltn"), dictionary: "Flatten Image".into(), ..Default::default() }; + AtnFile { + name: UnicodeString::new_nul("Set ✓"), + expanded: true, + actions: vec![ + AtnAction { + function_key: 2, + shift: true, + command: false, + color: 3, + name: UnicodeString::new_nul("Soften"), + expanded: true, + items: vec![blur, flatten], + }, + AtnAction { name: UnicodeString::new_nul("Tidy"), items: vec![trim], ..Default::default() }, + AtnAction { name: UnicodeString::new_nul("Empty"), ..Default::default() }, + ], + warnings: Vec::new(), + } + } + + #[test] + fn round_trip() { + let f = set(); + let bytes = write(&f).unwrap(); + let parsed = parse(&bytes).unwrap(); + assert_eq!(parsed, f); + assert_eq!(write(&parsed).unwrap(), bytes, "writing is stable"); + assert_eq!(parsed.actions[0].items[0].event.id(), "GsnB"); + assert!(parsed.actions[1].items[0].event.is("trim")); + assert_eq!(parsed.actions[1].items[0].event.id(), "trim"); + } + + /// The layout Photoshop writes for a one-step Trim action, assembled by hand from the + /// format description: field by field, so a mistake names the field. + #[test] + fn layout_matches_the_specification() { + let f = AtnFile { + name: UnicodeString::new_nul("Trim"), + expanded: false, + actions: vec![AtnAction { + name: UnicodeString::new_nul("Action 1"), + expanded: true, + items: vec![AtnItem { + expanded: true, + event: AtnEvent::Name("trim".into()), + dictionary: "Trim".into(), + descriptor: Some(Descriptor { + name: UnicodeString::new_nul("Trim"), + class_id: crate::descriptor::Id::Str(b"trim".to_vec()), + items: Vec::new(), + }), + ..Default::default() + }], + ..Default::default() + }], + warnings: Vec::new(), + }; + let mut expect = Vec::new(); + expect.extend_from_slice(&[0, 0, 0, 16]); // version + expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // set name + expect.push(0); // set collapsed + expect.extend_from_slice(&[0, 0, 0, 1]); // one action + expect.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // no function key, no modifiers, no colour + expect.extend_from_slice(&[0, 0, 0, 9]); + for c in "Action 1\0".encode_utf16() { + expect.extend_from_slice(&c.to_be_bytes()); + } + expect.push(1); // action expanded + expect.extend_from_slice(&[0, 0, 0, 1]); // one step + expect.extend_from_slice(&[1, 1, 0, 0]); // expanded, enabled, no dialog, options 0 + expect.extend_from_slice(b"TEXT\0\0\0\x04trim"); + expect.extend_from_slice(b"\0\0\0\x04Trim"); + expect.extend_from_slice(&[0xff; 4]); // a descriptor follows + expect.extend_from_slice(&[0, 0, 0, 5, 0, b'T', 0, b'r', 0, b'i', 0, b'm', 0, 0]); // class name + expect.extend_from_slice(b"\0\0\0\x04trim"); // class id, length-4 string form + expect.extend_from_slice(&[0, 0, 0, 0]); // no items + assert_eq!(write(&f).unwrap(), expect); + assert_eq!(parse(&expect).unwrap(), f); + } + + #[test] + fn truncation_never_parses_and_never_panics() { + let bytes = write(&set()).unwrap(); + for cut in 0..bytes.len() { + assert!(parse(&bytes[..cut]).is_err(), "cut at {cut}/{}", bytes.len()); + } + let mut padded = bytes.clone(); + padded.extend_from_slice(b"junk"); + let f = parse(&padded).unwrap(); + assert_eq!(f.warnings, ["4 trailing bytes ignored"]); + assert_eq!(f.actions, set().actions); + } + + #[test] + fn rejects_other_versions_and_bad_structure() { + let mut bytes = write(&set()).unwrap(); + bytes[3] = 12; + assert!(matches!(parse(&bytes), Err(PsdError::Unsupported(_)))); + // A count far beyond the data must fail before allocating. + let mut huge = vec![0, 0, 0, 16, 0, 0, 0, 0, 0]; + huge.extend_from_slice(&u32::MAX.to_be_bytes()); + assert!(matches!(parse(&huge), Err(PsdError::UnexpectedEof { .. }))); + // Unknown event kinds and descriptor flags are reported, not guessed. + let mut bad_kind = write(&set()).unwrap(); + let at = bad_kind.windows(4).position(|w| w == b"long").unwrap(); + bad_kind[at..at + 4].copy_from_slice(b"what"); + assert!(matches!(parse(&bad_kind), Err(PsdError::Invalid(_)))); + let mut bad_flag = write(&set()).unwrap(); + let at = bad_flag.windows(4).position(|w| w == [0xff; 4]).unwrap(); + bad_flag[at..at + 4].copy_from_slice(&[0, 0, 0, 7]); + assert!(matches!(parse(&bad_flag), Err(PsdError::Invalid(_)))); + } + + #[test] + fn garbage_never_panics() { + let seed = write(&set()).unwrap(); + let mut x: u32 = 0x9e37_79b9; + for i in 0..2000 { + let mut bytes = seed.clone(); + for _ in 0..(i % 7 + 1) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + let at = (x as usize) % bytes.len(); + bytes[at] = (x >> 8) as u8; + } + let _ = parse(&bytes); + } + } +} diff --git a/crates/psd/src/descriptor.rs b/crates/psd/src/descriptor.rs index b0e64ba439..632119a12d 100644 --- a/crates/psd/src/descriptor.rs +++ b/crates/psd/src/descriptor.rs @@ -149,10 +149,20 @@ pub enum ReferenceItem { /// Offset. offset: i32, }, - /// `Idnt` - Identifier(u32), - /// `indx` - Index(u32), + /// `Idnt`: a class plus an identifier (Photoshop's layer ids in action references). + Identifier { + /// Class. + class: Class, + /// Identifier. + id: u32, + }, + /// `indx`: a class plus a zero-based index. + Index { + /// Class. + class: Class, + /// Index. + index: u32, + }, /// `name` Name { /// Class. @@ -454,8 +464,8 @@ fn read_reference_item(r: &mut Reader<'_>) -> Result { b"Clss" => ReferenceItem::Class(Class::read(r)?), b"Enmr" => ReferenceItem::Enumerated { class: Class::read(r)?, type_id: Id::read(r)?, value: Id::read(r)? }, b"rele" => ReferenceItem::Offset { class: Class::read(r)?, offset: r.i32()? }, - b"Idnt" => ReferenceItem::Identifier(r.u32()?), - b"indx" => ReferenceItem::Index(r.u32()?), + b"Idnt" => ReferenceItem::Identifier { class: Class::read(r)?, id: r.u32()? }, + b"indx" => ReferenceItem::Index { class: Class::read(r)?, index: r.u32()? }, b"name" => ReferenceItem::Name { class: Class::read(r)?, name: UnicodeString::read(r)? }, other => { return Err(PsdError::Unsupported(format!("reference item type {:?}", String::from_utf8_lossy(other)))); @@ -556,13 +566,15 @@ fn write_reference_item(it: &ReferenceItem, out: &mut Vec) { class.write(out); out.put_i32(*offset); } - ReferenceItem::Identifier(v) => { + ReferenceItem::Identifier { class, id } => { out.put(b"Idnt"); - out.put_u32(*v); + class.write(out); + out.put_u32(*id); } - ReferenceItem::Index(v) => { + ReferenceItem::Index { class, index } => { out.put(b"indx"); - out.put_u32(*v); + class.write(out); + out.put_u32(*index); } ReferenceItem::Name { class, name } => { out.put(b"name"); @@ -614,8 +626,8 @@ mod tests { ReferenceItem::Class(class("", "Dcmn")), ReferenceItem::Enumerated { class: class("", "Lyr "), type_id: Id::new("Ordn"), value: Id::new("Trgt") }, ReferenceItem::Offset { class: class("", "Lyr "), offset: -1 }, - ReferenceItem::Identifier(7), - ReferenceItem::Index(2), + ReferenceItem::Identifier { class: class("", "Lyr "), id: 7 }, + ReferenceItem::Index { class: class("\0", "Lyr "), index: 2 }, ReferenceItem::Name { class: class("", "Lyr "), name: UnicodeString::new("Bg") }, ]), ) @@ -793,8 +805,8 @@ pub(crate) mod proptests { arb_class().prop_map(ReferenceItem::Class), (arb_class(), arb_id(), arb_id()).prop_map(|(class, type_id, value)| ReferenceItem::Enumerated { class, type_id, value }), (arb_class(), any::()).prop_map(|(class, offset)| ReferenceItem::Offset { class, offset }), - any::().prop_map(ReferenceItem::Identifier), - any::().prop_map(ReferenceItem::Index), + (arb_class(), any::()).prop_map(|(class, id)| ReferenceItem::Identifier { class, id }), + (arb_class(), any::()).prop_map(|(class, index)| ReferenceItem::Index { class, index }), (arb_class(), arb_ustr()).prop_map(|(class, name)| ReferenceItem::Name { class, name }), ] } diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 16d539b948..00d73c4003 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -43,6 +43,7 @@ pub mod abr; pub mod aco; pub mod ase; +pub mod atn; pub mod blend; pub mod builder; pub mod compression; diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 504ecf2beb..08565bfc3c 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -218,11 +218,126 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { + format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) + } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { + // A step from a .atn file PhotoCraft can't play yet: its Photoshop name, marked. + let name = params.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or("?"); + crate::i18n::fmt(tl!("{name} (Photoshop)"), &[("name", name)]) } else { label_of(id) } } +/// Command params for a picked file: a real path keeps the journal small; browser files arrive +/// as bytes. +fn source(name: &str, bytes: &[u8]) -> Value { + #[cfg(not(target_arch = "wasm32"))] + if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() { + return json!({"path": name}); + } + let _ = name; + json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)}) +} + +/// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…). +pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result { + let r = app.run("actions.load", source(name, bytes))?; + let n = r["actions"].as_array().map_or(0, Vec::len) as u64; + let unsupported = r["unsupported"].as_array().map_or(0, Vec::len); + let shown = crate::file_open::display_name(name); + let loaded = + crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", &shown)]); + app.ui.status_error = false; + app.ui.status = if unsupported > 0 { + let note = crate::i18n::trn( + crate::i18n::current(), + unsupported as u64, + "{n} step has no PhotoCraft equivalent yet", + "{n} steps have no PhotoCraft equivalent yet", + ); + let template = "{message} ({note})"; + crate::i18n::fmt(tl!(template), &[("message", &loaded), ("note", ¬e)]) + } else { + loaded + }; + // Show what arrived: select the first loaded action, expanded. + let list = &app.session.actions.list; + if let Some(first) = r["actions"][0].as_str() + && let Some(i) = list.iter().position(|a| a.name == first) + { + app.ui.actions.selected = Some(i); + app.ui.actions.selected_step = None; + app.ui.actions.expanded.resize(list.len(), false); + if let Some(e) = app.ui.actions.expanded.get_mut(i) { + *e = true; + } + } + app.ui.panels.history = true; + Ok(r) +} + +fn load_dialog(app: &mut PhotocraftApp) -> Result { + app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes)) +} + +/// Save Actions…: the selected action's set (every action of it), or every action when none +/// came from a set, as a `.atn` file Photoshop loads. +fn save_dialog(app: &mut PhotocraftApp) -> Result { + let set = selected_action(app).and_then(|a| a.set.clone()); + let params = match &set { + Some(set) => json!({"set": set, "skipUnsupported": true}), + None => json!({"skipUnsupported": true}), + }; + let r = app.run("actions.save", params)?; + let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?; + let n = r["actions"].as_array().map_or(0, Vec::len) as u64; + let skipped = r["skipped"].as_array().map_or(0, Vec::len); + let file = format!("{}.atn", r["set"].as_str().unwrap_or("Actions")); + app.pick_save(&file, move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + let shown = crate::file_open::display_name(&path); + let saved = + crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Saved {n} action to {file}", "Saved {n} actions to {file}"), &[("file", &shown)]); + app.ui.status_error = false; + app.ui.status = if skipped > 0 { + let note = crate::i18n::trn( + crate::i18n::current(), + skipped as u64, + "{n} step with no Photoshop equivalent left out", + "{n} steps with no Photoshop equivalent left out", + ); + let template = "{message} ({note})"; + crate::i18n::fmt(tl!(template), &[("message", &saved), ("note", ¬e)]) + } else { + saved + }; + Ok(json!({"path": path, "actions": n, "skipped": skipped})) + }) +} + +/// The Actions entries of the panel group's menu ("…"). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let mut act: Option<&str> = None; + if ui.button(tl!("Load Actions…")).clicked() { + act = Some("load"); + } + let any = !app.session.actions.list.is_empty(); + if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { + act = Some("save"); + } + let Some(a) = act else { return }; + ui.close(); + let r = if a == "load" { load_dialog(app) } else { save_dialog(app) }; + if let Err(e) = r + && e != "cancelled" + { + app.ui.status = e; + app.ui.status_error = true; + } +} + #[derive(Clone, Copy)] struct DragRow { action: usize, @@ -555,7 +670,7 @@ mod tests { let bottom = |height: f32| { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); for i in 0..50 { - app.session.actions.list.push(Action { name: format!("Action {i}"), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: format!("Action {i}"), steps: vec![], disabled: Default::default(), ..Default::default() }); } let mut h = Harness::builder().with_size(vec2(320.0, height)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(3); @@ -572,7 +687,12 @@ mod tests { use egui_kittest::{Harness, kittest::Queryable}; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); - app.session.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Action 1".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); let layers = app.session.active().unwrap().doc.layers.len(); // 60 fps steps, so two clicks a frame apart count as a double-click. let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| panel(app, ui), app); @@ -606,8 +726,18 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); assert_eq!(playback_selection(&app), None); - app.session.actions.list.push(Action { name: "Make Layer".into(), steps: vec![("layer.new.layer".into(), json!({}))], disabled: Default::default() }); - app.session.actions.list.push(Action { name: "Fit".into(), steps: vec![("view.fitOnScreen".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Make Layer".into(), + steps: vec![("layer.new.layer".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); + app.session.actions.list.push(Action { + name: "Fit".into(), + steps: vec![("view.fitOnScreen".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); assert_eq!(playback_selection(&app), Some(0)); app.ui.actions.selected = Some(1); assert_eq!(playback_selection(&app), Some(1)); @@ -654,7 +784,12 @@ mod tests { use egui_kittest::{Harness, kittest::Queryable}; let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); - app.session.actions.list.push(Action { name: "Bad action".into(), steps: vec![("not.a.command".into(), json!({}))], disabled: Default::default() }); + app.session.actions.list.push(Action { + name: "Bad action".into(), + steps: vec![("not.a.command".into(), json!({}))], + disabled: Default::default(), + ..Default::default() + }); let mut h = Harness::builder().with_size(vec2(400.0, 320.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(3); h.get_by_label("Play selection").click(); @@ -669,7 +804,7 @@ mod tests { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({"width":4,"height":4})).unwrap(); for i in 0..30 { - app.session.actions.list.push(Action { name: format!("Existing {i}"), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: format!("Existing {i}"), steps: vec![], disabled: Default::default(), ..Default::default() }); } let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(4); @@ -694,8 +829,9 @@ mod tests { name: "First".into(), steps: vec![("view.zoomOut".into(), json!({})), ("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); - app.session.actions.list.push(Action { name: "Second".into(), steps: vec![], disabled: Default::default() }); + app.session.actions.list.push(Action { name: "Second".into(), steps: vec![], disabled: Default::default(), ..Default::default() }); app.ui.actions.expanded = vec![true, false]; let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); h.run_steps(4); @@ -735,6 +871,7 @@ mod tests { name: "Child".into(), steps: vec![("layer.new.layer".into(), json!({})), ("view.fitOnScreen".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); app.run("actions.record", json!({"name":"Parent"})).unwrap(); app.run("actions.play", json!({"action":"Child"})).unwrap(); @@ -827,6 +964,7 @@ mod tests { name: "Fit".into(), steps: vec![("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let result = app.run("actions.play", json!({"action": "Fit"})).unwrap(); assert_eq!(result["ran"], 0); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8ad19f6385..2d0ef48f11 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -2514,6 +2514,7 @@ mod tests { name: "Linked contents".into(), steps: vec![(command.into(), json!({"layer": smart.0}))], disabled: Default::default(), + ..Default::default() }]; let r = call(&mut app, &ctx, "engine.execute", json!({"command": "actions.play", "params": {"action": "Linked contents"}})); assert_eq!(r["ok"], true, "{r}"); @@ -2544,6 +2545,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); let ctx = egui::Context::default(); let r = call(&mut app, &ctx, "engine.execute", json!({"command": "actions.play", "params": {"action": "Open"}})); @@ -2563,6 +2565,7 @@ mod tests { name: "Open".into(), steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); app.automation_input = true; let r = app.run("actions.play", json!({"action": "Open"})).unwrap(); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 6115e06a35..4bd043da66 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Actions") { + crate::actions::panel_menu(app, ui); + ui.separator(); + } if let Some(label) = tabs.get(sel) { crate::panel_docking::legacy_menu(app, ui, label); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a8e17f4843..889f0db89f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3660,3 +3660,13 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Načíst akce… + Save Actions… Uložit akce… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Načtena {n} akce ze souboru {file}|Načteny {n} akce ze souboru {file}|Načteno {n} akcí ze souboru {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok zatím nemá v PhotoCraftu obdobu|{n} kroky zatím nemají v PhotoCraftu obdobu|{n} kroků zatím nemá v PhotoCraftu obdobu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 596d2199ae..fdc945a901 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2692,3 +2692,13 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Aktionen laden… + Save Actions… Aktionen speichern… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} Aktion aus {file} geladen|{n} Aktionen aus {file} geladen +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} Schritt hat noch keine Entsprechung in PhotoCraft|{n} Schritte haben noch keine Entsprechung in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 07b80ef575..037615cbe2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3750,3 +3750,13 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Φόρτωση ενεργειών… + Save Actions… Αποθήκευση ενεργειών… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Φορτώθηκε {n} ενέργεια από το {file}|Φορτώθηκαν {n} ενέργειες από το {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} βήμα δεν έχει ακόμη αντίστοιχο στο PhotoCraft|{n} βήματα δεν έχουν ακόμη αντίστοιχο στο PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aafea8266f..9864dd74c3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2733,3 +2733,13 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Cargar acciones… + Save Actions… Guardar acciones… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Se cargó {n} acción de {file}|Se cargaron {n} acciones de {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} paso aún no tiene equivalente en PhotoCraft|{n} pasos aún no tienen equivalente en PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a1319f04fb..b922ebfe37 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3053,3 +3053,13 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Charger des actions… + Save Actions… Enregistrer les actions… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} action chargée depuis {file}|{n} actions chargées depuis {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} étape n’a pas encore d’équivalent dans PhotoCraft|{n} étapes n’ont pas encore d’équivalent dans PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e0eff7b8a7..10ed3d9b18 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,3 +3972,13 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Műveletek betöltése… + Save Actions… Műveletek mentése… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} művelet betöltve. Forrás: {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} lépésnek még nincs megfelelője a PhotoCraftban +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 67234cb33c..5c69b98dcd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3040,3 +3040,13 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Muat Tindakan… + Save Actions… Simpan Tindakan… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} tindakan dimuat dari {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} langkah belum memiliki padanan di PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 051b678ab0..fb05ce21bd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4016,3 +4016,13 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Carica azioni… + Save Actions… Salva azioni… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Caricata {n} azione da {file}|Caricate {n} azioni da {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} passaggio non ha ancora un equivalente in PhotoCraft|{n} passaggi non hanno ancora un equivalente in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e99c5884d..219bbf59e1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3662,3 +3662,13 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… アクションを読み込み… + Save Actions… アクションを保存… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} からアクション {n} 個を読み込みました +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個のステップにはまだ PhotoCraft の対応機能がありません +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e483ebd0b8..298e8bb3be 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3222,3 +3222,13 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 액션 불러오기… + Save Actions… 액션 저장… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file}에서 액션 {n}개를 불러왔습니다 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n}개 단계에는 아직 PhotoCraft에 해당하는 기능이 없습니다 +@plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 03702b3866..d3f81ed3c4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3812,3 +3812,13 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Handelingen laden… + Save Actions… Handelingen opslaan… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} handeling geladen uit {file}|{n} handelingen geladen uit {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} stap heeft nog geen equivalent in PhotoCraft|{n} stappen hebben nog geen equivalent in PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd4231e495..2ef524b758 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3649,3 +3649,13 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Wczytaj operacje… + Save Actions… Zapisz operacje… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Wczytano {n} operację z {file}|Wczytano {n} operacje z {file}|Wczytano {n} operacji z {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok nie ma jeszcze odpowiednika w PhotoCraft|{n} kroki nie mają jeszcze odpowiednika w PhotoCraft|{n} kroków nie ma jeszcze odpowiednika w PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f0125b55bf..8c9d446b2d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3842,3 +3842,13 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Carregar ações… + Save Actions… Salvar ações… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {n} ação carregada de {file}|{n} ações carregadas de {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} etapa ainda não tem equivalente no PhotoCraft|{n} etapas ainda não têm equivalente no PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e8aba875fa..b2a34e39f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2997,3 +2997,13 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Загрузить операции… + Save Actions… Сохранить операции… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Загружена {n} операция из {file}|Загружено {n} операции из {file}|Загружено {n} операций из {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранено {n} операции в {file}|Сохранено {n} операций в {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущено {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 8401f8e5ac..1a2974a005 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,3 +3784,13 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Eylemleri Yükle… + Save Actions… Eylemleri Kaydet… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} {file} dosyasından {n} eylem yüklendi +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} adımın PhotoCraft’ta henüz karşılığı yok +@plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e68e18a127..e366b0ffe1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3750,3 +3750,13 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Завантажити операції… + Save Actions… Зберегти операції… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Завантажено {n} операцію з {file}|Завантажено {n} операції з {file}|Завантажено {n} операцій з {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} крок ще не має відповідника в PhotoCraft|{n} кроки ще не мають відповідника в PhotoCraft|{n} кроків ще не мають відповідника в PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f7cd59aebb..6e35c4a387 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3536,3 +3536,13 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… Nạp hành động… + Save Actions… Lưu hành động… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Đã nạp {n} hành động từ {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} bước chưa có chức năng tương ứng trong PhotoCraft +@plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fe8884316b..b11c51f54f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2657,3 +2657,13 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 载入动作… + Save Actions… 存储动作… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已从 {file} 载入 {n} 个动作 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 个步骤在 PhotoCraft 中尚无对应功能 +@plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 83467820d6..95a079ec1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2642,3 +2642,13 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + +# Actions panel: Load/Save Actions (.atn) and kept Photoshop steps + Load Actions… 載入動作… + Save Actions… 儲存動作… + {name} (Photoshop) {name}(Photoshop) + {message} ({note}) {message}({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} 已從 {file} 載入 {n} 個動作 +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個步驟在 PhotoCraft 中尚無對應功能 +@plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index bc6bfcf087..4aff73c01e 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -843,6 +843,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "actions" => { + crate::actions::panel_menu(app, ui); + ui.separator(); + } _ => {} } } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9174da3ee9..7d5cafae9c 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -543,6 +543,7 @@ fn named_action_function_key_replays_and_survives_preferences_roundtrip() { name: "LivePrint 2R".into(), steps: vec![("layer.new.layer".into(), json!({"name": "F6 overlay"}))], disabled: Default::default(), + ..Default::default() }; h.state_mut().session.actions.list.push(action); crate::actions::assign_shortcut(h.state_mut(), "LivePrint 2R", "F6").unwrap(); @@ -568,6 +569,7 @@ fn assigning_action_function_key_moves_it_and_reports_failed_steps() { name: name.into(), steps: vec![("no.such.command".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); } crate::actions::assign_shortcut(h.state_mut(), "first", "F6").unwrap(); @@ -587,6 +589,7 @@ fn action_function_key_checks_automation_authorization() { name: "restricted".into(), steps: vec![("layer.new.layer".into(), json!({"name": "should not exist"}))], disabled: Default::default(), + ..Default::default() }); crate::actions::assign_shortcut(h.state_mut(), "restricted", "F6").unwrap(); h.state_mut().services.automation_command = Some(Box::new(|id, _| if id == "actions.play" { Err("denied action playback".into()) } else { Ok(()) })); @@ -624,6 +627,7 @@ fn f6_while_recording_records_a_named_call_not_the_child_steps() { name: "LivePrint 2R".into(), steps: vec![("layer.new.layer".into(), json!({"name":"Nested overlay"})), ("view.fitOnScreen".into(), json!({}))], disabled: Default::default(), + ..Default::default() }); crate::actions::assign_shortcut(h.state_mut(), "LivePrint 2R", "F6").unwrap(); h.state_mut().run("actions.record", json!({"name":"Action 2"})).unwrap(); diff --git a/docs/development.md b/docs/development.md index b7e47956cf..73a3bde0bf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -415,6 +415,21 @@ Input calls (`ui.key`, `ui.type`, `ui.click`) now reply only after the app has p so a following `ui.inspect` observes their effect. +## Photoshop Actions (.atn) + +`photocraft-psd` reads and writes Photoshop Actions files (`atn`, version 16: Photoshop 6 and +later); `photocraft-engine`'s `atn_cmds` maps their events onto commands (`actions.load`, +`actions.save`; the Actions panel menu's Load/Save Actions…; `photocraft-cli batch --actions +set.atn`). To see what a set holds (event ids, panel names, the parameter keys Photoshop +recorded) when adding an event to the table: + +```sh +cargo run -p photocraft-psd --example atn_dump -- "Default Actions.atn" +``` + +Events outside the table load as `actions.photoshopStep` steps that keep the descriptor +verbatim (so Save Actions writes them back) and fail at playback with the step's Photoshop name. + ## Remove Tool quality `cargo run --release -p photocraft-engine --example remove_quality -- [out dir]` fills diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 024d596f1e..ecda48fee7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | no | AUTO-219-2 | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (90 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | @@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | diff --git a/docs/gaps.md b/docs/gaps.md index 2ceba09952..38282b52f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; 2026-10-11: G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -87,9 +87,14 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), a full Actions panel with - step toggles and .atn import (AUTO-219-2), replay tests (AUTO-219-4), Batch recursion and - naming (AUTO-219-5), ExtendScript/UXP compatibility. +- **Missing:** document-independent action recording (AUTO-219-1), the rest of the Actions + panel: grouping by set, an Insert Stop item and editing the dialog toggle (AUTO-219-2), Batch + recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the + 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart + object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. +- **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` + and `actions.load`/`actions.save`; 90 Photoshop events map to commands both ways, Play and + Stop steps included; layer steps replay by layer name in any document (AUTO-219-2 partial). - **Ahead of Photoshop:** CLI, MCP and the JSON control channel drive every command. - **Estimate:** 120–200 h. **Human:** whether to support a JavaScript scripting dialect at all (AGENTS.md forbids JS in the app). @@ -225,6 +230,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | diff --git a/docs/scorecard.md b/docs/scorecard.md index 2d653afce6..b30f4d9a95 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 4 | 2 | 5 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 4 | 3 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -228,7 +228,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T |---|---|---|---|---| | AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | | AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | -| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | missing | [#219](https://github.com/storytold/photocraft/issues/219) | Panel deletes whole actions only (actions.rs:146) | +| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | partial | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae64d0c9c1..2f5509c694 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions; no .8BF, UXP/ExtendScript, .atn import, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript, no .atn import; .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2), no .atn, no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | @@ -450,7 +450,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Stability | Never-crash lint series, crash guard, `panic_hunt` fuzzing in the gate | medium-high | No field crash data yet. | | Camera RAW | DNG, CR2, Sony ARW (lossless + compressed), Nikon NEF (lossless + lossy compressed), RW2, uncompressed ORF; 2026-10-09: uncompressed Fujifilm RAF, Bayer and X-Trans (edge-directed X-Trans demosaic; 26 MP X-Trans develop 306 ms on 12 cores) | medium | CR3, compressed RAF and Nikon "lossy after split" still open (#50). Non-DNG cameras have no calibration: their colour (matrix + tone curve) is fitted per file to the camera's embedded JPEG, with the neutral sRGB-primaries fallback when the fit doesn't clearly help (2026-10-08: 8 D3200 NEFs, mean ΔE vs the camera JPEG 11.6–26.0 → 1.0–7.5); measured per-model calibration still open. | | AI / generative | none | ~0% | Deferred by decision (#41). | -| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; no ExtendScript/UXP/.atn; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | +| Ecosystem | Sandboxed WebAssembly plug-ins instead of .8BF; `.atn` actions load and save (2026-10-12); no ExtendScript/UXP; no Adobe Fonts/Libraries/cloud docs | low | By design for 8BF; scripting compatibility open. | | Platforms | macOS (notarized), Windows, Linux (AppImage/deb/rpm/Flatpak bundle), web | medium-high | Flathub later (#173); Windows signing material pending. | | Localisation | 2026-10-07: 10 UI languages; menu, `tl!`, blend mode, preference and brush-section coverage enforced by tests; live switching and scoped Preferences previews | medium | Engine errors/status messages still partly English; CJK web fonts, browser-locale detection, and RTL remain open. | @@ -563,6 +563,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 000e13f944..2d1c9421c9 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -21,9 +21,9 @@ note = "actions.rs:47 copies raw journal entries with explicit layer ids" [[item]] id = "AUTO-219-2" target = "Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import" -status = "missing" +status = "partial" issue = 219 -note = "Panel deletes whole actions only (actions.rs:146)" +note = "2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle" [[item]] id = "AUTO-219-3" From 049a92c288ed67199766bfa78cfb85e51d5491a5 Mon Sep 17 00:00:00 2001 From: Gotix-eg Date: Tue, 13 Oct 2026 01:40:05 +0300 Subject: [PATCH 165/523] feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925) * feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests. * Fill keys: keep Fill from History's own enablement after merging main; rustfmt edit.fillHistory joined the shared fill-key IDS on main and shares their enabled check, which now also accepts shape layers. Give Fill from History edit.fill's check so it isn't enabled where it can only fail, and limit the locked-shape test to the four colour keys. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/fill_key_cmds.rs | 80 +++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 2ae2fe4dfa..47559e2374 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -6,7 +6,9 @@ //! //! Each one is `edit.fill` with the colour and Preserve Transparency fixed: one "Fill" history //! step, the Channels panel target (alpha channel, Quick Mask) honoured, locked layers refused. +//! When targeting a vector shape layer, the fill keys recolor the shape's fill. +use photocraft_doc::{LayerContent, LayerId}; use serde_json::{Map, Value, json}; use crate::commands::CommandSpec; @@ -17,18 +19,56 @@ pub const IDS: [&str; 5] = ["edit.fillForeground", "edit.fillBackground", "edit. const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##; +fn target_shape_layer(s: &Session, p: &Value) -> Option { + if let Some(t) = p.get("target") + && (t.as_str() == Some("mask") || t.as_str() == Some("quickMask") || t.get("channel").is_some()) + { + return None; + } + let st = s.active()?; + if st.channel_view.target == crate::channel_cmds::ChannelTarget::LayerMask { + return None; + } + let id = if let Some(v) = p.get("layer") { LayerId(v.as_u64()?) } else { st.active_layer? }; + let layer = st.doc.layer(id)?; + if matches!(layer.content, LayerContent::Shape(_)) { Some(id) } else { None } +} + +/// The colour fill keys also recolor a targeted shape layer's fill. fn enabled(s: &Session) -> std::result::Result<(), String> { + if target_shape_layer(s, &Value::Null).is_some() { + return Ok(()); + } + fill_enabled(s) +} + +/// Enabled when `edit.fill` is (Fill from History has no shape-layer meaning). +fn fill_enabled(s: &Session) -> std::result::Result<(), String> { let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?; (edit_fill.enabled)(s) } -/// Run `edit.fill` with `color` (the foreground or background colour) and Preserve Transparency. +/// Run `edit.fill` with `color` (the foreground or background colour) and Preserve Transparency, +/// or recolor the vector shape fill when targeting a vector shape layer. fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) -> Result { let mut q: Map = match p { Value::Object(m) => m.clone(), Value::Null => Map::new(), _ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected an object".into() }), }; + + if let Some(id) = target_shape_layer(s, p) { + let c = if background { s.tools.background } else { s.tools.foreground }; + let q_u8 = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let hex = if c[3] < 1.0 { + format!("#{:02x}{:02x}{:02x}{:02x}", q_u8(c[0]), q_u8(c[1]), q_u8(c[2]), q_u8(c[3])) + } else { + format!("#{:02x}{:02x}{:02x}", q_u8(c[0]), q_u8(c[1]), q_u8(c[2])) + }; + let shape_edit = crate::commands::find("shape.edit").ok_or_else(|| EngineError::Other("shape.edit is unavailable".into()))?; + return (shape_edit.run)(s, &json!({ "layer": id.0, "fill": hex })); + } + // The colour and transparency handling are what the key means; don't let params override them. for k in ["color", "contents", "pattern", "preserveTransparency", "opacity"] { q.remove(k); @@ -104,7 +144,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: Some("Cmd+Alt+Backspace"), params: r##"{"state":n? (default: the History Brush's source, the oldest state still held),"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?,"preserveTransparency":bool?}"##, - enabled, + enabled: fill_enabled, run: fill_history, journal: true, }, @@ -260,4 +300,40 @@ mod tests { assert_eq!(sc("edit.fillForegroundPreserve"), Some("Alt+Shift+Backspace")); assert_eq!(sc("edit.fillBackgroundPreserve"), Some("Cmd+Shift+Backspace")); } + + #[test] + fn fill_keys_recolor_shape_layer() { + let mut s = session(); + s.execute("shape.create", json!({"kind": "rect", "rect": [1, 1, 5, 5], "fill": "#000000"})).unwrap(); + let info = s.execute("shape.info", json!({})).unwrap(); + assert_eq!(info["fill"], "#000000"); + + // Alt+Backspace fills with foreground (#ff0000) + s.execute("edit.fillForeground", json!({})).unwrap(); + let info_fg = s.execute("shape.info", json!({})).unwrap(); + assert_eq!(info_fg["fill"], "#ff0000"); + + // Cmd+Backspace fills with background (#0000ff) + s.execute("edit.fillBackground", json!({})).unwrap(); + let info_bg = s.execute("shape.info", json!({})).unwrap(); + assert_eq!(info_bg["fill"], "#0000ff"); + + // Undo restores previous fill (#ff0000) + assert!(s.undo()); + let info_undone = s.execute("shape.info", json!({})).unwrap(); + assert_eq!(info_undone["fill"], "#ff0000"); + + // Alt+Shift+Backspace also recolors shape + s.execute("tools.setColors", json!({"foreground": "#00ff00"})).unwrap(); + s.execute("edit.fillForegroundPreserve", json!({})).unwrap(); + let info_shift = s.execute("shape.info", json!({})).unwrap(); + assert_eq!(info_shift["fill"], "#00ff00"); + + // Locked shape layer errors gracefully without crashing + s.execute("layer.lockLayers", json!({"pixels": true})).unwrap(); + for id in &IDS[..4] { + let err = s.execute(id, json!({})).unwrap_err().to_string(); + assert!(err.contains("locked"), "{id}: {err}"); + } + } } From 23abc4302bb381bce0c110ccdf852cf128cc1bc6 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:43:21 +0200 Subject: [PATCH 166/523] fix(ui): default the Move tool Auto-Select target to Group (#4056) (#4093) Photoshop ships with Auto-Select on and its target set to Group; PhotoCraft defaulted to Layer. ToolOptions::default and the serde default now return "group"; saved preferences keep their stored value. Co-authored-by: highperfocused --- crates/ui-egui/src/move_auto_select_tests.rs | 4 ++-- crates/ui-egui/src/state.rs | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/move_auto_select_tests.rs b/crates/ui-egui/src/move_auto_select_tests.rs index 03ad1c21e4..0f01222a57 100644 --- a/crates/ui-egui/src/move_auto_select_tests.rs +++ b/crates/ui-egui/src/move_auto_select_tests.rs @@ -51,10 +51,10 @@ fn drag(app: &mut PhotocraftApp, from: [f64; 2], by: [f64; 2], mods: egui::Modif } #[test] -fn auto_select_is_on_by_default_with_the_layer_target() { +fn auto_select_is_on_by_default_with_the_group_target() { let o = ToolOptions::default(); assert!(o.move_auto_select, "Photoshop ships with Auto-Select checked"); - assert_eq!(o.move_target, "layer"); + assert_eq!(o.move_target, "group", "Photoshop defaults Auto-Select to Group (#4056)"); // A saved tool preset that predates the option gets the default too. let mut v = serde_json::to_value(&o).unwrap(); v.as_object_mut().unwrap().remove("move_auto_select"); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ee348d982b..02b4f005db 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -594,7 +594,8 @@ pub struct ToolOptions { #[serde(default = "one")] pub marquee_height: f32, /// Move tool: Auto-Select (with "layer" or "group" target) and Show Transform Controls. - /// Auto-Select is on by default, as in current Photoshop; ⌘/Ctrl-click inverts it. + /// Auto-Select is on with the "group" target by default, as in current Photoshop; + /// ⌘/Ctrl-click inverts it. #[serde(default = "yes")] pub move_auto_select: bool, #[serde(default = "default_move_target")] @@ -706,7 +707,7 @@ fn default_crop_resolution_unit() -> String { } fn default_move_target() -> String { - "layer".into() + "group".into() } fn default_curve_fit() -> f32 { From 95dbb01ad9ef770bc9a83c1fd8d2b35e913316fb Mon Sep 17 00:00:00 2001 From: zenjabba Date: Mon, 12 Oct 2026 18:44:09 -0400 Subject: [PATCH 167/523] fix(gpu): preflight compositor uniform buffer limits (#3931) Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com> --- crates/gpu/src/lib.rs | 49 +++++++++- crates/gpu/tests/uniform_limits.rs | 146 +++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 crates/gpu/tests/uniform_limits.rs diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 992e26dc31..cd2c77491f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -510,6 +510,31 @@ fn grid_rects(r: Rect, step: u32) -> Vec { out } +/// Number of rectangles `grid_rects` would produce, without allocating them during preflight. +fn grid_rect_count(r: Rect, step: u32) -> Result { + if r.is_empty() { + return Ok(0); + } + let s = i64::from(step.max(1)); + let axis = |lo: i32, hi: i32| usize::try_from((i64::from(hi) - 1).div_euclid(s) - i64::from(lo).div_euclid(s) + 1).ok(); + axis(r.x0, r.x1).and_then(|x| axis(r.y0, r.y1).and_then(|y| x.checked_mul(y))).ok_or_else(|| Unsupported("GPU compositor chunk count is too large".into())) +} + +/// Host length and GPU capacity for the chunk/pass uniform records, including their u32 +/// dynamic binding offsets. Decline unsupported stacks before an invalid buffer faults the device. +fn uniform_buffer_len(chunks: usize, passes: usize, limit: u64) -> Result { + let too_large = || Unsupported("GPU compositor uniform buffer is too large".into()); + let records = chunks.checked_add(passes).and_then(|n| u64::try_from(n).ok()).ok_or_else(too_large)?; + let bytes = records.checked_mul(STRIDE).ok_or_else(too_large)?; + if bytes > limit { + return Err(Unsupported(format!("GPU compositor uniform buffer requires {bytes} bytes; device limit is {limit}"))); + } + if bytes.saturating_sub(STRIDE) > u64::from(u32::MAX) { + return Err(Unsupported("GPU compositor uniform offsets exceed u32".into())); + } + usize::try_from(bytes).map_err(|_| too_large()) +} + /// The reach up to which each distance field must be exact (the largest any program asks for). fn field_reaches(progs: &[fx::MapProgram]) -> HashMap { let mut want: HashMap = HashMap::new(); @@ -915,6 +940,14 @@ impl Compositor { if region.is_empty() { return Ok(stats); } + let page = self.page; + let mut cells = grid_rects(region, page); + // Check every actual page cell before uploads, submissions or sink output. A later + // cell can need more chunk records than the first; a failure must leave the device healthy. + let buffer_limit = device.limits().max_buffer_size; + for part in &cells { + uniform_buffer_len(grid_rect_count(*part, self.chunk)?, plan.passes.len(), buffer_limit)?; + } if self.frame == 0 { // The first submission after device creation is dropped on some drivers (RADV), // which would render the first frame black; land it on an empty buffer instead. @@ -942,8 +975,6 @@ impl Compositor { // Page cells; every other frame in reverse, so a repeated full refresh of a document // larger than the budget starts with the pages still resident. - let page = self.page; - let mut cells = grid_rects(region, page); if self.frame.is_multiple_of(2) { cells.reverse(); } @@ -1168,7 +1199,8 @@ impl Compositor { self.pool.push((t, v)); } - // Uniforms: chunk records, then one record per pass. + // Uniforms: chunk records, then one record per pass. encode_scoped preflight checked + // the length, device capacity and dynamic offsets before any GPU work for this frame. let op_base = chunks.len() as u64 * STRIDE; let mut data = vec![0u8; (op_base + plan.passes.len() as u64 * STRIDE) as usize]; for (i, c) in chunks.iter().enumerate() { @@ -2257,6 +2289,17 @@ pub fn render_to_vec_stats( mod tests { use super::*; + #[test] + fn uniform_buffer_sizes_are_checked_before_allocation() { + assert_eq!(uniform_buffer_len(1, 255, 65_536).unwrap(), 65_536); + assert!(uniform_buffer_len(1, 255, 65_535).is_err()); + assert!(uniform_buffer_len(usize::MAX, 1, u64::MAX).is_err()); + assert!(uniform_buffer_len(usize::MAX, 0, u64::MAX).is_err()); + assert!(uniform_buffer_len(1, 16_777_216, u64::MAX).is_err(), "the last record offset cannot be represented by u32"); + #[cfg(target_pointer_width = "64")] + assert_eq!(uniform_buffer_len(1, 16_777_215, u64::MAX).unwrap(), 4_294_967_296, "the last valid u32 dynamic offset is accepted"); + } + #[test] fn half_roundtrip() { for v in [0.0f32, 1.0, 0.5, 0.25, 1.0 / 255.0, 0.333, 65504.0, -2.5, 1e-6] { diff --git a/crates/gpu/tests/uniform_limits.rs b/crates/gpu/tests/uniform_limits.rs new file mode 100644 index 0000000000..aea8fb6b12 --- /dev/null +++ b/crates/gpu/tests/uniform_limits.rs @@ -0,0 +1,146 @@ +//! A deep stack that cannot fit the device's uniform buffer must fall back without panicking. + +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Fill, Layer, LayerContent}; +use photocraft_geom::Size; +use photocraft_gpu::{Compositor, DeviceHealth, plan, render_to_vec}; + +static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn fills(count: usize) -> Document { + fills_size(count, Size::new(1, 1)) +} + +fn fills_size(count: usize, size: Size) -> Document { + let mut doc = Document::new("Small deep stack", size, ColorMode::Rgb, SampleType::U8); + for _ in 0..count { + doc.layers.push(Layer::new("Translucent fill", LayerContent::Fill(Fill::Solid(Color::rgba(0.25, 0.5, 0.75, 0.5))))); + } + doc +} + +fn constrained_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::default(); + let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) { + Ok(adapter) => adapter, + Err(error) => { + eprintln!("skipping uniform-limit test: no GPU adapter ({error})"); + return None; + } + }; + let limits = + wgpu::Limits { max_buffer_size: 65_536, max_storage_buffer_binding_size: 65_536, max_uniform_buffer_binding_size: 65_536, ..Default::default() }; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })) + .expect("the supported reduced device limits must create a device"); + assert_eq!(device.limits().max_buffer_size, 65_536); + Some((adapter, device, queue)) +} + +fn compositor(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Compositor { + let mut compositor = + Compositor::try_new_with_format(device, Compositor::preferred_acc_format(adapter)).expect("the reduced-limit device supports the compositor"); + compositor.set_texture_limit(256); + compositor +} + +fn healthy_control(compositor: &mut Compositor, device: &wgpu::Device, queue: &wgpu::Queue) { + let small = fills(2); + let expected = photocraft_compose::flatten(&small); + let actual = render_to_vec(compositor, device, queue, &small, small.bounds()).expect("a fitting stack renders on the same device"); + assert_eq!(actual.len(), 1); + for (cpu, gpu) in expected.px[0].iter().zip(actual[0]) { + assert!((cpu - gpu).abs() <= 1.0 / 255.0, "the healthy small-stack CPU control differs: {cpu} vs {gpu}"); + } +} + +#[test] +fn a_deep_stack_above_the_device_buffer_limit_returns_an_error_without_panicking() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = constrained_device() else { return }; + let mut compositor = compositor(&adapter, &device); + healthy_control(&mut compositor, &device, &queue); + let deep = fills(150); + eprintln!( + "uniform-limit reproduction: backend={:?}, buffer limit={}, page={}, passes={}", + adapter.get_info().backend, + device.limits().max_buffer_size, + compositor.page_size(), + plan(&deep).unwrap().passes.len() + ); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()))); + assert!(result.is_ok(), "rendering an oversized uniform buffer must return Unsupported, not panic"); + assert!(result.unwrap().is_err(), "a stack above the device's buffer limit must return Unsupported"); +} + +#[test] +fn an_oversized_stack_falls_back_without_faulting_the_watched_device() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = constrained_device() else { return }; + let health = DeviceHealth::watch(&device); + let mut compositor = compositor(&adapter, &device); + compositor.set_health(health.clone()); + healthy_control(&mut compositor, &device, &queue); + let deep = fills(150); + let fallback = match render_to_vec(&mut compositor, &device, &queue, &deep, deep.bounds()) { + Ok(_) => panic!("the oversized stack must return Unsupported for CPU fallback"), + Err(_) => photocraft_compose::flatten(&deep), + }; + for (actual, expected) in fallback.px[0].iter().zip([0.25, 0.5, 0.75, 1.0]) { + assert!((actual - expected).abs() <= 1.0 / 255.0, "CPU fallback preserves the translucent-fill composite: {actual} vs {expected}"); + } + let small = fills(2); + let next = render_to_vec(&mut compositor, &device, &queue, &small, small.bounds()); + eprintln!("watched uniform-limit reproduction: errors={}, fault={:?}", health.errors(), health.fault()); + eprintln!("next fitting stack after fallback: {next:?}"); + assert!(health.is_ok(), "the unsupported stack must leave the device healthy: {:?}", health.fault()); + assert_eq!(health.errors(), 0, "no invalid GPU operation is issued before falling back"); + healthy_control(&mut compositor, &device, &queue); +} + +#[test] +fn the_exact_buffer_limit_still_renders_with_cpu_parity() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = constrained_device() else { return }; + let health = DeviceHealth::watch(&device); + let mut compositor = compositor(&adapter, &device); + compositor.set_health(health.clone()); + let boundary = fills(127); + assert_eq!(plan(&boundary).unwrap().passes.len(), 255, "one chunk plus255passes uses exactly65536bytes"); + let expected = photocraft_compose::flatten(&boundary); + let actual = render_to_vec(&mut compositor, &device, &queue, &boundary, boundary.bounds()).expect("the exact supported buffer boundary is accepted"); + assert_eq!(actual.len(), 1); + for (cpu, gpu) in expected.px[0].iter().zip(actual[0]) { + assert!((cpu - gpu).abs() <= 1.0 / 255.0, "the boundary render differs from CPU: {cpu} vs {gpu}"); + } + assert_eq!(health.errors(), 0); + assert!(health.is_ok()); +} + +#[test] +fn later_oversized_page_cells_are_rejected_before_render_or_encode_calls_the_sink() { + let _lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let Some((adapter, device, queue)) = constrained_device() else { return }; + let health = DeviceHealth::watch(&device); + let doc = fills_size(126, Size::new(4096, 2048)); + let region = photocraft_geom::Rect::new(2047, 0, 4096, 2048); + assert_eq!(plan(&doc).unwrap().passes.len(), 253); + // Default pages are2048, chunks1024: the narrow first page has2chunks and fits; + // the later full page has4chunks and would exceed the same65536byte device limit. + for encode_only in [false, true] { + let mut compositor = Compositor::try_new_with_format(&device, Compositor::preferred_acc_format(&adapter)).unwrap(); + compositor.set_health(health.clone()); + assert_eq!(compositor.page_size(), 2048); + let mut calls = 0; + let mut sink = |_: &mut wgpu::CommandEncoder, _: photocraft_gpu::ChunkOut<'_>| calls += 1; + let result = if encode_only { + let mut encoder = device.create_command_encoder(&Default::default()); + compositor.encode(&device, &queue, &mut encoder, &doc, region, &mut sink) + } else { + compositor.render(&device, &queue, &doc, region, &mut sink) + }; + assert!(result.is_err(), "a later cell's uniform requirement must trigger CPU fallback"); + assert_eq!(calls, 0, "preflight must reject the whole render before any partial sink output"); + assert_eq!(health.errors(), 0); + assert!(health.is_ok()); + } +} From 40427f25dc612a1b024e327dfcdcf3fe10a195f6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:47:59 +0200 Subject: [PATCH 168/523] Properties: a new adjustment or fill layer brings Properties to the front (#4028) (#4079) --- crates/ui-egui/src/lib.rs | 10 ++++++++++ crates/ui-egui/src/mask_thumbs_ui/tests.rs | 19 +++++++++++++++++++ crates/ui-egui/src/panel_docking_tests.rs | 11 +++++++++++ 3 files changed, 40 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 025c19eb77..5e54fd2748 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -976,6 +976,16 @@ impl PhotocraftApp { // as in Photoshop (#3369): clicking the mask thumbnail switches it to the mask. self.ui.properties_layer = if creates_adjustment_or_fill { self.session.active().and_then(|st| st.active_layer).map(|l| l.0) } else { None }; } + if r.is_ok() && creates_adjustment_or_fill { + // Properties comes to the front, shown and expanded, so the new layer's settings can + // be edited at once, as in Photoshop (#4028). The layer is made whatever happens here. + if self.ui.docking.is_some() { + let _ = panel_docking::command(self, "window.panel.activate", &json!({"panel": "properties"})); + } else { + self.ui.dock_tabs.properties = 0; + dock::reveal(self, dock::Group::Properties); + } + } if r.is_ok() && id == "select.toWorkPath" { // Make Work Path selects the new work path in the Paths panel, as in Photoshop. self.ui.selected_path = Some("work".into()); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index f5ec70efb5..ad0e70ca84 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -737,3 +737,22 @@ fn a_new_adjustment_or_fill_layer_shows_its_settings_until_the_mask_thumbnail_is } } } + +// #4028: with Adjustments showing in a collapsed Properties group, a new adjustment layer brings +// Properties to the front and expands it, as in Photoshop, so its settings can be edited at once. +#[test] +fn a_new_adjustment_layer_brings_properties_to_the_front() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut h = harness(s, 0, 1.0, 290.0); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dockTabs": {"properties": 1}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(2); + assert!(h.state().ui.dock.is_collapsed(crate::dock::Group::Properties)); + h.state_mut().run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + h.run_steps(6); + assert_eq!(h.state().ui.dock_tabs.properties, 0, "Properties is the group's tab"); + assert!(!h.state().ui.dock.is_collapsed(crate::dock::Group::Properties), "the group is expanded"); + assert!(h.query_all_by_label("Reset to defaults").next().is_some(), "the Curves settings show"); +} diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index 6980a8a93f..657232f044 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -504,6 +504,17 @@ fn explicit_reveal_activates_existing_tab_and_expands_custom_group() { assert_eq!(app.ui.docking, before); } +// #4028: in a custom workspace too, a new adjustment layer brings Properties to the front. +#[test] +fn a_new_adjustment_layer_activates_properties_in_a_custom_workspace() { + let mut app = app(); + app.run("window.panel.activate", json!({"panel":"adjustments"})).unwrap(); + assert!(panel_active(app.ui.docking.as_ref().unwrap(), "adjustments")); + app.run("file.new", json!({"width":64,"height":64})).unwrap(); + app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + assert!(panel_active(app.ui.docking.as_ref().unwrap(), "properties")); +} + #[test] fn custom_group_commands_preserve_members_and_workspace_timeline() { let mut app = app(); From 9862dfbb097641336f53be4683124020f8f4dbfa Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 17:51:53 -0500 Subject: [PATCH 169/523] Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Actions panel: set folders, dialog toggle column, Insert Stop (#219) - Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet. Flag sets follow inserted steps; nothing edits during playback. - Panel: loose actions first, then each set as a collapsible folder row with its count (ui.actions.collapsedSets); dropping an action among another set's rows moves it there; Save Set… on a folder's context menu; a dialog toggle column next to the check marks; Insert Stop… in the panel menu with a small message/Allow Continue dialog. - Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog opens seeded with the recorded values; OK runs the dialog's values and resumes from the next step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}. - Docs: control protocol, scorecard AUTO-219-2 done, gaps G11. * Translate the Actions panel's set, dialog-toggle and Insert Stop strings * Resume a paused action only after the dialog's background job lands A filter confirmed from a dialog runs as a background job in the desktop app; the resumed steps met a locked document and failed. Wait for the job first (test covers background jobs). --- crates/engine/src/actions_cmds.rs | 6 +- crates/engine/src/actions_edit_cmds.rs | 268 ++++++++++++++++ crates/engine/src/commands.rs | 1 + crates/engine/src/lib.rs | 1 + crates/ui-egui/src/actions.rs | 406 +++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 5 + crates/ui-egui/src/i18n/cs.tsv | 11 + crates/ui-egui/src/i18n/de.tsv | 11 + crates/ui-egui/src/i18n/el.tsv | 11 + crates/ui-egui/src/i18n/es.tsv | 11 + crates/ui-egui/src/i18n/fr.tsv | 11 + crates/ui-egui/src/i18n/hu.tsv | 11 + crates/ui-egui/src/i18n/id.tsv | 11 + crates/ui-egui/src/i18n/it.tsv | 11 + crates/ui-egui/src/i18n/ja.tsv | 11 + crates/ui-egui/src/i18n/ko.tsv | 11 + crates/ui-egui/src/i18n/nl.tsv | 11 + crates/ui-egui/src/i18n/pl.tsv | 11 + crates/ui-egui/src/i18n/pt-br.tsv | 11 + crates/ui-egui/src/i18n/ru.tsv | 11 + crates/ui-egui/src/i18n/tr.tsv | 11 + crates/ui-egui/src/i18n/uk.tsv | 11 + crates/ui-egui/src/i18n/vi.tsv | 11 + crates/ui-egui/src/i18n/zh-hans.tsv | 11 + crates/ui-egui/src/i18n/zh-hant.tsv | 11 + docs/control-protocol.md | 11 + docs/gaps.md | 12 +- docs/scorecard.md | 4 +- scorecard/automation.toml | 4 +- 29 files changed, 897 insertions(+), 30 deletions(-) create mode 100644 crates/engine/src/actions_edit_cmds.rs diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 2611853b2d..edadde2117 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -53,7 +53,7 @@ impl Action { } /// Apply `f` to every per-step index set (`disabled`, `dialogs`) after steps moved. - fn remap_steps(&mut self, f: impl Fn(usize) -> Option) { + pub(crate) fn remap_steps(&mut self, f: impl Fn(usize) -> Option) { self.disabled = self.disabled.iter().filter_map(|&i| f(i)).collect(); self.dialogs = self.dialogs.iter().filter_map(|&i| f(i)).collect(); } @@ -275,7 +275,7 @@ pub fn pending_steps(s: &Session, action: usize) -> impl Iterator Result<()> { +pub(crate) fn flush_recording(s: &mut Session) -> Result<()> { let Some((_, idx)) = s.actions.recording else { return Ok(()) }; if idx >= s.actions.list.len() { return Err(bad("actions.stop", "the action being recorded no longer exists")); @@ -374,7 +374,7 @@ fn play(s: &mut Session, p: &Value) -> Result { }) } -fn not_playing(s: &Session, cmd: &str) -> Result<()> { +pub(crate) fn not_playing(s: &Session, cmd: &str) -> Result<()> { if s.actions.playing > 0 { Err(bad(cmd, "cannot edit actions during playback")) } else { Ok(()) } } diff --git a/crates/engine/src/actions_edit_cmds.rs b/crates/engine/src/actions_edit_cmds.rs new file mode 100644 index 0000000000..c7cde6869f --- /dev/null +++ b/crates/engine/src/actions_edit_cmds.rs @@ -0,0 +1,268 @@ +//! Editing an action's steps and its set, as the Actions panel does beyond recording: +//! `actions.insert` (Photoshop's Insert Stop… and Insert Menu Item…), `actions.dialog` (the +//! panel's dialog toggle column) and `actions.assignSet` (which set folder an action sits in). +//! +//! All three refuse to run during playback, commit pending recorded steps first, and keep the +//! per-step flag sets (`disabled`, `dialogs`) in step with the moved indices, like +//! `actions.move` and `actions.delete`. + +use serde_json::{Value, json}; + +use crate::actions_cmds::{flush_recording, not_playing, pending_steps, resolve}; +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +fn index(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a non-negative integer"))), + } +} + +/// Whether `id` can be a step: a registered command, a Play Action call, or one of the desktop +/// shell's recorded save/view commands. +fn is_step_command(id: &str) -> bool { + crate::commands::find(id).is_some() || id == "actions.play" || crate::actions_cmds::shell_save_command(id) || crate::actions_cmds::shell_view_command(id) +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.insert"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + let id = match p.get("id") { + Some(Value::String(id)) if !id.trim().is_empty() => id.trim().to_string(), + _ => return Err(bad(cmd, "needs `id`, the command the step runs")), + }; + if !is_step_command(&id) { + return Err(bad(cmd, format!("unknown command `{id}`"))); + } + let params = match p.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err(bad(cmd, "`params` must be an object")), + }; + if id == "actions.play" { + // Refuse the one cycle visible here; deeper ones are caught at playback. + let target = params.get("action"); + let is_self = target.and_then(Value::as_str) == Some(s.actions.list[idx].name.as_str()) || target.and_then(Value::as_u64) == Some(idx as u64); + if is_self { + return Err(bad(cmd, "an action cannot play itself")); + } + } + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + let at = index(p, "at", cmd)?.unwrap_or(len); + if at > len { + return Err(bad(cmd, format!("`at` {at} is past the last step ({len})"))); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &mut s.actions.list[idx]; + action.steps.insert(at, (id.clone(), params)); + action.remap_steps(|i| Some(if i >= at { i + 1 } else { i })); + let result = json!({"action": action.name, "step": at, "id": id, "steps": action.steps.len()}); + s.actions.touch(); + Ok(result) +} + +/// The panel's dialog toggle column. With `step`, switch one step's dialog on or off; without +/// it, every step (all on unless all already are, like clicking the action's own toggle). +fn dialog(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.dialog"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + let enabled = match p.get("enabled") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), + Some(_) => return Err(bad(cmd, "`enabled` must be true or false")), + }; + let step = index(p, "step", cmd)?; + let len = s.actions.list[idx].steps.len().saturating_add(pending_steps(s, idx).count()); + if step.is_some_and(|step| step >= len) { + return Err(bad(cmd, "step is out of range")); + } + if s.actions.recording.is_some_and(|(_, i)| i == idx) { + flush_recording(s)?; + } + let action = &mut s.actions.list[idx]; + match step { + Some(step) => { + if enabled.unwrap_or(!action.dialogs.contains(&step)) { + action.dialogs.insert(step); + } else { + action.dialogs.remove(&step); + } + } + None if enabled.unwrap_or(action.dialogs.len() < action.steps.len()) => action.dialogs = (0..action.steps.len()).collect(), + None => action.dialogs.clear(), + } + let result = json!({"action": action.name, "dialogs": action.dialogs}); + s.actions.touch(); + Ok(result) +} + +fn assign_set(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.assignSet"; + not_playing(s, cmd)?; + let idx = resolve(&s.actions.list, p, cmd)?; + let set = match p.get("set") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if !n.trim().is_empty() => Some(n.trim().to_string()), + Some(Value::String(_)) => return Err(bad(cmd, "`set` is empty; pass null to take the action out of its set")), + Some(_) => return Err(bad(cmd, "`set` must be a name or null")), + }; + let action = &mut s.actions.list[idx]; + action.set = set; + let result = json!({"action": action.name, "set": action.set}); + s.actions.touch(); + Ok(result) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "actions.insert", + label: "Insert Step", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "id":command id, "params":{}?, "at":index?=end} → {action, step, id, steps}. Inserts a step at the 0-based position (Photoshop's Insert Stop… with id actions.stopMessage, Insert Menu Item… with any command). Pending recorded steps count; flags of the steps after it follow them."##, + enabled: always, + run: insert, + journal: false, + }, + CommandSpec { + id: "actions.dialog", + label: "Toggle Step Dialog", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "step":index?, "enabled":bool?} → {action, dialogs:[step…]}. The Actions panel's dialog toggle: the desktop shell shows the step's dialog, seeded with its recorded params, and resumes playback when it is confirmed; headless playback runs the recorded params. Omitted enabled flips it; without step, every step: on unless all already are, or as enabled says."##, + enabled: always, + run: dialog, + journal: false, + }, + CommandSpec { + id: "actions.assignSet", + label: "Move to Set", + menu: &[], + shortcut: None, + params: r##"{"action":name|index, "set":name|null} → {action, set}. The set folder the Actions panel shows the action under (what Load Actions fills and Save Actions writes); null takes it out of any set."##, + enabled: always, + run: assign_set, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::actions_cmds::Action; + + fn session_with(steps: &[&str]) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.actions.list.push(Action::new("A", steps.iter().map(|id| (id.to_string(), json!({}))).collect())); + s + } + + #[test] + fn insert_places_the_step_and_shifts_the_flags() { + let mut s = session_with(&["select.all", "select.inverse", "select.deselect"]); + s.execute("actions.toggle", json!({"action": "A", "step": 1})).unwrap(); + s.execute("actions.dialog", json!({"action": "A", "step": 2})).unwrap(); + let r = s + .execute("actions.insert", json!({"action": "A", "at": 1, "id": "actions.stopMessage", "params": {"message": "Check", "allowContinue": true}})) + .unwrap(); + assert_eq!((r["step"].as_u64(), r["steps"].as_u64()), (Some(1), Some(4))); + let a = &s.actions.list[0]; + assert_eq!(a.steps[1], ("actions.stopMessage".to_string(), json!({"message": "Check", "allowContinue": true}))); + assert_eq!(a.disabled.iter().copied().collect::>(), [2], "the switched-off step moved down one"); + assert_eq!(a.dialogs.iter().copied().collect::>(), [3]); + // Default position: the end. A bare id works for a parameterless command. + let r = s.execute("actions.insert", json!({"action": 0, "id": "layer.new.layer"})).unwrap(); + assert_eq!(r["step"], 4); + assert_eq!(s.actions.list[0].steps[4].0, "layer.new.layer"); + // Playback runs the inserted stop and continues past it. + let played = s.execute("actions.play", json!({"action": "A"})).unwrap(); + assert_eq!(played["ran"], 4, "{played}"); + assert!(played.get("failed").is_none(), "{played}"); + } + + #[test] + fn insert_while_recording_keeps_pending_steps_in_order() { + let mut s = session_with(&[]); + s.execute("actions.record", json!({"action": "A"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "One"})).unwrap(); + s.execute("actions.insert", json!({"action": "A", "at": 0, "id": "select.all"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Two"})).unwrap(); + s.execute("actions.stop", json!({})).unwrap(); + let ids: Vec<&str> = s.actions.list[0].steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["select.all", "layer.new.layer", "layer.new.layer"]); + } + + #[test] + fn dialog_toggles_one_step_or_all() { + let mut s = session_with(&["select.all", "select.inverse"]); + assert_eq!(s.execute("actions.dialog", json!({"action": "A", "step": 0})).unwrap()["dialogs"], json!([0])); + assert_eq!(s.execute("actions.dialog", json!({"action": "A", "step": 0})).unwrap()["dialogs"], json!([])); + assert_eq!(s.execute("actions.dialog", json!({"action": "A", "step": 1, "enabled": true})).unwrap()["dialogs"], json!([1])); + assert_eq!(s.execute("actions.dialog", json!({"action": "A"})).unwrap()["dialogs"], json!([0, 1]), "not all on: all on"); + assert_eq!(s.execute("actions.dialog", json!({"action": "A"})).unwrap()["dialogs"], json!([]), "all on: all off"); + assert_eq!(s.execute("actions.dialog", json!({"action": "A", "enabled": true})).unwrap()["dialogs"], json!([0, 1])); + assert_eq!(s.execute("actions.get", json!({"action": "A"})).unwrap()["dialogs"], json!([0, 1])); + // Deleting a step keeps the flags of the others in place. + s.execute("actions.delete", json!({"action": "A", "step": 0})).unwrap(); + assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); + } + + #[test] + fn assign_set_moves_an_action_between_folders() { + let mut s = session_with(&["select.all"]); + assert_eq!(s.execute("actions.assignSet", json!({"action": "A", "set": " Retouch "})).unwrap()["set"], "Retouch"); + assert_eq!(s.actions.list[0].set.as_deref(), Some("Retouch")); + assert_eq!(s.execute("actions.list", json!({})).unwrap()["actions"][0]["set"], "Retouch"); + assert_eq!(s.execute("actions.assignSet", json!({"action": 0, "set": null})).unwrap()["set"], Value::Null); + assert_eq!(s.actions.list[0].set, None); + } + + #[test] + fn bad_params_fail_gracefully() { + let mut s = session_with(&["select.all"]); + let before = s.actions.list.clone(); + for p in [ + json!({}), + json!({"action": "A"}), + json!({"action": "A", "id": ""}), + json!({"action": "A", "id": 5}), + json!({"action": "A", "id": "not.a.command"}), + json!({"action": "A", "id": "select.all", "at": 2}), + json!({"action": "A", "id": "select.all", "at": -1}), + json!({"action": "A", "id": "select.all", "params": "x"}), + json!({"action": "A", "id": "actions.play", "params": {"action": "A"}}), + json!({"action": 9, "id": "select.all"}), + ] { + assert!(s.execute("actions.insert", p.clone()).is_err(), "{p}"); + } + for p in + [json!({}), json!({"action": "A", "step": 1}), json!({"action": "A", "step": "x"}), json!({"action": "A", "enabled": "yes"}), json!({"action": 3})] + { + assert!(s.execute("actions.dialog", p.clone()).is_err(), "{p}"); + } + for p in [json!({}), json!({"action": "A", "set": ""}), json!({"action": "A", "set": 7}), json!({"action": "B", "set": "S"})] { + assert!(s.execute("actions.assignSet", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.actions.list, before, "rejected edits change nothing"); + // Nothing edits during playback. + s.actions.list.push(Action::new("Loop", vec![("actions.insert".into(), json!({"action": "A", "id": "select.all"}))])); + let played = s.execute("actions.play", json!({"action": "Loop"})).unwrap(); + assert!(played["failed"]["error"].as_str().unwrap().contains("playback"), "{played}"); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6771b36f47..7a1cc4ffb8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1225,6 +1225,7 @@ fn build() -> Vec { v.extend(crate::eraser_cmds::specs()); v.extend(crate::preset_import_cmds::specs()); v.extend(crate::atn_cmds::specs()); + v.extend(crate::actions_edit_cmds::specs()); v.extend(crate::swatch_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::redeye_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1115ab7e33..4df2a0a81f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod actions_cmds; +pub mod actions_edit_cmds; pub mod adjust_cmds; pub mod adjust_params; pub mod align_cmds; diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 08565bfc3c..997770b5f6 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -21,6 +21,8 @@ pub struct ActionsUi { pub selected: Option, pub selected_step: Option, pub expanded: Vec, + /// Set folders shown collapsed (Photoshop's set disclosure triangles). + pub collapsed_sets: Vec, #[serde(skip)] reveal_recording: Option<(usize, usize)>, /// The action whose name is being edited in place (double-click on its name). @@ -92,7 +94,22 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result Result { @@ -128,9 +150,10 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result json!({"action": action.name, "ran": ran, "failed": f}), - None => json!({"action": action.name, "ran": ran}), + Ok(match (failed, paused) { + (Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}), + (None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}), + (None, None) => json!({"action": action.name, "ran": ran}), }) } @@ -177,6 +200,13 @@ fn begin_recording(app: &mut PhotocraftApp, append: bool) { } pub(crate) fn report_play(app: &mut PhotocraftApp, v: &Value) { + if let Some(paused) = v.get("paused").filter(|p| p.is_object()) { + let step = paused.get("step").and_then(Value::as_u64).unwrap_or(0); + let template = "Step {step} is waiting for its dialog"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("step", &(step + 1).to_string())]); + app.ui.status_error = false; + return; + } let Some(failed) = v.get("failed").filter(|f| f.is_object()) else { let ran = v.get("ran").and_then(Value::as_u64).unwrap_or(0); app.ui.status = crate::i18n::trn(crate::i18n::current(), ran, "Played {n} step", "Played {n} steps"); @@ -283,8 +313,8 @@ fn load_dialog(app: &mut PhotocraftApp) -> Result { /// Save Actions…: the selected action's set (every action of it), or every action when none /// came from a set, as a `.atn` file Photoshop loads. -fn save_dialog(app: &mut PhotocraftApp) -> Result { - let set = selected_action(app).and_then(|a| a.set.clone()); +fn save_dialog(app: &mut PhotocraftApp, set: Option) -> Result { + let set = set.or_else(|| selected_action(app).and_then(|a| a.set.clone())); let params = match &set { Some(set) => json!({"set": set, "skipUnsupported": true}), None => json!({"skipUnsupported": true}), @@ -317,6 +347,85 @@ fn save_dialog(app: &mut PhotocraftApp) -> Result { }) } +const INSERT_STOP: &str = "__insertStop"; + +pub fn owns_insert_stop(fields: &serde_json::Map) -> bool { + fields.contains_key(INSERT_STOP) +} + +/// Insert Stop…: a message step after the selected step (or at the end of the selected action). +fn insert_stop_dialog(app: &mut PhotocraftApp) -> Result { + let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or_else(|| tl!("Select an action first").to_string())?; + let len = app.session.actions.list[action].steps.len() + actions_cmds::pending_steps(&app.session, action).count(); + let at = app.ui.actions.selected_step.map_or(len, |s| (s + 1).min(len)); + let fields = + json!({INSERT_STOP: true, "__command": "actions.insert", "__label": "Insert Stop", "action": action, "at": at, "message": "", "allowContinue": false}); + let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); + Ok(json!({"dialog": id})) +} + +pub fn insert_stop_body(ui: &mut egui::Ui, fields: &mut serde_json::Map) { + ui.label(tl!("Message:")); + let mut message = fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string(); + ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0)); + fields.insert("message".into(), json!(message)); + ui.add_space(6.0); + let mut allow = fields.get("allowContinue").and_then(Value::as_bool).unwrap_or(false); + ui.checkbox(&mut allow, tl!("Allow Continue")); + fields.insert("allowContinue".into(), json!(allow)); +} + +pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map) -> Result { + let action = fields.get("action").and_then(Value::as_u64).ok_or("the dialog names no action")?; + let params = json!({ + "action": action, + "at": fields.get("at").cloned().unwrap_or(Value::Null), + "id": photocraft_engine::atn_cmds::STOP_MESSAGE, + "params": {"message": fields.get("message").cloned().unwrap_or(json!("")), "allowContinue": fields.get("allowContinue").cloned().unwrap_or(json!(false))}, + }); + let r = app.run("actions.insert", params)?; + if let (Some(a), Some(step)) = (usize::try_from(action).ok(), r["step"].as_u64().and_then(|s| usize::try_from(s).ok())) { + app.ui.actions.selected = Some(a); + app.ui.actions.selected_step = Some(step); + app.ui.actions.expanded.resize(app.session.actions.list.len(), false); + if let Some(e) = app.ui.actions.expanded.get_mut(a) { + *e = true; + } + } + Ok(r) +} + +/// The key a paused playback leaves in its dialog's fields: `{action, from}`. +pub const RESUME: &str = "__resumeAction"; + +/// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this). +pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map) { + let Some(r) = fields.get(RESUME) else { return }; + // The dialog's command may run as a background job; the next step needs its result. + if let Some(doc) = app.session.active().map(|d| d.doc.id) + && let Some(job) = app.session.job_on(doc) + { + let _ = app.session.wait_job(job.id); + } + let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)}); + match play(app, ¶ms) { + Ok(v) => report_play(app, &v), + Err(e) => { + let template = "Action playback failed: {error}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]); + app.ui.status_error = true; + } + } +} + +/// A step dialog cancelled: the action stops there, as in Photoshop. +pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map) { + if fields.contains_key(RESUME) { + app.ui.status = tl!("Action playback stopped").to_string(); + app.ui.status_error = false; + } +} + /// The Actions entries of the panel group's menu ("…"). pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut act: Option<&str> = None; @@ -327,9 +436,18 @@ pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() { act = Some("save"); } + ui.separator(); + let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len()); + if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() { + act = Some("stop"); + } let Some(a) = act else { return }; ui.close(); - let r = if a == "load" { load_dialog(app) } else { save_dialog(app) }; + let r = match a { + "load" => load_dialog(app), + "stop" => insert_stop_dialog(app), + _ => save_dialog(app, None), + }; if let Err(e) = r && e != "cancelled" { @@ -380,6 +498,12 @@ fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) { } app.ui.actions.selected = Some(to); app.ui.actions.selected_step = None; + // Dropped among another set's actions: the action joins that set (the row above wins). + let list = &app.session.actions.list; + let neighbour = to.checked_sub(1).and_then(|j| list.get(j)).or_else(|| list.get(to + 1)).and_then(|a| a.set.clone()); + if list.get(to).is_some_and(|a| a.set != neighbour) { + let _ = app.run("actions.assignSet", json!({"action": to, "set": neighbour})); + } } } @@ -439,15 +563,49 @@ fn rename_action(app: &mut PhotocraftApp, index: usize, name: String) { } } +/// One action's row data for a frame. +struct Row { + name: String, + steps: Vec<(String, Value)>, + disabled: std::collections::BTreeSet, + dialogs: std::collections::BTreeSet, + set: Option, +} + +/// The panel's order: actions outside any set first, then each set folder (in order of first +/// appearance) with its actions. Indices are into the engine's flat list. +fn display_order(rows: &[Row]) -> Vec { + let mut order: Vec = rows.iter().enumerate().filter(|(_, r)| r.set.is_none()).map(|(i, _)| i).collect(); + let mut sets: Vec<&str> = Vec::new(); + for r in rows { + if let Some(set) = r.set.as_deref() + && !sets.contains(&set) + { + sets.push(set); + } + } + for set in sets { + order.extend(rows.iter().enumerate().filter(|(_, r)| r.set.as_deref() == Some(set)).map(|(i, _)| i)); + } + order +} + pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let recording = app.session.actions.recording; - let rows: Vec<_> = app.session.actions.list.iter().enumerate().map(|(i, a)| (a.name.clone(), visible_steps(app, i), a.disabled.clone())).collect(); + let rows: Vec = app + .session + .actions + .list + .iter() + .enumerate() + .map(|(i, a)| Row { name: a.name.clone(), steps: visible_steps(app, i), disabled: a.disabled.clone(), dialogs: a.dialogs.clone(), set: a.set.clone() }) + .collect(); if app .ui .actions .selected - .is_some_and(|i| i >= rows.len() || app.ui.actions.selected_step.is_some_and(|step| rows.get(i).is_none_or(|row| step >= row.1.len()))) + .is_some_and(|i| i >= rows.len() || app.ui.actions.selected_step.is_some_and(|step| rows.get(i).is_none_or(|row| step >= row.steps.len()))) { app.ui.actions.selected = None; app.ui.actions.selected_step = None; @@ -460,10 +618,13 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let mut renamed = None; let mut toggled = None; + let mut dialog_toggled = None; + let mut set_toggled: Option = None; + let mut save_set: Option = None; let max_h = (ui.available_height() - 38.0).max(0.0); let mut play_idx = None; let mut moved = None; - let recording_row = recording.and_then(|(_, i)| rows.get(i).map(|(_, steps, _)| (i, steps.len()))); + let recording_row = recording.and_then(|(_, i)| rows.get(i).map(|r| (i, r.steps.len()))); let reveal = recording_row.filter(|row| app.ui.actions.reveal_recording != Some(*row)); app.ui.actions.reveal_recording = recording_row; if let Some((i, _)) = reveal { @@ -492,8 +653,54 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } - for (i, (name, steps, disabled)) in rows.iter().enumerate() { + let mut shown_set: Option<&str> = None; + for i in display_order(&rows) { + let Row { name, steps, disabled, dialogs, set } = &rows[i]; + // A set folder row before the first of its actions; collapsed folders hide them. + if let Some(set) = set.as_deref() + && shown_set != Some(set) + { + shown_set = Some(set); + let collapsed = app.ui.actions.collapsed_sets.iter().any(|c| c == set); + let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, !collapsed, set)); + if hresp.hovered() { + ui.painter().rect_filled(hr, 0.0, t.hover.gamma_multiply(0.5)); + } + let tri = Rect::from_center_size(pos2(hr.left() + 12.0, hr.center().y), vec2(8.0, 8.0)); + let pts = if collapsed { + vec![tri.left_top(), tri.right_center(), tri.left_bottom()] + } else { + vec![tri.left_top(), tri.right_top(), tri.center_bottom()] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); + crate::icons::paint( + ui, + Rect::from_center_size(pos2(hr.left() + 30.0, hr.center().y), vec2(16.0, 16.0)), + if collapsed { "folder" } else { "folder-open" }, + 12.0, + t.icon, + ); + ui.painter().text(pos2(hr.left() + 44.0, hr.center().y), Align2::LEFT_CENTER, set, egui::FontId::proportional(12.0), t.text); + let count = rows.iter().filter(|r| r.set.as_deref() == Some(set)).count() as u64; + let summary = crate::i18n::trn(crate::i18n::current(), count, "{n} action", "{n} actions"); + ui.painter().text(pos2(hr.right() - 8.0, hr.center().y), Align2::RIGHT_CENTER, summary, egui::FontId::proportional(11.0), t.text_faint); + if hresp.clicked() { + set_toggled = Some(set.to_string()); + } + hresp.context_menu(|ui| { + if ui.button(tl!("Save Set…")).clicked() { + save_set = Some(set.to_string()); + ui.close(); + } + }); + } + if set.as_deref().is_some_and(|set| app.ui.actions.collapsed_sets.iter().any(|c| c == set)) { + continue; + } + let indent = if set.is_some() { 14.0 } else { 0.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); + let x0 = rect.left() + indent; if reveal == Some((i, 0)) { ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM)); } @@ -508,22 +715,21 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { moved = Some(change); } let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false); - let tri = Rect::from_center_size(pos2(rect.left() + 12.0, rect.center().y), vec2(8.0, 8.0)); + let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0)); let pts = if expanded { vec![tri.left_top(), tri.right_top(), tri.center_bottom()] } else { vec![tri.left_top(), tri.right_center(), tri.left_bottom()] }; ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); - crate::icons::paint(ui, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); + crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon); let recording_this = recording.is_some_and(|(_, r)| r == i); let nsteps = steps.len(); let name_rect = if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index == i) { - let field = - Rect::from_min_max(pos2(rect.left() + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(rect.left() + 120.0), rect.bottom() - 3.0)); + let field = Rect::from_min_max(pos2(x0 + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(x0 + 120.0), rect.bottom() - 3.0)); if let Some(text) = rename_field(app, ui, i, field) { renamed = Some((i, text)); } field } else { - ui.painter().text(pos2(rect.left() + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text) + ui.painter().text(pos2(x0 + 44.0, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text) }; ui.painter().text( pos2(rect.right() - 8.0, rect.center().y), @@ -543,7 +749,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if resp.clicked() { app.ui.actions.selected = Some(i); app.ui.actions.selected_step = None; - if resp.interact_pointer_pos().is_some_and(|p| p.x < rect.left() + 20.0) { + if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) { if app.ui.actions.expanded.len() <= i { app.ui.actions.expanded.resize(i + 1, false); } @@ -588,17 +794,33 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } let on = !disabled.contains(&step); let color = if on { t.text_dim } else { t.text_faint }; - ui.painter().text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), color); + ui.painter().text(pos2(x0 + 62.0, r.center().y), Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), color); // Photoshop's check column: unchecked steps are skipped by playback, Batch and droplets. let check = ui.interact( - Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(16.0, 16.0)), + Rect::from_center_size(pos2(x0 + 30.0, r.center().y), vec2(16.0, 16.0)), ui.id().with(("action-step-check", i, step)), Sense::click(), ); check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, format!("{}: {label}", tl!("Include in playback")))); paint_check(ui, check.rect, on, &t); + // Photoshop's dialog column: playback shows the step's dialog, seeded with its + // recorded values, and continues when it is confirmed. + let with_dialog = dialogs.contains(&step); + let dialog = ui.interact( + Rect::from_center_size(pos2(x0 + 46.0, r.center().y), vec2(16.0, 16.0)), + ui.id().with(("action-step-dialog", i, step)), + Sense::click(), + ); + // A toggle button, not a check box, so the check column stays the only CheckBox role. + dialog.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback"))) + }); + crate::icons::paint(ui, dialog.rect, "message-square", 11.0, if with_dialog { t.icon } else { t.text_faint.gamma_multiply(0.45) }); + let dialog = dialog.on_hover_text(tl!("Show dialog during playback")); if check.on_hover_text(tl!("Include in playback")).clicked() { toggled = Some((i, step)); + } else if dialog.clicked() { + dialog_toggled = Some((i, step)); } else if response.clicked() { app.ui.actions.selected = Some(i); app.ui.actions.selected_step = Some(step); @@ -610,6 +832,24 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if let Some((action, step)) = toggled { let _ = app.run("actions.toggle", json!({"action": action, "step": step})); } + if let Some((action, step)) = dialog_toggled { + let _ = app.run("actions.dialog", json!({"action": action, "step": step})); + } + if let Some(set) = set_toggled { + match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) { + Some(i) => { + app.ui.actions.collapsed_sets.remove(i); + } + None => app.ui.actions.collapsed_sets.push(set), + } + } + if let Some(set) = save_set + && let Err(e) = save_dialog(app, Some(set)) + && e != "cancelled" + { + app.ui.status = e; + app.ui.status_error = true; + } if let Some((index, name)) = renamed { rename_action(app, index, name); } @@ -1042,4 +1282,134 @@ mod tests { assert_eq!(*written.borrow(), ["out/result.psd", "out/result.psd"]); assert_eq!(app.session.active().unwrap().path.as_deref(), Some("out/result.psd")); } + + #[test] + fn set_folders_group_actions_and_collapse() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut sharpen = Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))]); + sharpen.set = Some("Retouch".into()); + let mut soften = Action::new("Soften", vec![("filter.blur.blur".into(), json!({}))]); + soften.set = Some("Retouch".into()); + app.session.actions.list.push(Action::new("Loose", vec![])); + app.session.actions.list.push(sharpen); + app.session.actions.list.push(Action::new("Also loose", vec![])); + app.session.actions.list.push(soften); + let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app); + h.run_steps(3); + // The folder row shows the set with its count; its actions sit under it, loose ones first. + let folder = h.get_by_label("Retouch").rect(); + assert!(h.get_by_label("Loose").rect().top() < folder.top()); + assert!(h.get_by_label("Also loose").rect().top() < folder.top()); + assert!(h.get_by_label("Sharpen").rect().top() > folder.top()); + assert!(h.get_by_label("Soften").rect().top() > h.get_by_label("Sharpen").rect().top()); + assert_eq!(display_order(&[]), Vec::::new()); + // Clicking the folder collapses it; its actions disappear and the state is remembered. + h.get_by_label("Retouch").click(); + h.run_steps(3); + assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]); + assert!(h.query_by_label("Sharpen").is_none()); + assert!(h.query_by_label("Loose").is_some()); + h.get_by_label("Retouch").click(); + h.run_steps(3); + assert!(h.state().ui.actions.collapsed_sets.is_empty()); + assert!(h.query_by_label("Sharpen").is_some()); + } + + #[test] + fn moving_an_action_next_to_another_set_joins_it() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut a = Action::new("A", vec![]); + a.set = Some("One".into()); + let mut b = Action::new("B", vec![]); + b.set = Some("Two".into()); + app.session.actions.list.push(a); + app.session.actions.list.push(b); + app.session.actions.list.push(Action::new("C", vec![])); + // C dropped below B: it joins B's set, "Two". + move_row(&mut app, DragRow { action: 2, step: None }, 2); + assert_eq!(app.session.actions.list[2].set.as_deref(), Some("Two")); + // B dropped to the top, above A: the row below decides when there is none above. + move_row(&mut app, DragRow { action: 1, step: None }, 0); + assert_eq!((app.session.actions.list[0].name.as_str(), app.session.actions.list[0].set.as_deref()), ("B", Some("One"))); + // C dropped between A (One) and nothing: the row above wins. + move_row(&mut app, DragRow { action: 2, step: None }, 2); + assert_eq!((app.session.actions.list[2].name.as_str(), app.session.actions.list[2].set.as_deref()), ("C", Some("One"))); + } + + #[test] + fn insert_stop_dialog_adds_a_stop_after_the_selected_step() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + app.session.actions.list.push(Action::new("A", vec![("select.all".into(), json!({})), ("select.inverse".into(), json!({}))])); + assert!(insert_stop_dialog(&mut app).is_err(), "needs a selected action"); + app.ui.actions.selected = Some(0); + app.ui.actions.selected_step = Some(0); + let id = insert_stop_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + assert!(owns_insert_stop(&d.fields)); + assert_eq!(d.fields["at"], 1); + d.fields.insert("message".into(), json!("Check the mask")); + d.fields.insert("allowContinue".into(), json!(true)); + crate::dialogs::confirm(&mut app, id).unwrap(); + let steps = &app.session.actions.list[0].steps; + assert_eq!(steps[1], (photocraft_engine::atn_cmds::STOP_MESSAGE.to_string(), json!({"message": "Check the mask", "allowContinue": true}))); + assert_eq!((app.ui.actions.selected, app.ui.actions.selected_step), (Some(0), Some(1))); + assert_eq!(step_label(&steps[1].0, &steps[1].1), "Stop: Check the mask"); + // Without a selected step the stop goes last; the panel's menu wiring is exercised by name. + app.ui.actions.selected_step = None; + let id = insert_stop_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap(); + assert_eq!(app.ui.dialog_mut(id).unwrap().fields["at"], 3); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert_eq!(app.session.actions.list[0].steps.len(), 3); + } + + #[test] + fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut a = Action::new( + "Blur", + vec![("layer.new.layer".into(), json!({})), ("filter.blur.gaussianBlur".into(), json!({"radius": 3})), ("select.all".into(), json!({}))], + ); + a.dialogs.insert(1); + app.session.actions.list.push(a); + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + assert_eq!(v["ran"], 1, "{v}"); + assert_eq!(v["paused"]["step"], 1); + let id = v["paused"]["dialog"].as_u64().unwrap(); + let d = app.ui.dialog_mut(id).unwrap(); + assert_eq!(d.fields["radius"], 3, "the dialog opens on the recorded values"); + assert_eq!(d.fields[RESUME], json!({"action": "Blur", "from": 2})); + assert!(app.session.active().unwrap().doc.selection.is_none(), "nothing after the dialog ran yet"); + report_play(&mut app, &v); + assert!(app.ui.status.contains("dialog"), "{}", app.ui.status); + // OK runs the blur with the dialog's values and plays the rest. + app.ui.dialog_mut(id).unwrap().fields.insert("radius".into(), json!(5)); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert!(app.session.active().unwrap().doc.selection.is_some(), "the step after the dialog ran"); + assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status); + // Cancel stops the action there. + app.run("select.deselect", json!({})).unwrap(); + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_none()); + assert!(app.ui.status.contains("stopped"), "{}", app.ui.status); + // With background jobs (the desktop default) the resumed steps wait for the dialog's + // filter to land instead of meeting a locked document. + app.background_jobs = true; + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + let id = v["paused"]["dialog"].as_u64().unwrap(); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert!(app.session.active().unwrap().doc.selection.is_some(), "the step after the background blur ran"); + assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status); + app.background_jobs = false; + // Agents play headlessly: no pause, the recorded values run. + app.automation_input = true; + let v = play(&mut app, &json!({"action": "Blur"})).unwrap(); + assert_eq!(v["ran"], 3, "{v}"); + assert!(v.get("paused").is_none()); + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 1573d5ec31..73f3357a8c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -231,6 +231,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::stroke_ui::owns(&fields) => crate::stroke_ui::body(ui, &mut fields), DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), + DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields), @@ -488,6 +489,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::stroke_ui::owns(&d.fields) => crate::stroke_ui::confirm(app, &d.fields), DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields), @@ -523,6 +525,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { } if result.is_ok() { crate::filter_dialog::remember(app, &cmd, &d.fields); + // A step dialog of a playing action: the action continues after it. + crate::actions::resume(app, &d.fields); } result } @@ -534,6 +538,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { /// Cancel a dialog and its dependent Layer Style picker, without applying edits. pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?; + crate::actions::stopped(app, &closed.fields); if crate::guide_layout_ui::owns(&closed.fields) { app.ui.dialogs.retain(|child| child.fields.get("__dialog").and_then(Value::as_u64) != Some(id)); } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 889f0db89f..36fc380769 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3670,3 +3670,14 @@ navigator Zoom Zvětšení @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok zatím nemá v PhotoCraftu obdobu|{n} kroky zatím nemají v PhotoCraftu obdobu|{n} kroků zatím nemá v PhotoCraftu obdobu @plural Saved {n} action to {file}|Saved {n} actions to {file} Uložena {n} akce do souboru {file}|Uloženy {n} akce do souboru {file}|Uloženo {n} akcí do souboru {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Vynechán {n} krok bez obdoby v Photoshopu|Vynechány {n} kroky bez obdoby v Photoshopu|Vynecháno {n} kroků bez obdoby v Photoshopu + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Vložit zastavení… + Save Set… Uložit sadu… + Message: Zpráva: + Allow Continue Povolit pokračování + Show dialog during playback Zobrazit dialog při přehrávání + Select an action first Nejprve vyberte akci + Action playback stopped Přehrávání akce zastaveno + Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog +@plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fdc945a901..dc366b4553 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2702,3 +2702,14 @@ curves Smooth Glätten @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} Schritt hat noch keine Entsprechung in PhotoCraft|{n} Schritte haben noch keine Entsprechung in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} Aktion in {file} gespeichert|{n} Aktionen in {file} gespeichert @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Schritt ohne Photoshop-Entsprechung ausgelassen|{n} Schritte ohne Photoshop-Entsprechung ausgelassen + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Stopp einfügen… + Save Set… Set speichern… + Message: Meldung: + Allow Continue Fortsetzen erlauben + Show dialog during playback Dialog bei der Wiedergabe anzeigen + Select an action first Wählen Sie zuerst eine Aktion aus + Action playback stopped Aktionswiedergabe angehalten + Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog +@plural {n} action|{n} actions {n} Aktion|{n} Aktionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 037615cbe2..3f693d543f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3760,3 +3760,14 @@ curves Smooth Εξομάλυνση @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} βήμα δεν έχει ακόμη αντίστοιχο στο PhotoCraft|{n} βήματα δεν έχουν ακόμη αντίστοιχο στο PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Αποθηκεύτηκε {n} ενέργεια στο {file}|Αποθηκεύτηκαν {n} ενέργειες στο {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Παραλείφθηκε {n} βήμα χωρίς αντίστοιχο στο Photoshop|Παραλείφθηκαν {n} βήματα χωρίς αντίστοιχο στο Photoshop + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Εισαγωγή διακοπής… + Save Set… Αποθήκευση συνόλου… + Message: Μήνυμα: + Allow Continue Να επιτρέπεται η συνέχεια + Show dialog during playback Εμφάνιση του παραθύρου διαλόγου κατά την αναπαραγωγή + Select an action first Επιλέξτε πρώτα μια ενέργεια + Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε + Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του +@plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9864dd74c3..3f001c478b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2743,3 +2743,14 @@ curves Smooth Suavizar @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} paso aún no tiene equivalente en PhotoCraft|{n} pasos aún no tienen equivalente en PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Se guardó {n} acción en {file}|Se guardaron {n} acciones en {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} paso sin equivalente en Photoshop omitido|{n} pasos sin equivalente en Photoshop omitidos + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Insertar detención… + Save Set… Guardar conjunto… + Message: Mensaje: + Allow Continue Permitir continuar + Show dialog during playback Mostrar el cuadro de diálogo durante la reproducción + Select an action first Seleccione primero una acción + Action playback stopped Reproducción de la acción detenida + Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo +@plural {n} action|{n} actions {n} acción|{n} acciones diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b922ebfe37..4dd927500c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3063,3 +3063,14 @@ curves Smooth Lisser @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} étape n’a pas encore d’équivalent dans PhotoCraft|{n} étapes n’ont pas encore d’équivalent dans PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} action enregistrée dans {file}|{n} actions enregistrées dans {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} étape sans équivalent Photoshop omise|{n} étapes sans équivalent Photoshop omises + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Insérer un arrêt… + Save Set… Enregistrer l’ensemble… + Message: Message : + Allow Continue Autoriser la poursuite + Show dialog during playback Afficher la boîte de dialogue pendant l’exécution + Select an action first Sélectionnez d’abord une action + Action playback stopped Exécution de l’action arrêtée + Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue +@plural {n} action|{n} actions {n} action|{n} actions diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 10ed3d9b18..a84d19458b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3982,3 +3982,14 @@ cameraRaw Yellow Sárga @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} lépésnek még nincs megfelelője a PhotoCraftban @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} művelet mentve. Cél: {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} Photoshop-megfelelő nélküli lépés kihagyva + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Leállítás beszúrása… + Save Set… Készlet mentése… + Message: Üzenet: + Allow Continue Folytatás engedélyezése + Show dialog during playback Párbeszédablak megjelenítése lejátszáskor + Select an action first Először válasszon ki egy műveletet + Action playback stopped A művelet lejátszása leállt + Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár +@plural {n} action|{n} actions {n} művelet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5c69b98dcd..ea51c03775 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3050,3 +3050,14 @@ curves Smooth Haluskan @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} langkah belum memiliki padanan di PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} tindakan disimpan ke {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} langkah tanpa padanan Photoshop dilewati + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Sisipkan Berhenti… + Save Set… Simpan Set… + Message: Pesan: + Allow Continue Izinkan Lanjut + Show dialog during playback Tampilkan dialog saat pemutaran + Select an action first Pilih tindakan terlebih dahulu + Action playback stopped Pemutaran tindakan dihentikan + Step {step} is waiting for its dialog Langkah {step} menunggu dialognya +@plural {n} action|{n} actions {n} tindakan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index fb05ce21bd..1aa22cc2aa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4026,3 +4026,14 @@ navigator Zoom Zoom @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} passaggio non ha ancora un equivalente in PhotoCraft|{n} passaggi non hanno ancora un equivalente in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Salvata {n} azione in {file}|Salvate {n} azioni in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Omesso {n} passaggio senza equivalente in Photoshop|Omessi {n} passaggi senza equivalente in Photoshop + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Inserisci interruzione… + Save Set… Salva set… + Message: Messaggio: + Allow Continue Consenti di continuare + Show dialog during playback Mostra la finestra di dialogo durante l’esecuzione + Select an action first Seleziona prima un’azione + Action playback stopped Esecuzione dell’azione interrotta + Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo +@plural {n} action|{n} actions {n} azione|{n} azioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 219bbf59e1..40749c2d0b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3672,3 +3672,14 @@ curves Smooth スムーズ @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個のステップにはまだ PhotoCraft の対応機能がありません @plural Saved {n} action to {file}|Saved {n} actions to {file} {file} にアクション {n} 個を保存しました @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop に対応するもののない {n} 個のステップを省きました + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… ストップを挿入… + Save Set… セットを保存… + Message: メッセージ: + Allow Continue 続行を許可 + Show dialog during playback 再生中にダイアログを表示 + Select an action first 先にアクションを選択してください + Action playback stopped アクションの再生を停止しました + Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています +@plural {n} action|{n} actions アクション {n} 個 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 298e8bb3be..a93a20be59 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3232,3 +3232,14 @@ curves Smooth 매끄럽게 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n}개 단계에는 아직 PhotoCraft에 해당하는 기능이 없습니다 @plural Saved {n} action to {file}|Saved {n} actions to {file} {file}에 액션 {n}개를 저장했습니다 @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop에 해당하는 기능이 없는 {n}개 단계를 제외했습니다 + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… 정지 삽입… + Save Set… 세트 저장… + Message: 메시지: + Allow Continue 계속 허용 + Show dialog during playback 재생 중 대화 상자 표시 + Select an action first 먼저 액션을 선택하세요 + Action playback stopped 액션 재생이 중지되었습니다 + Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 +@plural {n} action|{n} actions 액션 {n}개 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index d3f81ed3c4..fc5809033c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3822,3 +3822,14 @@ curves Smooth Vloeiend maken @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} stap heeft nog geen equivalent in PhotoCraft|{n} stappen hebben nog geen equivalent in PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} handeling opgeslagen in {file}|{n} handelingen opgeslagen in {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} stap zonder Photoshop-equivalent weggelaten|{n} stappen zonder Photoshop-equivalent weggelaten + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Stop invoegen… + Save Set… Set opslaan… + Message: Bericht: + Allow Continue Doorgaan toestaan + Show dialog during playback Dialoogvenster tonen tijdens afspelen + Select an action first Selecteer eerst een handeling + Action playback stopped Afspelen van de handeling gestopt + Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster +@plural {n} action|{n} actions {n} handeling|{n} handelingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2ef524b758..9d3ecd9433 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3659,3 +3659,14 @@ curves Smooth Wygładź @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} krok nie ma jeszcze odpowiednika w PhotoCraft|{n} kroki nie mają jeszcze odpowiednika w PhotoCraft|{n} kroków nie ma jeszcze odpowiednika w PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Zapisano {n} operację do {file}|Zapisano {n} operacje do {file}|Zapisano {n} operacji do {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Pominięto {n} krok bez odpowiednika w Photoshopie|Pominięto {n} kroki bez odpowiednika w Photoshopie|Pominięto {n} kroków bez odpowiednika w Photoshopie + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Wstaw zatrzymanie… + Save Set… Zapisz zestaw… + Message: Komunikat: + Allow Continue Zezwól na kontynuację + Show dialog during playback Pokazuj okno dialogowe podczas odtwarzania + Select an action first Najpierw wybierz operację + Action playback stopped Odtwarzanie operacji zatrzymane + Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe +@plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8c9d446b2d..b26e9fd07e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3852,3 +3852,14 @@ layerLabel No Color Sem cor @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} etapa ainda não tem equivalente no PhotoCraft|{n} etapas ainda não têm equivalente no PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} ação salva em {file}|{n} ações salvas em {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out {n} etapa sem equivalente no Photoshop foi omitida|{n} etapas sem equivalente no Photoshop foram omitidas + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Inserir parada… + Save Set… Salvar conjunto… + Message: Mensagem: + Allow Continue Permitir continuar + Show dialog during playback Mostrar a caixa de diálogo durante a reprodução + Select an action first Selecione uma ação primeiro + Action playback stopped Reprodução da ação interrompida + Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo +@plural {n} action|{n} actions {n} ação|{n} ações diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b2a34e39f4..e57d0686b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3007,3 +3007,14 @@ curves Smooth Сгладить @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} шаг пока не имеет эквивалента в PhotoCraft|{n} шага пока не имеют эквивалента в PhotoCraft|{n} шагов пока не имеют эквивалента в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Сохранена {n} операция в {file}|Сохранено {n} операции в {file}|Сохранено {n} операций в {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущен {n} шаг без эквивалента в Photoshop|Пропущено {n} шага без эквивалента в Photoshop|Пропущено {n} шагов без эквивалента в Photoshop + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Вставить остановку… + Save Set… Сохранить набор… + Message: Сообщение: + Allow Continue Разрешить продолжение + Show dialog during playback Показывать диалог при воспроизведении + Select an action first Сначала выберите операцию + Action playback stopped Воспроизведение операции остановлено + Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога +@plural {n} action|{n} actions {n} операция|{n} операции|{n} операций diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1a2974a005..3f552460ca 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3794,3 +3794,14 @@ cameraData Lens Lens @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} adımın PhotoCraft’ta henüz karşılığı yok @plural Saved {n} action to {file}|Saved {n} actions to {file} {n} eylem {file} dosyasına kaydedildi @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Photoshop karşılığı olmayan {n} adım atlandı + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Durdurma Ekle… + Save Set… Seti Kaydet… + Message: İleti: + Allow Continue Devam Etmeye İzin Ver + Show dialog during playback Oynatma sırasında iletişim kutusunu göster + Select an action first Önce bir eylem seçin + Action playback stopped Eylem oynatma durduruldu + Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor +@plural {n} action|{n} actions {n} eylem diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e366b0ffe1..f7feba19a7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3760,3 +3760,14 @@ curves Smooth Згладити @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} крок ще не має відповідника в PhotoCraft|{n} кроки ще не мають відповідника в PhotoCraft|{n} кроків ще не мають відповідника в PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Збережено {n} операцію до {file}|Збережено {n} операції до {file}|Збережено {n} операцій до {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Пропущено {n} крок без відповідника в Photoshop|Пропущено {n} кроки без відповідника в Photoshop|Пропущено {n} кроків без відповідника в Photoshop + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Вставити зупинку… + Save Set… Зберегти набір… + Message: Повідомлення: + Allow Continue Дозволити продовження + Show dialog during playback Показувати діалог під час відтворення + Select an action first Спочатку виберіть операцію + Action playback stopped Відтворення операції зупинено + Step {step} is waiting for its dialog Крок {step} очікує на свій діалог +@plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6e35c4a387..890533bab2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3546,3 +3546,14 @@ curves Smooth Làm mượt @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} bước chưa có chức năng tương ứng trong PhotoCraft @plural Saved {n} action to {file}|Saved {n} actions to {file} Đã lưu {n} hành động vào {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Đã bỏ qua {n} bước không có chức năng tương ứng trong Photoshop + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… Chèn điểm dừng… + Save Set… Lưu bộ… + Message: Thông báo: + Allow Continue Cho phép tiếp tục + Show dialog during playback Hiện hộp thoại khi phát + Select an action first Hãy chọn một hành động trước + Action playback stopped Đã dừng phát hành động + Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó +@plural {n} action|{n} actions {n} hành động diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b11c51f54f..2f13bc3bfb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2667,3 +2667,14 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 个步骤在 PhotoCraft 中尚无对应功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已将 {n} 个动作存储到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已省略 {n} 个没有 Photoshop 对应功能的步骤 + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… 插入停止… + Save Set… 存储组… + Message: 信息: + Allow Continue 允许继续 + Show dialog during playback 播放时显示对话框 + Select an action first 请先选择一个动作 + Action playback stopped 动作播放已停止 + Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 +@plural {n} action|{n} actions {n} 个动作 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a079ec1e..b72aa4509b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2652,3 +2652,14 @@ curves Smooth 平滑 @plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} 個步驟在 PhotoCraft 中尚無對應功能 @plural Saved {n} action to {file}|Saved {n} actions to {file} 已將 {n} 個動作儲存到 {file} @plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out 已略過 {n} 個沒有 Photoshop 對應功能的步驟 + +# Actions panel: set folders, dialog toggle, Insert Stop + Insert Stop… 插入停止… + Save Set… 儲存組合… + Message: 訊息: + Allow Continue 允許繼續 + Show dialog during playback 播放時顯示對話框 + Select an action first 請先選取一個動作 + Action playback stopped 動作播放已停止 + Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 +@plural {n} action|{n} actions {n} 個動作 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 72d1902cf7..b775728aff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -526,6 +526,17 @@ the parent before its next step. Every nested command retains the normal automat Existing recordings containing expanded commands remain unchanged; remove those steps and record the named call again if desired. +Actions sit in **set folders** (`set` on `actions.list` / `actions.get`, filled by Load Actions, +written by Save Actions, changed with `actions.assignSet`): the panel shows loose actions first, +then each set as a collapsible folder (`ui.actions.collapsedSets`); dropping an action among +another set's rows moves it into that set. Each step has Photoshop's two toggles: the check +(`actions.toggle`, skipped when off) and the **dialog** toggle (`actions.dialog`). Playing a +step with its dialog on opens that command's dialog seeded with the recorded values and pauses: +the play result carries `paused: {step, id, dialog}`, OK runs the dialog's values and continues +from the next step, Cancel stops the action. Agents (`automation_input`) play headlessly with the +recorded values. The panel menu's **Insert Stop…** adds an `actions.stopMessage` step after the +selected step (`actions.insert` takes any command id and params). + Layer-panel **Quick Export as PNG** exports every selected layer as a separate PNG. A single Save dialog chooses the first filename; remaining files are numbered siblings with sanitized layer names. Selection is captured before opening the dialog; an unselected layer is excluded. diff --git a/docs/gaps.md b/docs/gaps.md index 38282b52f8..564138e0ae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -87,14 +87,16 @@ what an agent can't finish alone. - **Estimate:** 120–200 h. ### G11. Actions and scripting -- **Missing:** document-independent action recording (AUTO-219-1), the rest of the Actions - panel: grouping by set, an Insert Stop item and editing the dialog toggle (AUTO-219-2), Batch - recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the +- **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming + (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 90 Photoshop events map to commands both ways, Play and - Stop steps included; layer steps replay by layer name in any document (AUTO-219-2 partial). + and `actions.load`/`actions.save`; 92 Photoshop events map to commands both ways, Play and + Stop steps included; layer steps replay by layer name in any document. The panel groups actions + in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes + on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents + (AUTO-219-2 done). - **Ahead of Photoshop:** CLI, MCP and the JSON control channel drive every command. - **Estimate:** 120–200 h. **Human:** whether to support a JavaScript scripting dialect at all (AGENTS.md forbids JS in the app). diff --git a/docs/scorecard.md b/docs/scorecard.md index b30f4d9a95..31ded86f27 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -11,7 +11,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | -| [Automation](#automation) | 4 | 3 | 4 | 631 of 631 menu items live (100.0%) | +| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | | [Open bugs](#open-bugs) | 29 | 2 | 1 | | @@ -228,7 +228,7 @@ Menu wiring (`docs/parity-checklist.md`): 631 of 631 menu items live (100.0%). T |---|---|---|---|---| | AUTO-1 | Every menu item dispatches a command id (UI, CLI, control channel and MCP share them) | done | | docs/parity-checklist.md: all menu items live; menus.rs is_live | | AUTO-219-1 | Record actions in a document-independent form (implicit layer, merged type keystrokes) | missing | [#219](https://github.com/storytold/photocraft/issues/219) | actions.rs:47 copies raw journal entries with explicit layer ids | -| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | partial | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | +| AUTO-219-2 | Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import | done | [#219](https://github.com/storytold/photocraft/issues/219) | 2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle | | AUTO-219-3 | actions.record/stop/play/list commands for agents and the CLI | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions.list/get/record/stop/play/delete in actions_cmds.rs; the panel, CLI and MCP share them. Nested steps pass the session authorize hook. | | AUTO-219-4 | Replay tests for crop, filters, selections and styled text on a second document | done | [#219](https://github.com/storytold/photocraft/issues/219) | actions_cmds.rs: a_recorded_edit_run_replays_on_a_second_document records a crop, a Gaussian Blur, a selection fill and faux-bold type on one document and replays all of them onto a second (plus the original layer/select/fill replay) | | AUTO-219-5 | Batch: subfolders, open documents, naming template, progress/cancel, error log, stop | partial | [#219](https://github.com/storytold/photocraft/issues/219) | file_cmds.rs:590-631 folder/array input, per-file errors; no recursion, template or cancel | diff --git a/scorecard/automation.toml b/scorecard/automation.toml index 2d1c9421c9..0de2ab3bd2 100644 --- a/scorecard/automation.toml +++ b/scorecard/automation.toml @@ -21,9 +21,9 @@ note = "actions.rs:47 copies raw journal entries with explicit layer ids" [[item]] id = "AUTO-219-2" target = "Actions panel: step and dialog toggles, reorder, delete a step, Stop, sets, .atn import" -status = "partial" +status = "done" issue = 219 -note = "2026-10-12: Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle" +note = "2026-10-12: set folders (collapsible, drag between sets, actions.assignSet), dialog toggle column (playback opens the step's dialog seeded with its values and resumes on OK; actions.dialog), Insert Stop… (actions.insert), step toggles, reorder, delete a step; Load/Save Actions (.atn) in the panel menu, CLI batch and actions.load/save (atn_cmds.rs): 90 Photoshop events map to commands both ways, the rest are kept verbatim and fail at playback by name; step toggles, reorder and delete a step done; Stop steps play (actions.stopMessage) but there is no Insert Stop menu item; sets and dialog toggles are stored and round-trip but the panel neither groups by set nor edits the dialog toggle" [[item]] id = "AUTO-219-3" From cd36d1ea0d2ea523df4f231f08eafda73d0fe921 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:52:43 +0200 Subject: [PATCH 170/523] Delete with several layers selected deletes them, not the active layer's targeted mask (#4025) (#4078) --- crates/ui-egui/src/shortcut_dispatch.rs | 8 +++-- crates/ui-egui/src/shortcut_dispatch/tests.rs | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index b4bb1b2dc3..17607c479f 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -257,7 +257,8 @@ pub fn dispatch_pressed(app: &mut PhotocraftApp, ctx: &egui::Context, focus: Foc /// type or shape layer the key did nothing. A selection, a single channel or Quick Mask keeps Clear. /// With the active layer's mask targeted the key edits the mask, never the layer's pixels: a /// selection is filled with the background colour in the mask (Photoshop's Delete on a mask), and -/// with no selection the mask itself is deleted, as Layer › Layer Mask › Delete. +/// with no selection the mask itself is deleted, as Layer › Layer Mask › Delete, unless several +/// layers are selected: then they are deleted. fn delete_key_command(app: &PhotocraftApp, id: String) -> String { if id != "edit.clear" { return id; @@ -270,7 +271,10 @@ fn delete_key_command(app: &PhotocraftApp, id: String) -> String { let solo_mask = colors_off && has_mask && !alpha_on && st.doc.quick_mask.is_none(); let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask; let mask = (app.ui.mask_target || solo_mask || layer_mask) && has_mask; - if mask && (composite || layer_mask) { + // Several selected layers outrank the active one's targeted mask (#4025): a new adjustment + // layer targets its mask, so the first press used to delete only that mask. + let several = st.selected_layers().len() > 1; + if mask && (composite || layer_mask) && !(several && composite && st.doc.selection.is_none()) { let cmd = if st.doc.selection.is_some() { "edit.fillBackground" } else { "layer.layerMask.delete" }; return cmd.into(); } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 7d5cafae9c..71a7bc385a 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -524,6 +524,36 @@ fn delete_without_a_selection_deletes_every_selected_layer() { assert_eq!(names(&h).len(), n - 2); } +/// #4025: with several layers selected and no selection, a targeted mask on the active one doesn't +/// take the key: Delete removes the selected layers in one press, as it does with the layer +/// targeted. A selection still fills the targeted mask. +#[test] +fn delete_with_several_layers_selected_deletes_them_even_with_the_mask_targeted() { + let mut h = harness(); + put_focus(&mut h, Place::Canvas); + let s = &mut h.state_mut().session; + let first = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap()["layer"].as_u64().unwrap()); + let second = photocraft_doc::LayerId(s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layer": first.0, "mode": "add"})).unwrap(); + let st = s.active().unwrap(); + assert_eq!(st.selected_layers().len(), 2); + assert!(st.active_layer.and_then(|l| st.doc.layer(l)).is_some_and(|l| l.mask.is_some()), "the active adjustment layer has a mask"); + assert!(st.doc.selection.is_some(), "the harness keeps a selection"); + h.state_mut().ui.mask_target = true; + h.run_steps(2); + let n = names(&h).len(); + press(&mut h, "Delete"); + assert_eq!(logged(&h), ["edit.fillBackground"], "a selection edits the mask"); + assert_eq!(names(&h).len(), n); + h.state_mut().session.execute("select.deselect", json!({})).unwrap(); + h.run_steps(2); + press(&mut h, "Delete"); + assert_eq!(logged(&h), ["layer.delete"]); + let doc = &h.state().session.active().unwrap().doc; + assert!(doc.layer(first).is_none() && doc.layer(second).is_none(), "both selected layers are gone"); + assert_eq!(names(&h).len(), n - 2); +} + /// A selection keeps Edit › Clear: the selected pixels go, the layer stays. #[test] fn delete_with_a_selection_clears_it() { From 4ff2dc33cacc1036f740935353571b877069570b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 09:56:58 +1100 Subject: [PATCH 171/523] Fix multi-layer Delete and Move drags on a locked Background (#4109) * Fix multi-layer Delete and Move drags on a locked Background. Delete was removing only the active layer's targeted mask when several layers were selected. A Move drag that starts on the locked Background now draws the layer marquee instead of showing the lock dialog. * Drop the multi-layer Delete hunk: #4078 fixes #4025 This PR keeps only the Move-on-locked-Background marquee fix (#4058). Co-Authored-By: Claude Opus 5.5 * Move: a click on a locked layer still selects it before the marquee Keeps hidden_layer_tests::auto_select_skips_hidden_layers green: Auto-Select picks the visible Background under the pointer, and a drag's marquee replaces that selection on release. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 23 +++++++++++++++++++++-- crates/ui-egui/src/move_box.rs | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 804744d39d..17de8820e3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4629,10 +4629,29 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let target = app.ui.tool_options.move_target.clone(); let mode = if mods.shift { "add" } else { "replace" }; let before = app.session.active().map(|st| st.selected_layers()); + // A locked layer under the pointer (the Background) can't move. Photoshop draws + // the layer marquee instead of reporting the lock (#4058). Probe without + // selecting so a locked hit doesn't become the selection first. + let probe = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "select": false})); + let locked_hit = probe.as_ref().ok().and_then(|v| v["layer"].as_u64()).is_some_and(|id| { + app.session.active().is_some_and(|st| { + let id = photocraft_doc::LayerId(id); + let locks = st.doc.effective_locks(id); + locks.position || locks.all || st.doc.is_background(id) + }) + }); + if locked_hit || (probe.as_ref().is_ok_and(|v| v["layer"].is_null()) && crate::move_box::empty_at(app, [x, y])) { + if locked_hit { + // A click still selects the locked layer, as in Photoshop; a drag's + // marquee then replaces that selection on release. + let _ = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode})); + } + crate::move_box::begin(app, [x, y], mods.shift); + return; + } let picked = app.run("layer.pickAt", json!({"x": x, "y": y, "target": target, "mode": mode})); // No pixels there at all (an empty spot, or off the canvas): drag a marquee that - // selects the layers it touches instead (move_box.rs). A fully locked layer's - // pixels still say it is locked once the pointer moves (`move_lock`). + // selects the layers it touches instead (move_box.rs). if picked.is_ok_and(|v| v["layer"].is_null()) && crate::move_box::empty_at(app, [x, y]) { crate::move_box::begin(app, [x, y], mods.shift); return; diff --git a/crates/ui-egui/src/move_box.rs b/crates/ui-egui/src/move_box.rs index f826992a33..ccbfd9cb4f 100644 --- a/crates/ui-egui/src/move_box.rs +++ b/crates/ui-egui/src/move_box.rs @@ -114,4 +114,24 @@ mod tests { sel.sort_unstable_by_key(|l| l.0); assert_eq!(sel, ids); } + + /// #4058: a drag that starts on the locked Background draws the layer marquee. It does not + /// open the move-lock dialog, and releasing selects the unlocked layer the box touches. + #[test] + fn a_drag_on_the_locked_background_selects_instead_of_erroring() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 80, "height": 60})).unwrap(); + app.sync_views(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("select.rect", json!({"x": 40, "y": 8, "width": 16, "height": 16})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); + let paint = app.session.active().unwrap().active_layer.unwrap(); + app.ui.tool = Tool::Move; + app.ui.tool_options.move_auto_select = true; + drag(&mut app, [8.0, 8.0], [50.0, 20.0], egui::Modifiers::NONE); + assert!(app.ui.dialogs.iter().all(|d| d.kind != crate::state::DialogKind::Error), "no lock dialog"); + assert!(app.layer_box.is_none()); + assert_eq!(app.session.active().unwrap().selected_layers(), vec![paint]); + } } From bbfaefd5f03c681e1d14d63a76226122627043e3 Mon Sep 17 00:00:00 2001 From: Erica Franco Date: Mon, 12 Oct 2026 17:00:03 -0600 Subject: [PATCH 172/523] File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preference was stored but read nowhere, so Edit › Preferences hid it. Never now writes Photoshop's white placeholder instead of the merged image and records has_real_merged_data = false in resource 1057, without rendering the composite: smaller files and faster saves. Always and Ask When Saving keep the real merged image, as Image Previews already does with Ask. A file without layer records always keeps its real merged image, and layered TIFF still renders the composite it is made of. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 1 + apps/photocraft/src/services.rs | 1 + crates/engine/src/file_cmds.rs | 28 +++-- crates/engine/src/file_cmds/tests.rs | 20 +++ crates/engine/src/prefs.rs | 1 - crates/io/src/lib.rs | 16 ++- crates/io/src/psd_export.rs | 59 +++++++-- crates/io/src/tiff_layers.rs | 3 +- crates/io/tests/psd_maximize_compatibility.rs | 115 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 29 +++-- crates/ui-egui/src/save_identity_tests.rs | 23 ++++ docs/scorecard.md | 6 +- 12 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 crates/io/tests/psd_maximize_compatibility.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 6c9939607d..16781e42bb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -347,6 +347,7 @@ fn services(inbox: Inbox) -> Services { opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()) })), file_dialog: Some(Box::new(|request, _parent, reply| match request { diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f6695717be..fea0d34541 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -217,6 +217,7 @@ fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_i opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None }; opts.tga_bits = settings.tga_bits; opts.image_previews = settings.image_previews; + opts.psd_maximize_compatibility = settings.psd_maximize_compatibility; opts } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 5aa2441a1f..74069c8b8c 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -218,8 +218,8 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result { Ok(r.document) } -/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits and PSD -/// previews. +/// What a headless save writes beyond the format: JPEG quality, TIFF layers, Targa bits, PSD +/// previews and the PSD merged image. #[derive(Debug, Clone, Copy)] pub(crate) struct SaveOpts { /// Photoshop's 0–12 JPEG scale. @@ -231,25 +231,30 @@ pub(crate) struct SaveOpts { pub tga_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility). + pub psd_maximize_compatibility: bool, } impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } } } impl SaveOpts { /// `quality`, `tiffLayers` and `tgaBits` from command `cmd`'s params (a `tgaBits` other than - /// 24 or 32 is an error); the image previews from the session's File Handling preferences - /// (Ask When Saving has no dialog here, so it saves them, as Photoshop's Save As checkbox - /// starts on). + /// 24 or 32 is an error); the image previews and the PSD merged image from the session's File + /// Handling preferences (Ask When Saving has no dialog here, so it saves them, as Photoshop's + /// Save As checkbox and its Maximize Compatibility checkbox both start on). pub(crate) fn from_params(s: &Session, p: &Value, cmd: &str) -> Result { + let file_handling = &s.prefs().file_handling; Ok(SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, - image_previews: s.prefs().file_handling.image_previews != crate::prefs::Ask::Never, + image_previews: file_handling.image_previews != crate::prefs::Ask::Never, + psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) } @@ -283,8 +288,13 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; let save = save.into(); - let mut opts = - photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, tga_bits: save.tga_bits, image_previews: save.image_previews, ..Default::default() }; + let mut opts = photocraft_io::ExportOptions { + tiff_layers: save.tiff_layers, + tga_bits: save.tga_bits, + image_previews: save.image_previews, + psd_maximize_compatibility: save.psd_maximize_compatibility, + ..Default::default() + }; // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index a4100a138d..8cab1ce635 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -192,6 +192,26 @@ fn image_previews_preference_controls_the_psd_thumbnail() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility decides whether a +/// layered PSD keeps its real merged image: Always and Ask When Saving write it, Never writes +/// Photoshop's placeholder and says so in resource 1057. +#[test] +fn maximize_compatibility_preference_controls_the_psd_merged_image() { + use crate::prefs::Ask; + let dir = tmp("maximize"); + let mut s = session(300, 200, 16); + s.execute("layer.new.layer", json!({})).unwrap(); + let real_merged = |path: &str| photocraft_psd::PsdFile::from_bytes(&std::fs::read(path).unwrap()).unwrap().has_real_merged_data(); + for (ask, expect) in [(Ask::Always, true), (Ask::Never, false), (Ask::Ask, true)] { + s.edit_prefs(|p| p.file_handling.maximize_psd_compatibility = ask); + for ext in ["psd", "psb"] { + let out = join(&dir, &format!("{ask:?}.{ext}")); + s.execute("file.saveACopy", json!({"path": out})).unwrap(); + assert_eq!(real_merged(&out), Some(expect), "{ask:?} {ext}"); + } + } +} + #[test] fn open_as_forces_the_decoder() { let dir = tmp("openas"); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 074835fd03..3a8b7ce695 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -1054,7 +1054,6 @@ pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[ "workspace.autoShowHiddenPanels", "workspace.enableFloatingDocumentWindowDocking", "fileHandling.saveInBackground", - "fileHandling.maximizePsdCompatibility", "performance.cacheLevels", "performance.effectCacheMb", "performance.legacyCompositing", diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index c387dc4e30..6d33c332a0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -177,11 +177,23 @@ pub struct ExportOptions { /// PSD/PSB: embed the thumbnail image resource (1036), Preferences › File Handling › Image /// Previews. On by default, as Photoshop's Always Save. pub image_previews: bool, + /// PSD/PSB: write the real merged image, Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility (see [`PsdExportOptions::maximize_compatibility`]). On by default, as + /// Photoshop's Always. + pub psd_maximize_compatibility: bool, } impl Default for ExportOptions { fn default() -> Self { - ExportOptions { encode: EncodeOptions::default(), force_psb: false, tiff_layers: false, xmp: XmpEmbed::All, tga_bits: None, image_previews: true } + ExportOptions { + encode: EncodeOptions::default(), + force_psb: false, + tiff_layers: false, + xmp: XmpEmbed::All, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -382,7 +394,7 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & return Ok(ExportResult { bytes: photocraft_format::save_to_bytes(doc.as_ref(), &previews)?, warnings: Vec::new() }); } if ext == "psd" || ext == "psb" { - let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; + let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", maximize_compatibility: opts.psd_maximize_compatibility, ..Default::default() }; let (mut file, mut warnings) = with_span(ctl, 0.0, PSD_MODEL_DONE, |ctl| psd_export::document_to_psd_interruptible(doc, &o, ctl)); // A cancelled conversion stops early with an incomplete model: never write it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 36f5941f78..38db080873 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -35,11 +35,17 @@ pub struct PsdExportOptions { pub merged_matte: bool, /// Compression of the merged image (32-bit float documents are always written raw). pub merged_compression: Compression, + /// Write the real merged image (Preferences › File Handling › Maximize PSD and PSB File + /// Compatibility). `false` writes Photoshop's white placeholder instead and records + /// `has_real_merged_data = false` in resource 1057: a smaller file and a faster save, but + /// readers that only show the merged image see white. A document without layer records always + /// gets the real image, since its pixels live nowhere else. + pub maximize_compatibility: bool, } impl Default for PsdExportOptions { fn default() -> Self { - PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle } + PsdExportOptions { force_psb: false, merged_matte: true, merged_compression: Compression::Rle, maximize_compatibility: true } } } @@ -1227,6 +1233,28 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool, ctl (planes, has_alpha, translucent) } +/// The merged image of a file saved without Maximize Compatibility: Photoshop's white placeholder +/// in the document's colour channels, with no alpha. Encoded like [`merged_planes`], so CMYK white +/// is no ink and Lab white is L 100 with neutral a*/b*. +fn placeholder_planes(doc: &Document, fmt: &PixelFormat) -> Vec { + let cc = fmt.mode.color_channels(); + let cmyk = cmyk_of(fmt); + let lab16 = fmt.mode == ColorMode::Lab && fmt.sample == SampleType::U16; + let space = photocraft_compose::cmyk_space(doc); + let white = photocraft_color::convert::with_cmyk_space(space.as_ref(), || photocraft_raster::from_rgba(fmt, [1.0, 1.0, 1.0, 1.0])); + let n = doc.size.area() as usize; + let mut planes = Vec::with_capacity(n * fmt.sample.bytes() * cc); + for c in 0..cc { + let v = white.get(c).copied().unwrap_or(1.0); + let x = if cmyk { 1.0 - v } else { v }; + let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x }; + let mut sample = Vec::with_capacity(4); + encode_be(x, fmt.sample, &mut sample); + planes.extend(sample.iter().copied().cycle().take(n * sample.len())); + } + planes +} + /// Converts a document to a PSD file model (see [`document_to_psd_with`]). pub fn document_to_psd(doc: &Document) -> PsdFile { document_to_psd_with(doc, &PsdExportOptions::default()).0 @@ -1470,15 +1498,24 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c let space = photocraft_compose::cmyk_space(doc); photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers, &mut tick)); - // Merged composite, rendered and encoded in bands (no full-size float composite). Matting - // against white only changes pixels with alpha < 1; if some are slightly translucent but all - // round to opaque (so no alpha channel is written), encode once more without the matte. - let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; - let (mut planes, has_alpha, translucent) = - crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); - if !has_alpha && translucent && opts.merged_matte { - planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; - } + // Without Maximize Compatibility the merged image is a white placeholder and the composite + // isn't rendered at all. A document without layer records keeps the real image: its pixels + // live nowhere else. + let real_merged = opts.maximize_compatibility || ex.records.is_empty(); + let (mut planes, has_alpha) = if real_merged { + // Merged composite, rendered and encoded in bands (no full-size float composite). Matting + // against white only changes pixels with alpha < 1; if some are slightly translucent but + // all round to opaque (so no alpha channel is written), encode once more without the matte. + let merged_end = layers_share + (1.0 - layers_share) * MERGED_RENDERED; + let (mut planes, has_alpha, translucent) = + crate::with_span(ctl, layers_share, merged_end, |ctl| merged_planes(doc, &fmt, cmyk_of(&fmt), opts.merged_matte, ctl)); + if !has_alpha && translucent && opts.merged_matte { + planes = merged_planes(doc, &fmt, cmyk_of(&fmt), false, &Interrupt::cancel_only(&|| ctl.cancelled())).0; + } + (planes, has_alpha) + } else { + (placeholder_planes(doc, &fmt), false) + }; let n = doc.size.area() as usize; if !has_alpha { planes.truncate(cc * n * sample.bytes()); @@ -1623,7 +1660,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c if let Some(Some(data)) = slices_resource { resources.push(ImageResource::new(crate::slices_map::SLICES, data)); } - resources.push(version_info_resource(true)); + resources.push(version_info_resource(real_merged)); let has_layers = !ex.records.is_empty(); let placement = match (has_layers, sample) { diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 3142bc1a8c..0aca0fe882 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -201,7 +201,8 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions, ctl: &photocr let big = doc.size.width > 30_000 || doc.size.height > 30_000; // The PSD exporter renders the composite we need for the TIFF itself: unmatted (the TIFF // keeps straight alpha) and uncompressed (its planes are interleaved below). - let psd_opts = PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw }; + let psd_opts = + PsdExportOptions { force_psb: opts.force_psb || big, merged_matte: false, merged_compression: Compression::Raw, maximize_compatibility: true }; let (file, w) = crate::with_span(ctl, 0.0, TIFF_MODEL_DONE, |ctl| crate::psd_export::document_to_psd_interruptible(doc, &psd_opts, ctl)); // A cancelled conversion stops early with an incomplete model: never encode it. ctl.check().map_err(|_| IoError::Cancelled)?; diff --git a/crates/io/tests/psd_maximize_compatibility.rs b/crates/io/tests/psd_maximize_compatibility.rs new file mode 100644 index 0000000000..c8f277f6ee --- /dev/null +++ b/crates/io/tests/psd_maximize_compatibility.rs @@ -0,0 +1,115 @@ +//! Preferences › File Handling › Maximize PSD and PSB File Compatibility: turned off, a layered +//! PSD/PSB stores Photoshop's white placeholder instead of the merged image and says so in +//! resource 1057 (`has_real_merged_data = false`). Layers, alpha channels and the Quick Mask are +//! written as usual, so the file reopens the same. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{AlphaChannel, Document, Layer}; +use photocraft_geom::{Rect, Size}; +use photocraft_io::{ExportOptions, export, import}; +use photocraft_psd::PsdFile; +use photocraft_raster::Surface; + +fn compat_off() -> ExportOptions { + ExportOptions { psd_maximize_compatibility: false, ..Default::default() } +} + +fn parse(bytes: &[u8]) -> PsdFile { + PsdFile::from_bytes(bytes).expect("parse") +} + +/// A `w`×`h` document of `mode`/`depth` holding one layer of `rgba` over the whole canvas. +fn filled(mode: ColorMode, depth: SampleType, w: u32, h: u32, rgba: [f32; 4]) -> Document { + let mut d = Document::new("compat", Size::new(w, h), mode, depth); + let fmt = d.pixel_format(); + let mut px = vec![0.0; fmt.channels()]; + photocraft_raster::from_rgba_into(&fmt, rgba, &mut px); + let mut layer = Layer::raster("pixels", fmt); + layer.surface_mut().unwrap().fill_rect(Rect::new(0, 0, w as i32, h as i32), &px); + d.layers.push(layer); + d +} + +/// An 8-bit RGB document whose layer is seeded noise, alpha included: its merged image barely +/// compresses, unlike the placeholder. +fn noisy(w: u32, h: u32) -> Document { + let mut d = Document::new("noise", Size::new(w, h), ColorMode::Rgb, SampleType::U8); + let fmt = d.pixel_format(); + let mut state = 0x2545_f491_u32; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state & 0xff) as f32 / 255.0 + }; + let vals: Vec = (0..(w * h) as usize * fmt.channels()).map(|_| next()).collect(); + let mut layer = Layer::raster("noise", fmt); + layer.surface_mut().unwrap().write_region(Rect::new(0, 0, w as i32, h as i32), &vals); + d.layers.push(layer); + d +} + +#[test] +fn off_writes_the_white_placeholder_and_flags_it() { + let d = noisy(96, 64); + for name in ["x.psd", "x.psb"] { + let no_previews = |o: ExportOptions| ExportOptions { image_previews: false, ..o }; + let on = export(&d, name, &no_previews(ExportOptions::default())).unwrap(); + let off = export(&d, name, &no_previews(compat_off())).unwrap(); + let (real, placeholder) = (parse(&on.bytes), parse(&off.bytes)); + assert_eq!(real.has_real_merged_data(), Some(true), "{name}"); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{name}"); + assert!(real.merged_has_alpha() && !placeholder.merged_has_alpha(), "{name}: the placeholder has no alpha"); + assert!(placeholder.composite_rgba8().unwrap().data.iter().all(|&b| b == 255), "{name}: opaque white"); + assert_eq!(real.layers(), placeholder.layers(), "{name}: the layers are written the same"); + assert!(off.bytes.len() * 3 < on.bytes.len() * 2, "{name}: {} bytes without the merged image, {} with it", off.bytes.len(), on.bytes.len()); + } +} + +/// The placeholder is exactly the white a real merged image would store, in every colour model +/// and depth PSD has: CMYK white is no ink, Lab white is L 100 with neutral a*/b*. +#[test] +fn the_placeholder_is_the_white_a_real_merged_image_stores() { + use ColorMode::{Cmyk, Grayscale, Lab, Rgb}; + use SampleType::{F32, U8, U16}; + for (mode, depth) in + [(Rgb, U8), (Rgb, U16), (Rgb, F32), (Grayscale, U8), (Grayscale, U16), (Grayscale, F32), (Cmyk, U8), (Cmyk, U16), (Lab, U8), (Lab, U16)] + { + let d = filled(mode, depth, 9, 5, [1.0; 4]); + let real = parse(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes); + let placeholder = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert_eq!(placeholder.has_real_merged_data(), Some(false), "{mode:?} {depth:?}"); + assert!(!real.merged_has_alpha(), "{mode:?} {depth:?}: an opaque document"); + assert_eq!(placeholder.header, real.header, "{mode:?} {depth:?}"); + assert_eq!(placeholder.decode_merged().unwrap(), real.decode_merged().unwrap(), "{mode:?} {depth:?}"); + } +} + +#[test] +fn layers_and_alpha_channels_reopen_unchanged() { + let mut d = noisy(32, 16); + let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false)); + selection.fill_rect(Rect::new(0, 0, 10, 16), &[1.0]); + d.channels.push(AlphaChannel::new("Alpha 1", selection.clone())); + d.quick_mask = Some(AlphaChannel::new("Quick Mask", selection)); + let reopen = |opts: &ExportOptions| { + let r = import("x.psd", &export(&d, "x.psd", opts).unwrap().bytes).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + r.document + }; + let (on, off) = (reopen(&ExportOptions::default()), reopen(&compat_off())); + assert_eq!(off.layers.len(), 1); + assert_eq!(off.channels, on.channels, "alpha channels follow the placeholder"); + assert_eq!(off.quick_mask, on.quick_mask); + let canvas = d.bounds(); + assert_eq!(photocraft_compose::render(&off, canvas).px, photocraft_compose::render(&on, canvas).px, "the same image"); +} + +/// Without layer records the merged image is the only copy of the pixels, so it stays real. +#[test] +fn a_file_without_layers_keeps_its_real_merged_image() { + let d = Document::new("empty", Size::new(8, 8), ColorMode::Rgb, SampleType::U8); + let file = parse(&export(&d, "x.psd", &compat_off()).unwrap().bytes); + assert!(file.layers().is_empty()); + assert_eq!(file.has_real_merged_data(), Some(true)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5e54fd2748..1092f4081f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,11 +250,23 @@ pub struct ExportSettings { /// PSD/PSB: embed a thumbnail (Preferences › File Handling › Image Previews; see /// [`PhotocraftApp::save_settings`]). pub image_previews: bool, + /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB + /// File Compatibility; see [`PhotocraftApp::save_settings`]). + pub psd_maximize_compatibility: bool, } impl Default for ExportSettings { fn default() -> Self { - ExportSettings { jpeg_quality: None, webp_lossless: true, webp_quality: None, tiff_layers: true, xmp_all: true, tga_bits: None, image_previews: true } + ExportSettings { + jpeg_quality: None, + webp_lossless: true, + webp_quality: None, + tiff_layers: true, + xmp_all: true, + tga_bits: None, + image_previews: true, + psd_maximize_compatibility: true, + } } } @@ -1315,13 +1327,16 @@ impl PhotocraftApp { } /// What a save writes by default under the preferences: File Handling › Image Previews - /// decides whether a PSD/PSB gets its thumbnail. Ask When Saving has no Save As checkbox - /// here yet, so it saves the preview, as Photoshop's checkbox starts on. A JPEG or WebP - /// is written the way the active document's file was (its JPEG quality, a WebP lossy at - /// its quality or lossless), as Photoshop's Save and Save As options start there. + /// decides whether a PSD/PSB gets its thumbnail, and Maximize PSD and PSB File Compatibility + /// whether it gets its real merged image. Ask When Saving has no dialog here yet, so it saves + /// both, as Photoshop's checkboxes start on. A JPEG or WebP is written the way the active + /// document's file was (its JPEG quality, a WebP lossy at its quality or lossless), as + /// Photoshop's Save and Save As options start there. pub(crate) fn save_settings(&self) -> ExportSettings { - let previews = self.session.prefs().file_handling.image_previews != photocraft_engine::prefs::Ask::Never; - let mut settings = ExportSettings { image_previews: previews, ..ExportSettings::default() }; + let file_handling = &self.session.prefs().file_handling; + let previews = file_handling.image_previews != photocraft_engine::prefs::Ask::Never; + let maximize = file_handling.maximize_psd_compatibility != photocraft_engine::prefs::Ask::Never; + let mut settings = ExportSettings { image_previews: previews, psd_maximize_compatibility: maximize, ..ExportSettings::default() }; if let Some(st) = self.session.active() { let e = &st.doc.file_encoding; settings.jpeg_quality = e.jpeg_quality.map(|q| q.clamp(1, 100)); diff --git a/crates/ui-egui/src/save_identity_tests.rs b/crates/ui-egui/src/save_identity_tests.rs index c0cd85eaed..c947afc9b6 100644 --- a/crates/ui-egui/src/save_identity_tests.rs +++ b/crates/ui-egui/src/save_identity_tests.rs @@ -205,6 +205,29 @@ fn image_previews_preference_reaches_the_exporter() { } } +/// Preferences › File Handling › Maximize PSD and PSB File Compatibility reaches the exporter of +/// Save As, Save and automation saves: Always and Ask When Saving keep the PSD's real merged +/// image, Never doesn't. +#[test] +fn maximize_compatibility_preference_reaches_the_exporter() { + let seen = Rc::new(RefCell::new(Vec::new())); + let mut app = app(); + let log = seen.clone(); + app.services.export = Some(Box::new(move |_, _, settings: &ExportSettings| { + log.borrow_mut().push(settings.psd_maximize_compatibility); + Ok((b"out".to_vec(), Vec::new())) + })); + let ctx = egui::Context::default(); + for (value, expect) in [("always", true), ("never", false), ("ask", true)] { + app.run("prefs.set", json!({"path": "fileHandling.maximizePsdCompatibility", "value": value})).unwrap(); + seen.borrow_mut().clear(); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": format!("{value}.psd")})).unwrap(); + menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap(); + app.save_automation(Some(format!("{value} agent.psd"))).unwrap(); + assert_eq!(*seen.borrow(), [expect; 3], "{value}"); + } +} + /// #2399: Save and Save As start at the opened file's own encoding: a JPEG's estimated quality, /// a WebP lossy (at its quality) or lossless; a new document keeps the codec defaults. #[test] diff --git a/docs/scorecard.md b/docs/scorecard.md index 31ded86f27..d2ee641b1e 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 27 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -176,9 +176,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t ## Workspace and UI -**Settings that do nothing: 27 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. +**Settings that do nothing: 26 of 156** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't. -Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `file_handling.maximize_psd_compatibility`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. +Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `enhanced_controls.touch_gestures`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`. | Id | Target | Status | Issue | Note | |---|---|---|---|---| From 7015f2916db4abaa9379216af5548152e5739545 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:01:24 +0200 Subject: [PATCH 173/523] View: Esc leaves full screen modes (part of #4116) (#4145) Esc returned to Standard only from Full Screen Mode, and was consumed in the UI pass before tools, dialogs and popups could see it. Handle it in the shortcut chain instead, after everything that owns Esc, for both full screen modes, via view.screenMode.standard. --- crates/ui-egui/src/lib.rs | 3 -- crates/ui-egui/src/shortcuts.rs | 25 +++++++++------ crates/ui-egui/src/view_cmds/tests.rs | 44 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1092f4081f..a144477cdb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1710,9 +1710,6 @@ impl eframe::App for PhotocraftApp { // system title bar this is where the document name lives, as the in-app title is hidden. panels::sync_window_title(self, &ctx); let chrome = !self.ui.view.hides_chrome(); - if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({})); - } if chrome && self.ui.panels.menu_bar { panels::title_bar(self, ui); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7c0c8145f6..a156a6ce3b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -531,15 +531,13 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { return; } } - // Esc with no active operation deselects (#2674), like ⌘D. The key is only taken when a - // selection exists, and never from a state that handles Esc later in the frame: menus, - // dialogs and inline type returned above, transform, pen, lasso and crop consumed it, and - // these own theirs: full screen (Esc returns), the palette, the canvas and layer menus, the - // brush picker, Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any - // open egui popup and the toolbar tool flyout. A canvas drag in progress (a marquee being - // drawn) keeps the selection too. - let esc_taken = app.ui.view.hides_chrome() - || app.drag.is_some() + // Esc with no active operation leaves a full screen mode, else deselects (#2674), like ⌘D. + // The key is never taken from a state that handles Esc itself: menus, dialogs, text fields + // and inline type returned above, transform, pen, lasso and crop consumed it, and these own + // theirs later in the frame: the palette, the canvas and layer menus, the brush picker, + // Rotate View, Adaptive Wide Angle, the Filter Gallery, the TIFF prompt, any open egui popup + // and the toolbar tool flyout. A canvas drag in progress (a marquee being drawn) keeps it too. + let esc_taken = app.drag.is_some() || app.ui.palette_open || app.ui.canvas_tool_menu.is_some() || app.ui.layer_menu.is_some() @@ -550,6 +548,15 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { || app.distort.gallery.is_some() || ctx.any_popup_open() || ctx.data(|d| d.get_temp::<(egui::Id, egui::Rect)>(egui::Id::new("tool-flyout")).is_some()); + // Both full screen modes (with and without the menu bar) return to Standard, as in Photoshop + // (#4116), through the menu command so the window and the Screen Mode check follow. + if !esc_taken && app.ui.view.screen_mode != "standard" && pressed(Key::Escape) { + if let Err(e) = crate::menus::invoke(app, ctx, "view.screenMode.standard", json!({})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } if !esc_taken && app.session.active().is_some_and(|st| st.doc.selection.is_some()) && pressed(Key::Escape) { if let Err(e) = app.run("select.deselect", json!({})) { app.ui.status = e; diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 34c93ccc25..3de1a20b4b 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -369,3 +369,47 @@ fn load_selection_without_alpha_channels_offers_the_composite() { assert_eq!(app.ui.dialog_mut(dialog).unwrap().fields["channel"], "RGB"); crate::dialogs::confirm(&mut app, dialog).unwrap(); } + +/// One frame of the shortcut handler with a plain Esc press. +fn press_esc(app: &mut PhotocraftApp, ctx: &egui::Context) { + let key = egui::Key::Escape; + let input = egui::RawInput { + focused: true, + events: vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ..Default::default() + }; + ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn esc_leaves_full_screen_modes_unless_something_else_owns_it() { + let (mut app, ctx) = app_with(1); + // Both full screen modes return to Standard, and the menu check follows (#4116). + for mode in ["fullScreen", "fullScreenWithMenuBar"] { + menu(&mut app, &ctx, &format!("view.screenMode.{mode}"), json!({})).unwrap(); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard", "{mode}"); + assert_eq!(checked(&app, "view.screenMode.standard"), Some(true)); + } + // In Standard, Esc leaves the mode alone. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + // An open dialog takes Esc first. + menu(&mut app, &ctx, "view.screenMode.fullScreen", json!({})).unwrap(); + menu(&mut app, &ctx, "image.canvasSize", json!({})).unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + app.ui.dialogs.clear(); + // So does Free Transform: Esc cancels it and the mode stays. + app.run("select.all", json!({})).unwrap(); + crate::transform_tool::begin(&mut app, &ctx).unwrap(); + assert!(app.ui.transform.is_some()); + press_esc(&mut app, &ctx); + assert!(app.ui.transform.is_none()); + assert_eq!(app.ui.view.screen_mode, "fullScreen"); + // A selection stays while Esc leaves full screen; the next Esc deselects. + press_esc(&mut app, &ctx); + assert_eq!(app.ui.view.screen_mode, "standard"); + assert!(app.session.active().unwrap().doc.selection.is_some()); +} From 13f5c104f6e784d32b74dd4f078efa7cf4c5226f Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:02:15 -0500 Subject: [PATCH 174/523] Map the remaining frequent Photoshop action events (#219) (#4465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame centre), as a Photoshop action records a Free Transform; image.applyImage takes a source layer by name. - .atn events: transform (layer → translate or relative transform; selection → transformSelection), applyImageEvent, export (Save for Web, with the txtu path record decoded and written back; kept verbatim without a destination), newPlacedLayer / placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close, delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections → select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events. - Export tries every event listing a command id (Quick Mask on → set, off → clearEvent). - Docs: event counts in the parity docs. --- crates/engine/src/atn_cmds.rs | 420 +++++++++++++++++++++++++++- crates/engine/src/atn_cmds/tests.rs | 192 +++++++++++++ crates/engine/src/channel_cmds.rs | 21 +- crates/engine/src/transform_cmds.rs | 59 +++- docs/file-format-parity.md | 2 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 6 +- 7 files changed, 689 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 4e5de06970..fa9e649c0b 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -270,6 +270,31 @@ fn color_desc(hex: &str) -> Option { Some(desc("RGBC").with("Rd ", D::Double(ch(0)?)).with("Grn ", D::Double(ch(2)?)).with("Bl ", D::Double(ch(4)?))) } +/// A `Pth ` value's text: Photoshop stores `"txtu"`, a little-endian total length, a +/// little-endian UTF-16 unit count and the units (NUL-terminated). +fn path_text(v: &D) -> Option { + let D::Path(b) = v else { return None }; + if b.get(0..4)? != b"txtu" { + return None; + } + let n = u32::from_le_bytes(b.get(8..12)?.try_into().ok()?) as usize; + let units: Vec = b.get(12..12usize.checked_add(n.checked_mul(2)?)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + let text = String::from_utf16_lossy(&units); + let text = text.trim_end_matches('\0'); + (!text.is_empty()).then(|| text.to_string()) +} + +fn path_value(text: &str) -> D { + let units: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let mut b = b"txtu".to_vec(); + b.extend_from_slice(&((12 + units.len() * 2) as u32).to_le_bytes()); + b.extend_from_slice(&(units.len() as u32).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + D::Path(b) +} + /// Photoshop's `BlnM` enumeration ↔ PhotoCraft's blend mode labels. const BLEND_MODES: &[(&str, &str)] = &[ ("Nrml", "Normal"), @@ -713,6 +738,60 @@ fn selective_to(p: &Value) -> Option { ) } +/// Gradient Map: the gradient's colour stops at 0..4096 → `stops: [[0..1, "#rrggbb"], …]`. +fn gradient_map_from(d: &Descriptor) -> Option { + let grad = get_obj(d, "Grad")?; + let stops: Option> = get_list(grad, "Clrs")? + .iter() + .map(|v| match v { + D::Descriptor(stop) => Some(json!([(get_num(stop, "Lctn")? / 4096.0).clamp(0.0, 1.0), color_hex(get_obj(stop, "Clr ")?)?])), + _ => None, + }) + .collect(); + let mut out = json!({"stops": stops?}); + if let Some(r) = get_bool(d, "Rvrs") { + out["reverse"] = json!(r); + } + if let Some(dd) = get_bool(d, "Dthr") { + out["dither"] = json!(dd); + } + Some(out) +} + +fn gradient_map_to(p: &Value) -> Option { + let stops: Option> = p + .get("stops")? + .as_array()? + .iter() + .map(|st| { + let a = st.as_array()?; + Some(D::Descriptor( + desc("Clrt") + .with("Clr ", D::Descriptor(color_desc(a.get(1)?.as_str()?)?)) + .with("Type", enm("Clry", "UsrS")) + .with("Lctn", long((a.first()?.as_f64()? * 4096.0).round())) + .with("Mdpn", long(50.0)), + )) + }) + .collect(); + let transparency = D::List(vec![ + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(0.0)).with("Mdpn", long(50.0))), + D::Descriptor(desc("TrnS").with("Opct", pct(100.0)).with("Lctn", long(4096.0)).with("Mdpn", long(50.0))), + ]); + let grad = desc("Grdn") + .with("Nm ", text("Custom")) + .with("GrdF", enm("GrdF", "CstS")) + .with("Intr", D::Double(4096.0)) + .with("Clrs", D::List(stops?)) + .with("Trns", transparency); + Some( + desc("GdMp") + .with("Grad", D::Descriptor(grad)) + .with("Rvrs", D::Boolean(jbool(p, "reverse").unwrap_or(false))) + .with("Dthr", D::Boolean(jbool(p, "dither").unwrap_or(false))), + ) +} + /// An adjustment: its Photoshop class (also the direct event's code), the command suffix (`image.adjustments.`, `layer.newAdjustmentLayer.`), and /// the converters between the adjustment descriptor and the command params. struct Adjustment { @@ -743,6 +822,7 @@ const ADJUSTMENTS: &[Adjustment] = &[ adj_fields!("BlnW", "blackWhite", BW), Adjustment { class: "ChnM", suffix: "channelMixer", from: channel_mixer_from, to: channel_mixer_to }, Adjustment { class: "SlcC", suffix: "selectiveColor", from: selective_from, to: selective_to }, + Adjustment { class: "GdMp", suffix: "gradientMap", from: gradient_map_from, to: gradient_map_to }, ]; fn adjustment_by_class(c: &str) -> Option<&'static Adjustment> { @@ -1028,6 +1108,20 @@ fn set_from(d: &Descriptor) -> Mapped { (Some("Chnl"), Target::Name(n)) => step("select.loadSelection", json!({"channel": n})), _ => unsupported, }, + Some(D::Descriptor(t)) if t.class_id.is("Plgn") => { + let pts: Option> = get_list(t, "Pts ") + .unwrap_or(&[]) + .iter() + .map(|v| match v { + D::Descriptor(pt) => Some(json!([get_num(pt, "Hrzn")?, get_num(pt, "Vrtc")?])), + _ => None, + }) + .collect(); + match pts { + Some(pts) if pts.len() >= 3 => step("select.lasso", json!({"points": pts})), + _ => unsupported, + } + } Some(D::Descriptor(t)) if t.class_id.is("Rctn") || t.class_id.is("Elps") => { let (Some(top), Some(left), Some(bottom), Some(right)) = (get_num(t, "Top "), get_num(t, "Left"), get_num(t, "Btom"), get_num(t, "Rght")) else { @@ -1041,6 +1135,8 @@ fn set_from(d: &Descriptor) -> Mapped { } _ => unsupported, }, + // Quick Mask mode on (`set` of the document's quick-mask property); `clearEvent` turns it off. + (Some("Prpr"), Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": true})), (Some("Lyr"), _) => { let Some(mut p) = layer_target(r) else { return unsupported }; let Some(t) = get_obj(d, "T ") else { return unsupported }; @@ -1095,6 +1191,24 @@ fn set_to(id: &str, p: &Value) -> Option { "transparency" => sel(D::Reference(vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("Trsp") }])), name => sel(name_ref("Chnl", name)), }, + "select.lasso" => { + let pts: Option> = p + .get("points")? + .as_array()? + .iter() + .map(|pt| { + let a = pt.as_array()?; + Some(D::Descriptor(desc("Pnt ").with("Hrzn", px(a.first()?.as_f64()?)).with("Vrtc", px(a.get(1)?.as_f64()?)))) + }) + .collect(); + sel(D::Descriptor(desc("Plgn").with("Pts ", D::List(pts?)))) + } + "select.editInQuickMaskMode" => { + if jbool(p, "on") != Some(true) { + return None; + } + desc("null").with("null", quick_mask_ref()) + } "select.rect" => { let (x, y, w, h) = (jnum(p, "x")?, jnum(p, "y")?, jnum(p, "width")?, jnum(p, "height")?); let cls = if jbool(p, "ellipse") == Some(true) { "Elps" } else { "Rctn" }; @@ -1195,14 +1309,279 @@ fn delete_from(d: &Descriptor) -> Mapped { Some(p) => step("layer.delete", Value::Object(p)), None => Mapped::Unsupported { harmless: false }, }, + // The targeted channel of a layer is its mask (what Photoshop records after making an + // adjustment layer over a selection and discarding the mask). + Some(r) if ref_class(r).as_deref() == Some("Chnl") && matches!(target(r), Target::Ordinal(o) if o == "Trgt") => none("layer.layerMask.delete"), Some(_) => Mapped::Unsupported { harmless: false }, } } +fn quick_mask_ref() -> D { + D::Reference(vec![ + ReferenceItem::Property { class: class("Prpr"), key: code("QucM") }, + ReferenceItem::Enumerated { class: class("Dcmn"), type_id: code("Ordn"), value: code("Trgt") }, + ]) +} + +fn clear_from(d: &Descriptor) -> Mapped { + match get_ref(d, "null").map(target) { + Some(Target::Property(p)) if p == "QucM" => step("select.editInQuickMaskMode", json!({"on": false})), + None => none("edit.clear"), + _ => Mapped::Unsupported { harmless: false }, + } +} + +fn clear_to(id: &str, p: &Value) -> Option { + match id { + "select.editInQuickMaskMode" if jbool(p, "on") == Some(false) => Some(desc("null").with("null", quick_mask_ref())), + _ => None, + } +} + +/// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. +fn close_from(d: &Descriptor) -> Mapped { + if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } +} + +fn close_to(_: &str, _: &Value) -> Option { + Some(desc("null").with("Svng", enm("YsN ", "N "))) +} + +/// `transform`: Photoshop records a Free Transform relative to the frame (offset of its centre, +/// width/height in percent, angle). A layer moved only becomes `layer.translate`; anything +/// else is `edit.transform`'s relative form; the selection's frame is `select.transformSelection`. +fn transform_from(d: &Descriptor) -> Mapped { + let Some(r) = get_ref(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let unsupported = Mapped::Unsupported { harmless: false }; + let mut p = Map::new(); + if let Some(o) = get_obj(d, "Ofst") { + let in_px = |k: &str| matches!(o.get(k), Some(D::UnitFloat { unit: PX, .. })); + if !in_px("Hrzn") || !in_px("Vrtc") { + return unsupported; + } + if let Some(x) = get_num(o, "Hrzn") { + p.insert("dx".into(), x.into()); + } + if let Some(y) = get_num(o, "Vrtc") { + p.insert("dy".into(), y.into()); + } + } + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY"), ("Angl", "rotate")] { + if let Some(v) = get_num(d, key) { + p.insert(param.into(), v.into()); + } + } + if d.get("Skew").is_some() || d.get("Usng").is_some() { + return unsupported; + } + let geometry_only = p.keys().all(|k| k == "dx" || k == "dy"); + match (ref_class(r).as_deref(), target(r)) { + (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => step("select.transformSelection", Value::Object(p)), + (Some("Lyr"), _) => { + let Some(mut lp) = layer_target(r) else { return unsupported }; + if geometry_only { + lp.insert("dx".into(), p.get("dx").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + lp.insert("dy".into(), p.get("dy").and_then(Value::as_f64).unwrap_or(0.0).round().into()); + return step("layer.translate", Value::Object(lp)); + } + lp.extend(p); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + lp.insert("interpolation".into(), i.into()); + } + step("edit.transform", Value::Object(lp)) + } + _ => unsupported, + } +} + +fn transform_to(id: &str, p: &Value) -> Option { + if p.get("quad").is_some() || p.get("matrix").is_some() || p.get("rect").is_some() { + return None; + } + let target = match id { + "edit.transform" => layer_ref(p), + "select.transformSelection" => D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: code("fsel") }]), + _ => return None, + }; + let mut d = desc("null").with("null", target).with("FTcs", enm("QCSt", "Qcsa")); + d = d.with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(jnum(p, "dx").unwrap_or(0.0))).with("Vrtc", px(jnum(p, "dy").unwrap_or(0.0))))); + for (key, param) in [("Wdth", "scaleX"), ("Hght", "scaleY")] { + if let Some(v) = jnum(p, param) { + d = d.with(key, pct(v)); + } + } + if let Some(a) = jnum(p, "rotate") { + d = d.with("Angl", ang(a)); + } + if let Some(i) = jstr(p, "interpolation").and_then(|i| lookup_back(INTERPOLATION, i)) { + d = d.with("Intr", enm("Intp", i)); + } + Some(d) +} + +/// `Clcn` (Apply Image / Calculations blending) ↔ `image.applyImage`'s `blending`. +const CALC_BLEND: &[(&str, &str)] = &[ + ("Nrml", "normal"), + ("Mltp", "multiply"), + ("Scrn", "screen"), + ("Ovrl", "overlay"), + ("SftL", "softLight"), + ("HrdL", "hardLight"), + ("CDdg", "colorDodge"), + ("CBrn", "colorBurn"), + ("Drkn", "darken"), + ("Lghn", "lighten"), + ("Dfrn", "difference"), + ("Xclu", "exclusion"), + ("linearBurn", "linearBurn"), + ("linearDodge", "linearDodge"), + ("Add ", "add"), + ("Sbtr", "subtract"), +]; +const SOURCE_CHANNELS: &[(&str, &str)] = + &[("RGB", "composite"), ("CMYK", "composite"), ("Lab", "composite"), ("Trsp", "transparency"), ("Msk", "mask"), ("fsel", "selection")]; + +fn apply_image_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(with) = get_obj(d, "With") else { return unsupported }; + let Some(r) = get_ref(with, "T ") else { return unsupported }; + let mut source = Map::new(); + for item in r { + match item { + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Chnl") => { + let v = trimmed(value); + let Some(channel) = lookup(SOURCE_CHANNELS, &v).or_else(|| lookup(CHANNELS, &v)) else { return unsupported }; + source.insert("channel".into(), channel.into()); + } + ReferenceItem::Name { class, name } if class.class_id.is("Lyr ") => { + source.insert("layer".into(), name.to_string_lossy().into()); + } + ReferenceItem::Enumerated { class, value, .. } if class.class_id.is("Lyr ") && trimmed(value) == "Mrgd" => { + source.insert("layer".into(), "merged".into()); + } + _ => return unsupported, + } + } + if let Some(i) = get_bool(with, "Invr") { + source.insert("invert".into(), i.into()); + } + let mut p = json!({"source": source}); + if let Some(c) = get_enum(with, "Clcl") { + match lookup(CALC_BLEND, &c) { + Some(b) => p["blending"] = json!(b), + None => return unsupported, + } + } + for (key, param) in [("Scl ", "scale"), ("Ofst", "offset"), ("Opct", "opacity")] { + if let Some(v) = get_num(with, key) { + p[param] = json!(v); + } + } + if let Some(t) = get_bool(with, "PrsT") { + p["preserveTransparency"] = json!(t); + } + step("image.applyImage", p) +} + +fn apply_image_to(_: &str, p: &Value) -> Option { + let source = p.get("source").cloned().unwrap_or(json!({})); + let channel = jstr(&source, "channel").unwrap_or("composite"); + let chan = lookup_back(SOURCE_CHANNELS, channel).or_else(|| lookup_back(CHANNELS, channel))?; + let mut items = vec![ReferenceItem::Enumerated { class: class("Chnl"), type_id: code("Chnl"), value: code(chan) }]; + match jstr(&source, "layer") { + Some("merged") | None => items.push(ReferenceItem::Enumerated { class: class("Lyr "), type_id: code("Ordn"), value: code("Mrgd") }), + Some(name) => items.push(ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul(name) }), + } + let mut with = desc("null").with("T ", D::Reference(items)); + if let Some(i) = jbool(&source, "invert") { + with = with.with("Invr", D::Boolean(i)); + } + with = with.with("Clcl", enm("Clcn", lookup_back(CALC_BLEND, jstr(p, "blending").unwrap_or("multiply"))?)); + if let Some(v) = jnum(p, "opacity") { + with = with.with("Opct", pct(v)); + } + if let Some(v) = jnum(p, "scale") { + with = with.with("Scl ", D::Double(v)); + } + if let Some(v) = jnum(p, "offset") { + with = with.with("Ofst", D::Double(v)); + } + if let Some(t) = jbool(p, "preserveTransparency") { + with = with.with("PrsT", D::Boolean(t)); + } + Some(desc("null").with("With", D::Descriptor(with))) +} + +const WEB_FORMATS: &[(&str, &str)] = &[("PN24", "png24"), ("PN8", "png8"), ("JPEG", "jpeg"), ("GIFf", "gif"), ("WBMP", "wbmp")]; + +/// `export` (Save for Web): needs the destination path (`In`), as the command writes a file. +fn export_from(d: &Descriptor) -> Mapped { + let unsupported = Mapped::Unsupported { harmless: false }; + let Some(u) = get_obj(d, "Usng") else { return unsupported }; + let Some(path) = d.get("In ").and_then(path_text) else { return unsupported }; + let mut p = json!({"path": path}); + if let Some(f) = get_enum(u, "Fmt ") { + match lookup(WEB_FORMATS, &f) { + Some(f) => p["format"] = json!(f), + None => return unsupported, + } + } + for (key, param) in [("Intr", "interlaced"), ("Trns", "transparency"), ("EICC", "embedIcc"), ("Prgr", "progressive"), ("Optm", "optimized")] { + if let Some(b) = get_bool(u, key) { + p[param] = json!(b); + } + } + for (key, param) in [("Qlty", "quality"), ("NCol", "colors")] { + if let Some(v) = get_num(u, key) { + p[param] = json!(v); + } + } + if get_bool(u, "Mtt ") == Some(false) { + p["matte"] = json!("none"); + } else if let (Some(r), Some(g), Some(b)) = (get_num(u, "MttR"), get_num(u, "MttG"), get_num(u, "MttB")) { + p["matte"] = + json!(format!("#{:02x}{:02x}{:02x}", r.round().clamp(0.0, 255.0) as u8, g.round().clamp(0.0, 255.0) as u8, b.round().clamp(0.0, 255.0) as u8)); + } + step("file.export.saveForWebLegacy", p) +} + +fn export_to(_: &str, p: &Value) -> Option { + let path = jstr(p, "path")?; + let mut u = desc("SaveForWeb").with("Op ", enm("SWOp", "OpSa")).with("Fmt ", enm("IRFm", lookup_back(WEB_FORMATS, jstr(p, "format").unwrap_or("png24"))?)); + for (key, param, default) in [ + ("Intr", "interlaced", false), + ("Trns", "transparency", true), + ("EICC", "embedIcc", false), + ("Prgr", "progressive", false), + ("Optm", "optimized", true), + ] { + u = u.with(key, D::Boolean(jbool(p, param).unwrap_or(default))); + } + if let Some(q) = jnum(p, "quality") { + u = u.with("Qlty", long(q)); + } + if let Some(c) = jnum(p, "colors") { + u = u.with("NCol", long(c)); + } + match jstr(p, "matte") { + Some("none") => u = u.with("Mtt ", D::Boolean(false)), + matte => { + let c = color_desc(matte.unwrap_or("#ffffff"))?; + u = u + .with("Mtt ", D::Boolean(true)) + .with("MttR", get_num(&c, "Rd ").map(long)?) + .with("MttG", get_num(&c, "Grn ").map(long)?) + .with("MttB", get_num(&c, "Bl ").map(long)?); + } + } + Some(desc("null").with("Usng", D::Descriptor(u)).with("In ", path_value(path))) +} + fn delete_to(id: &str, p: &Value) -> Option { Some(match id { "edit.clear" => desc("null"), "layer.delete" => desc("null").with("null", layer_ref(p)), + "layer.layerMask.delete" => desc("null").with("null", target_ref("Chnl")), _ => return None, }) } @@ -1866,6 +2245,17 @@ const EVENTS: &[Event] = &[ to: |_, p| adjustment_by_class("BlnW").and_then(|a| (a.to)(p)), }, adjustment_event!("ChnM", "ChnM", "channelMixer", "Channel Mixer"), + Event { + code: "GdMp", + name: "gradientMapEvent", + label: "Gradient Map", + ids: &["image.adjustments.gradientMap"], + from: |d| match adjustment_by_class("GdMp") { + Some(a) => adjustment_steps(a, "image.adjustments.", Some(d)), + None => Mapped::Unsupported { harmless: false }, + }, + to: |_, p| adjustment_by_class("GdMp").and_then(|a| (a.to)(p)), + }, adjustment_event!("SlcC", "SlcC", "selectiveColor", "Selective Color"), ev!("Dstt", "desaturate", "Desaturate", "image.adjustments.desaturate", &[]), ev!("Eqlz", "equalize", "Equalize", "image.adjustments.equalize", &[]), @@ -1961,6 +2351,7 @@ const EVENTS: &[Event] = &[ "layer.newAdjustmentLayer.blackWhite", "layer.newAdjustmentLayer.channelMixer", "layer.newAdjustmentLayer.selectiveColor", + "layer.newAdjustmentLayer.gradientMap", "file.new", "file.newFromClipboard", ], @@ -1977,6 +2368,8 @@ const EVENTS: &[Event] = &[ "select.reselect", "select.loadSelection", "select.rect", + "select.lasso", + "select.editInQuickMaskMode", "layer.setProps", "layer.renameLayer", "layer.setLabelColor", @@ -2001,7 +2394,20 @@ const EVENTS: &[Event] = &[ from: select_from, to: select_to, }, - Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear"], from: delete_from, to: delete_to }, + Event { code: "Dlt ", name: "delete", label: "Delete", ids: &["layer.delete", "edit.clear", "layer.layerMask.delete"], from: delete_from, to: delete_to }, + Event { code: "", name: "clearEvent", label: "Clear", ids: &["select.editInQuickMaskMode"], from: clear_from, to: clear_to }, + Event { + code: "Trnf", + name: "transform", + label: "Transform", + ids: &["edit.transform", "select.transformSelection"], + from: transform_from, + to: transform_to, + }, + Event { code: "AppI", name: "applyImageEvent", label: "Apply Image", ids: &["image.applyImage"], from: apply_image_from, to: apply_image_to }, + ev!("", "newPlacedLayer", "Convert to Smart Object", "layer.smartObjects.convertToSmartObject", &[]), + ev!("", "placedLayerEditContents", "Edit Contents", "layer.smartObjects.editContents", &[]), + ev!("", "updatePlacedLayer", "Update Smart Objects", "layer.smartObjects.updateModifiedContent", &[]), Event { code: "Hd ", name: "hide", label: "Hide", ids: &["layer.hideLayers"], from: |d| visibility_from(d, "layer.hideLayers"), to: visibility_to }, Event { code: "Shw ", name: "show", label: "Show", ids: &["layer.showLayers"], from: |d| visibility_from(d, "layer.showLayers"), to: visibility_to }, Event { @@ -2086,7 +2492,8 @@ const EVENTS: &[Event] = &[ to: profile_to, }, // File and actions. - ev!("Cls ", "close", "Close", "file.close", &[]), + Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, + Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, @@ -2098,8 +2505,10 @@ fn event_for(item: &AtnEvent) -> Option<&'static Event> { EVENTS.iter().find(|e| (!e.code.is_empty() && e.code.trim_end() == id.trim_end()) || e.name == id) } -fn event_for_id(id: &str) -> Option<&'static Event> { - EVENTS.iter().find(|e| e.ids.contains(&id)) +/// The event `id` exports as: the first event listing it whose converter accepts the params +/// (Quick Mask on is a `set`, off a `clearEvent`). +fn event_and_descriptor(id: &str, params: &Value) -> Option<(&'static Event, Descriptor)> { + EVENTS.iter().filter(|e| e.ids.contains(&id)).find_map(|e| (e.to)(id, params).map(|d| (e, d))) } // ------------------------------------------------------------------ import @@ -2220,12 +2629,11 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { }; return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } - let e = event_for_id(id)?; let mut params = params.clone(); if id == "actions.play" && params.get("set").is_none() { params["set"] = json!(set); } - let descriptor = (e.to)(id, ¶ms)?; + let (e, descriptor) = event_and_descriptor(id, ¶ms)?; let event = if e.code.is_empty() { AtnEvent::Name(e.name.to_string()) } else { AtnEvent::Code(e.code.as_bytes().try_into().ok()?) }; Some(AtnItem { event, dictionary: e.label.to_string(), descriptor: Some(descriptor), ..Default::default() }) } diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index ff7e040878..6d6f7bb68e 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -261,11 +261,41 @@ fn every_mapped_command_exports_and_imports_back() { ("edit.assignProfile", json!({"profile": "srgb"})), ("file.close", json!({})), ("file.save", json!({})), + ("layer.layerMask.delete", json!({})), + ("select.lasso", json!({"points": [[1, 2], [30, 2], [15, 40]]})), + ("select.editInQuickMaskMode", json!({"on": true})), + ("select.editInQuickMaskMode", json!({"on": false})), + ("edit.transform", json!({"dx": 10, "dy": -5, "scaleX": 50, "scaleY": 75, "rotate": 12.5, "interpolation": "bicubic"})), + ("edit.transform", json!({"layer": "Shadow", "dx": 0, "dy": 0, "scaleX": 200})), + ("select.transformSelection", json!({"dx": 3, "dy": 4, "scaleX": 110, "scaleY": 110, "rotate": -30})), + ( + "image.applyImage", + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}), + ), + ("image.applyImage", json!({"source": {"channel": "red", "layer": "merged", "invert": true}, "blending": "multiply", "opacity": 50})), + ( + "file.export.saveForWebLegacy", + json!({"path": "/tmp/out/banner.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "progressive": false, "optimized": true, "matte": "#ffffff"}), + ), + ( + "file.export.saveForWebLegacy", + json!({"path": "C:\\shots\\a.jpg", "format": "jpeg", "interlaced": false, "transparency": false, "embedIcc": true, "progressive": true, "optimized": true, "quality": 80, "matte": "none"}), + ), + ("image.adjustments.gradientMap", json!({"stops": [[0, "#000000"], [0.5, "#ff8000"], [1, "#ffffff"]], "reverse": true, "dither": false})), + ("layer.newAdjustmentLayer.gradientMap", json!({"stops": [[0, "#102030"], [1, "#f0e0d0"]], "reverse": false, "dither": true})), + ("layer.smartObjects.convertToSmartObject", json!({})), + ("layer.smartObjects.editContents", json!({})), + ("layer.smartObjects.updateModifiedContent", json!({})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; for (id, params) in &cases { let item = export_item(id, params, "S").unwrap_or_else(|| panic!("{id} does not export")); + if *id == "file.close" { + // Close exports "don't save" and imports as a plain close; the Save answer is tested below. + assert_eq!(item.event.id(), "Cls "); + continue; + } let e = event_for(&item.event).unwrap_or_else(|| panic!("{id} exported as an unknown event {}", item.event.id())); match (e.from)(item.descriptor.as_ref().unwrap()) { Mapped::Steps(steps) => { @@ -486,3 +516,165 @@ fn colours_convert_both_ways() { assert_eq!(color_desc("#12ab9"), None); assert_eq!(color_desc("#ééééé"), None); } + +#[test] +fn remaining_events_import_in_their_photoshop_shapes() { + // Close with the Save answer becomes Save then Close. + let close_yes = item("Cls ", "Close", Some(desc("null").with("Svng", enm("YsN ", "Ys ")))); + let close_no = item("close", "Close", Some(desc("null").with("Svng", enm("YsN ", "N ")))); + // A layer only moved is a translation; scaled, it is the relative Free Transform; the + // selection's frame goes to Transform Selection; points (not pixels) stay unsupported. + let moved = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(-57.4)).with("Vrtc", px(12.0)))) + .with("Intr", enm("Intp", "Bcbc")); + let scaled = desc("null") + .with("null", target_ref("Lyr ")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(10.0)).with("Vrtc", px(0.0)))) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(true)) + .with("Intr", enm("Intp", "Bcbc")); + let sel = desc("null") + .with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])) + .with("FTcs", enm("QCSt", "Qcs0")) + .with("Ofst", D::Descriptor(desc("Ofst").with("Hrzn", px(1014.0)).with("Vrtc", px(0.0)))); + let points = desc("null").with("null", target_ref("Lyr ")).with( + "Ofst", + D::Descriptor(desc("Ofst").with("Hrzn", D::UnitFloat { unit: *b"#Rlt", value: 3.0 }).with("Vrtc", D::UnitFloat { unit: *b"#Rlt", value: 1.0 })), + ); + // Apply Image as recorded by the frequency-separation action. + let apply = desc("null").with( + "With", + D::Descriptor( + desc("null") + .with( + "T ", + D::Reference(vec![ + ReferenceItem::Enumerated { class: class("Chnl"), type_id: Id::new("Chnl"), value: Id::new("RGB ") }, + ReferenceItem::Name { class: class("Lyr "), name: UnicodeString::new_nul("High-Frequency") }, + ]), + ) + .with("Clcl", enm("Clcn", "Sbtr")) + .with("Scl ", D::Double(2.0)) + .with("Ofst", D::Double(128.0)) + .with("PrsT", D::Boolean(true)), + ), + ); + // Save for Web with and without a destination. + let web = desc("null") + .with( + "Usng", + D::Descriptor( + desc("SaveForWeb") + .with("Fmt ", enm("IRFm", "PN24")) + .with("Intr", D::Boolean(false)) + .with("Trns", D::Boolean(true)) + .with("Mtt ", D::Boolean(true)) + .with("MttR", long(255.0)) + .with("MttG", long(255.0)) + .with("MttB", long(255.0)) + .with("EICC", D::Boolean(false)), + ), + ) + .with("In ", path_value("/Users/me/Desktop/out.png")); + let web_nowhere = desc("null").with("Usng", D::Descriptor(desc("SaveForWeb").with("Fmt ", enm("IRFm", "PN24")))); + // Quick Mask on/off, a polygon selection, the mask of the target layer deleted. + let qm_on = desc("null").with("null", quick_mask_ref()); + let polygon = desc("null").with("null", D::Reference(vec![ReferenceItem::Property { class: class("Chnl"), key: Id::new("fsel") }])).with( + "T ", + D::Descriptor(desc("Plgn").with( + "Pts ", + D::List(vec![ + D::Descriptor(desc("Pnt ").with("Hrzn", px(1.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(30.0)).with("Vrtc", px(2.0))), + D::Descriptor(desc("Pnt ").with("Hrzn", px(15.0)).with("Vrtc", px(40.0))), + ]), + )), + ); + let mask_delete = desc("null").with("null", target_ref("Chnl")); + let items = vec![ + item("GsnB", "Gaussian Blur", Some(desc("null").with("Rds ", px(1.0)))), + close_yes, + close_no, + item("transform", "Transform", Some(moved)), + item("Trnf", "Transform", Some(scaled)), + item("transform", "Transform", Some(sel)), + item("transform", "Transform", Some(points)), + item("applyImageEvent", "Apply Image", Some(apply)), + item("export", "Export", Some(web)), + item("export", "Export", Some(web_nowhere)), + item("setd", "Set", Some(qm_on.clone())), + item("clearEvent", "Clear", Some(qm_on.clone())), + item("set", "Set", Some(polygon)), + item("delete", "Delete", Some(mask_delete)), + item("newPlacedLayer", "Convert to Smart Object", None), + item("placedLayerEditContents", "Edit Contents", None), + item("updatePlacedLayer", "Update Smart Objects", None), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("More"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let imp = import(&atn::write(&file).unwrap()).unwrap(); + let steps = &imp.actions[0].steps; + let ids: Vec<&str> = steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + ids, + [ + "filter.blur.gaussianBlur", + "file.save", + "file.close", + "file.close", + "layer.translate", + "edit.transform", + "select.transformSelection", + PHOTOSHOP_STEP, + "image.applyImage", + "file.export.saveForWebLegacy", + PHOTOSHOP_STEP, + "select.editInQuickMaskMode", + "select.editInQuickMaskMode", + "select.lasso", + "layer.layerMask.delete", + "layer.smartObjects.convertToSmartObject", + "layer.smartObjects.editContents", + "layer.smartObjects.updateModifiedContent", + ] + ); + assert_eq!(steps[4].1, json!({"dx": -57, "dy": 12})); + assert_eq!(steps[5].1, json!({"dx": 10, "dy": 0, "scaleX": 50, "scaleY": 50, "interpolation": "bicubic"})); + assert_eq!(steps[6].1, json!({"dx": 1014, "dy": 0})); + assert_eq!( + steps[8].1, + json!({"source": {"channel": "composite", "layer": "High-Frequency"}, "blending": "subtract", "scale": 2, "offset": 128, "preserveTransparency": true}) + ); + assert_eq!( + steps[9].1, + json!({"path": "/Users/me/Desktop/out.png", "format": "png24", "interlaced": false, "transparency": true, "embedIcc": false, "matte": "#ffffff"}) + ); + assert_eq!(steps[11].1, json!({"on": true})); + assert_eq!(steps[12].1, json!({"on": false})); + assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); + assert_eq!(imp.unsupported.len(), 2); + + // The Photoshop path record round-trips and rejects other shapes. + assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png")); + assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None); + assert_eq!(path_text(&D::Path(vec![])), None); + assert_eq!(path_text(&D::Text(UnicodeString::new("x"))), None); + assert_eq!(path_text(&path_value("")), None); + + // Apply Image by layer name runs on a document; a missing name is a clear error. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + s.execute("layer.new.layer", json!({"name": "High-Frequency"})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Top"})).unwrap(); + s.execute("image.applyImage", steps[8].1.clone()).unwrap(); + let e = s.execute("image.applyImage", json!({"source": {"layer": "Nope"}})).unwrap_err().to_string(); + assert!(e.contains("Nope"), "{e}"); +} diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index 63f8f5b626..86bf55d1a1 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -328,9 +328,20 @@ fn native_pixels(doc: &Document, layer: Option) -> Result<(Vec, us } } -/// `"layer"` param of a source: a layer id, or `"merged"` (default). -fn source_layer(p: &Value) -> Option { - p.get("layer").and_then(Value::as_u64).map(LayerId) +/// `"layer"` param of a source: a layer id, a layer name (the topmost of that name, as a loaded +/// Photoshop action refers to it), or `"merged"` (default). An unknown name is an error. +fn source_layer(doc: &Document, p: &Value, cmd: &str) -> Result> { + match p.get("layer") { + Some(Value::Number(n)) => Ok(n.as_u64().map(LayerId)), + Some(Value::String(name)) if !name.is_empty() && name != "merged" => doc + .walk() + .into_iter() + .rev() + .find(|(_, _, l)| l.name == *name) + .map(|(_, _, l)| Some(l.id)) + .ok_or_else(|| bad(cmd, format!("no source layer named \"{name}\""))), + _ => Ok(None), + } } /// Planes of channel `r` from document `doc` (`layer` = None: merged). A composite gives one @@ -425,7 +436,7 @@ fn source(s: &Session, p: &Value, cmd: &str, gray: bool) -> Result> return Err(bad(cmd, format!("source document \"{}\" is {}×{}; it must match the target size", st.doc.name, st.doc.size.width, st.doc.size.height))); } let r = p.get("channel").map_or(Some(ChanRef::Composite), |v| parse_ref(v, &st.doc)).ok_or_else(|| bad(cmd, "unknown `channel`"))?; - let layer = source_layer(p); + let layer = source_layer(&st.doc, p, cmd)?; let mut planes = if gray { vec![gray_plane(&st.doc, layer, st.active_layer, r)?] } else { ref_planes(&st.doc, layer, st.active_layer, r)? }; // Guard every caller (`.remove(0)` / `src[0]`) against an empty source. if planes.is_empty() { @@ -1716,7 +1727,7 @@ pub fn specs() -> Vec { "Apply Image…", ["Image"], None, - r##"{"source":{"document":index?,"layer":id|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, + r##"{"source":{"document":index?,"layer":id|name|"merged"="merged","channel":"composite"|"red|…"|index|"selection"|"transparency"|"mask"="composite","invert":bool=false},"blending":"normal|multiply|screen|overlay|softLight|hardLight|colorDodge|colorBurn|darken|lighten|difference|exclusion|linearBurn|linearDodge|add|subtract|…"="multiply","opacity":0..100=100,"scale":1..2=1,"offset":-255..255=0,"preserveTransparency":bool=false,"mask":{"document","layer","channel","invert"}?,"sourceChannel|sourceDocument|sourceLayer|sourceInvert|maskChannel|…":flat form of source/mask?}"##, has_apply_target, apply_image ), diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 684d308de0..44926b09f3 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -397,6 +397,33 @@ pub(crate) fn composite_over(dst: &mut Surface, top: &Surface) { dst.write_region(b, &d); } +/// The relative form (`scaleX`, `scaleY` in percent, `rotate` in degrees clockwise, `dx`, `dy` +/// in pixels), as Photoshop records a Free Transform: scale and rotate about the frame's +/// centre, then move. `None` when none of them is given. +fn relative_matrix(p: &Value, rect: [f64; 4]) -> Result> { + let num = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|f| f.is_finite()).map(Some).ok_or_else(|| bad(format!("`{k}` must be a finite number"))), + } + }; + let (sx, sy, rot, dx, dy) = (num("scaleX")?, num("scaleY")?, num("rotate")?, num("dx")?, num("dy")?); + if sx.is_none() && sy.is_none() && rot.is_none() && dx.is_none() && dy.is_none() { + return Ok(None); + } + let (sx, sy) = (sx.unwrap_or(100.0) / 100.0, sy.unwrap_or(100.0) / 100.0); + if sx.abs() < 1e-6 || sy.abs() < 1e-6 { + return Err(bad("`scaleX` and `scaleY` must not be 0")); + } + let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0); + let (sin, cos) = rot.unwrap_or(0.0).to_radians().sin_cos(); + // (x, y) → translate(-c) → scale → rotate → translate(c + d). + let (a, b, c, d) = (sx * cos, sx * sin, -sy * sin, sy * cos); + let e = cx + dx.unwrap_or(0.0) - a * cx - c * cy; + let f = cy + dy.unwrap_or(0.0) - b * cx - d * cy; + Ok(Some(Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]))) +} + fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> { let a = p.get("quad")?.as_array()?; if a.len() != 4 { @@ -459,8 +486,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { } // Affine [a b c d e f] maps (x, y) → (a·x + c·y + e, b·x + d·y + f). Homography([v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0]) + } else if let Some(m) = relative_matrix(p, rect)? { + m } else { - return Err(bad("pass `quad` (where the rect's corners go) or `matrix`")); + return Err(bad("pass `quad` (where the rect's corners go), `matrix`, or `scaleX`/`scaleY`/`rotate`/`dx`/`dy`")); }; if h.inverse().is_none() { return Err(bad("the transform collapses the layer")); @@ -515,7 +544,7 @@ pub fn specs() -> Vec { 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),"interpolation":"bicubic|bilinear|nearest"="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?,"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"="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, @@ -993,4 +1022,30 @@ mod tests { s.execute("layer.setProps", json!({"locks": {"position": true}})).unwrap(); assert!(s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 0]})).is_err(), "locked layer refuses identity transform"); } + + #[test] + fn relative_params_scale_and_rotate_about_the_frame_centre() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 20, "y": 30, "width": 40, "height": 20})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + // 50% about the centre (40, 40), then 10 px right: the content ends at x 40..60, y 35..45. + let r = s.execute("edit.transform", json!({"scaleX": 50, "scaleY": 50, "dx": 10})).unwrap(); + let rect: Vec = serde_json::from_value(r["rect"].clone()).unwrap(); + assert_eq!(rect, [20.0, 30.0, 60.0, 50.0], "the reported frame is the source frame"); + let bounds = |s: &Session| { + let d = s.active().unwrap(); + let b = transform_bounds(&d.doc, d.doc.layer(d.active_layer.unwrap()).unwrap()); + (b.x0, b.y0, b.x1, b.y1) + }; + assert_eq!(bounds(&s), (40, 35, 60, 45)); + // A quarter turn about the centre (50, 40) swaps the frame's sides. + s.execute("edit.transform", json!({"rotate": 90})).unwrap(); + assert_eq!(bounds(&s), (45, 30, 55, 50)); + for bad in [json!({"scaleX": 0}), json!({"rotate": "x"}), json!({"dx": f64::NAN})] { + assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); + } + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ecda48fee7..bc892c0ed3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (90 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (105 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | diff --git a/docs/gaps.md b/docs/gaps.md index 564138e0ae..c5e1172415 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,11 +89,13 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - 90 mapped events (Transform with scale or rotation, Apply Image, Save for Web, Place, smart - object edits, Generative Fill…) load as kept Photoshop steps that fail at playback by name. + mapped events (Place, Image Size in percent, paths, conditionals, Generative Fill…) load as + kept Photoshop steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 92 Photoshop events map to commands both ways, Play and - Stop steps included; layer steps replay by layer name in any document. The panel groups actions + and `actions.load`/`actions.save`; 105 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask and polygon selections + since the third PR), Play and Stop steps included; layer steps replay by layer name in any + document. The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents (AUTO-219-2 done). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2f5509c694..ae076b3208 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (90 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 90 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 105 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (90 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (105 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | From e2a35b04fd693f25ac44f3a08e7de019bc1483b4 Mon Sep 17 00:00:00 2001 From: Tommy Leonhardsen Date: Tue, 13 Oct 2026 01:05:37 +0200 Subject: [PATCH 175/523] Windows: keep full screen and maximized apart; never restore full screen (#4396) * Windows: keep full screen and maximized apart; never restore full screen winit on Windows keeps the maximized style on a window that goes full screen from maximized. The shell then draws the taskbar black instead of hiding it, and on the way back the window stays maximized at the monitor's full size, over the taskbar (#3550, #4018, #4230). Un-maximize before entering full screen and maximize again after leaving it. eframe also restores full screen from ui.ron, as full screen and maximized at once; drop that flag in the window builder hook, so PhotoCraft starts in Standard Screen Mode as its own state says. Co-Authored-By: Claude Opus 5.5 * contributors: add aweussom * view_cmds: mark the winit#4756 workaround and how to retire it Co-Authored-By: Claude Opus 5.5 * rustfmt view_cmds.rs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/photocraft/src/main.rs | 28 ++++++++++++++++- contributors/people.toml | 3 ++ crates/ui-egui/src/view_cmds.rs | 43 ++++++++++++++++++++++++++- crates/ui-egui/src/view_cmds/tests.rs | 28 +++++++++++++++++ 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index aee9eabca1..00d334758b 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -90,11 +90,21 @@ fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { // eframe saves native window geometry and egui panel/window sizes on exit. // Keep that state beside preferences, including config overrides and portable mode. persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")), - window_builder: Some(Box::new(at_least_min_size)), + window_builder: Some(Box::new(restored_window)), ..Default::default() } } +/// The window eframe restores from `ui.ron`, corrected before it is created: at least the +/// minimum size, and never full screen. PhotoCraft always starts in Standard Screen Mode, but +/// eframe also saves and restores full screen, and a window closed in a full-screen mode is +/// saved full screen and maximized at once: the overlap `view_cmds::screen_mode_commands` keeps +/// apart at run time. [OBSERVED 2026-10-12, Windows 11] Restored that way, View › Screen Mode › +/// Full Screen did nothing until `F` had cycled through Standard. +fn restored_window(builder: egui::ViewportBuilder) -> egui::ViewportBuilder { + at_least_min_size(builder).with_fullscreen(false) +} + /// Grow a restored window that is smaller than the minimum size, keeping its centre. On /// Windows, eframe saves a minimized window's size (160 pt wide, 64 pt tall once eframe has /// clamped it), and the minimum size isn't applied to a restored one: PhotoCraft then reopened @@ -624,6 +634,22 @@ mod tests { assert_eq!(super::at_least_min_size(builder).inner_size, None); } + #[test] + fn a_window_saved_in_full_screen_reopens_maximized_not_full_screen() { + let hook = super::native_options(super::CUSTOM_TITLEBAR).window_builder.expect("a window builder hook"); + // What eframe restores from a ui.ron saved in a full-screen mode on Windows. + let saved = super::native_options(super::CUSTOM_TITLEBAR) + .viewport + .with_inner_size([2560.0, 1392.0]) + .with_position([0.0, 0.0]) + .with_fullscreen(true) + .with_maximized(true); + let opened = hook(saved); + assert_eq!(opened.fullscreen, Some(false), "Standard Screen Mode is where PhotoCraft starts"); + assert_eq!(opened.maximized, Some(true), "the rest of the saved geometry is kept"); + assert_eq!(opened.inner_size, Some(egui::vec2(2560.0, 1392.0))); + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { // #1271, #1316: Preferences › Interface › System Title Bar gives the window back its diff --git a/contributors/people.toml b/contributors/people.toml index 53bd154ace..82a9e20a49 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -124,3 +124,6 @@ sync_github_name = true [people.arthurdeka] sync_github_name = true + +[people.aweussom] +real_name = "Tommy Leonhardsen" diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 264550b884..33fc6affc5 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -167,6 +167,41 @@ const LAYOUTS: [&str; 10] = [ "tile", ]; +/// Whether the window was maximized when a screen mode took it full screen (egui temp data). +fn was_maximized_id() -> egui::Id { + egui::Id::new("pc-full-screen-was-maximized") +} + +/// The window commands for switching to screen mode `next`, and whether the window should be +/// maximized again when it leaves full screen. With `keep_apart` (Windows), full screen and +/// maximized never overlap: a maximized window is un-maximized before it goes full screen and +/// maximized again after it leaves. Why: winit on Windows keeps the maximized style on a window +/// that goes full screen from maximized, and the shell then draws the taskbar black instead of +/// hiding it; on the way out the window stays maximized at the monitor's full size, over the +/// taskbar (#3550, #4018, #4230). [OBSERVED 2026-10-12, Windows 11, eframe 0.36.2, winit 0.30.13, +/// a minimal eframe window; un-maximizing first gave a clean full screen and a work-area maximize.] +/// +/// WORKAROUND(winit#4756): remove `keep_apart` once the winit in Cargo.lock (0.30.13 today) has +/// the fix; check first with https://gist.github.com/aweussom/3f80ffb82df7f0102341858afbbffd9b +/// (`--max`, no `--fix`: full screen must have no WS_MAXIMIZE and hide the taskbar). +fn screen_mode_commands(keep_apart: bool, full_screen: bool, maximized: bool, was_maximized: bool, next: &str) -> (Vec, bool) { + let want = next != "standard"; + let mut commands = Vec::new(); + let mut remember = was_maximized; + if keep_apart && want && !full_screen && maximized { + commands.push(egui::ViewportCommand::Maximized(false)); + remember = true; + } + commands.push(egui::ViewportCommand::Fullscreen(want)); + if !want { + if keep_apart && full_screen && remember { + commands.push(egui::ViewportCommand::Maximized(true)); + } + remember = false; + } + (commands, remember) +} + /// Pixel aspect ratio (width / height of a pixel) of a preset id. pub fn pixel_aspect_ratio(id: &str) -> f32 { // View › Pixel Aspect Ratio › Custom: `custom::` (see `workspace_ui`). @@ -658,7 +693,13 @@ fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Res }; o.screen_mode = next.to_string(); // Both full-screen modes take the whole display; the menu bar stays in the first. - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(next != "standard")); + let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false))); + let was_maximized = ctx.data(|d| d.get_temp::(was_maximized_id())).unwrap_or(false); + let (commands, was_maximized) = screen_mode_commands(cfg!(target_os = "windows"), full_screen, maximized, was_maximized, next); + ctx.data_mut(|d| d.insert_temp(was_maximized_id(), was_maximized)); + for command in commands { + ctx.send_viewport_cmd(command); + } return Ok(json!({"screenMode": next})); } if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") { diff --git a/crates/ui-egui/src/view_cmds/tests.rs b/crates/ui-egui/src/view_cmds/tests.rs index 3de1a20b4b..b930e6e26e 100644 --- a/crates/ui-egui/src/view_cmds/tests.rs +++ b/crates/ui-egui/src/view_cmds/tests.rs @@ -59,6 +59,34 @@ fn show_snap_extras_and_screen_modes_are_state() { assert_eq!(v["view"]["show"]["layer_edges"], true); } +#[test] +fn full_screen_and_maximized_never_overlap_on_windows() { + use egui::ViewportCommand::{Fullscreen, Maximized}; + // Maximized → full screen: un-maximize first, and remember it. + let (cmds, remember) = screen_mode_commands(true, false, true, false, "fullScreenWithMenuBar"); + assert_eq!(cmds, vec![Maximized(false), Fullscreen(true)]); + assert!(remember); + // Full screen → the other full-screen mode: nothing about maximized changes. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "fullScreen"); + assert_eq!(cmds, vec![Fullscreen(true)]); + assert!(remember); + // Full screen → Standard: maximize again after leaving, then forget. + let (cmds, remember) = screen_mode_commands(true, true, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false), Maximized(true)]); + assert!(!remember); + // A window that was not maximized goes in and out untouched. + let (cmds, remember) = screen_mode_commands(true, false, false, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); + let (cmds, remember) = screen_mode_commands(true, true, false, false, "standard"); + assert_eq!((cmds, remember), (vec![Fullscreen(false)], false)); + // Standard while already standard never maximizes. + let (cmds, _) = screen_mode_commands(true, false, false, true, "standard"); + assert_eq!(cmds, vec![Fullscreen(false)]); + // Elsewhere the commands are as before. + let (cmds, remember) = screen_mode_commands(false, false, true, false, "fullScreen"); + assert_eq!((cmds, remember), (vec![Fullscreen(true)], false)); +} + #[test] fn zoom_presets_and_fit_layers() { let (mut app, ctx) = app_with(1); From 3f0a633056d5ea8c9f1a0aec2d3572f4789780bf Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 07:05:49 +0800 Subject: [PATCH 176/523] Make channel filter previews match the committed edit (#3979) * Fix selected-channel filter previews and reduced Quick Mask proxies Co-authored-by: Codex * Keep the new dialog preview aligned with the selected channel * Bound full-resolution dialog preview textures to the device limit --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> Co-authored-by: Codex Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/compose/src/proxy.rs | 24 ++ crates/ui-egui/src/canvas.rs | 236 +++++++++++++++++++- crates/ui-egui/src/channel_view.rs | 2 +- crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/filter_dialog.rs | 15 ++ crates/ui-egui/src/filter_preview_ui.rs | 129 ++++++++++- crates/ui-egui/src/filter_preview_worker.rs | 1 + 7 files changed, 388 insertions(+), 20 deletions(-) diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 4fe3b879fb..7fe7fc3983 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -79,6 +79,9 @@ pub fn proxy_document(doc: &Document, k: u32) -> Document { c.surface = downsample(&c.surface, k); } p.selection = p.selection.as_ref().map(|s| downsample(s, k)); + if let Some(mask) = &mut p.quick_mask { + mask.surface = downsample(&mask.surface, k); + } p } @@ -172,6 +175,27 @@ mod tests { assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none()); } + #[test] + fn proxy_scales_quick_mask_at_every_depth() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = Document::new("mask", Size::new(16, 12), ColorMode::Rgb, depth); + let mut surface = Surface::new(PixelFormat::new(ColorMode::Grayscale, depth, false)); + surface.fill_rect(Rect::new(0, 0, 8, 12), &[1.0]); + let mut mask = photocraft_doc::AlphaChannel::new("Quick Mask", surface); + mask.opacity = 0.25; + doc.quick_mask = Some(mask); + for k in [1, 2, 4] { + let proxy = proxy_document(&doc, k); + let mask = proxy.quick_mask.as_ref().unwrap(); + assert_eq!(mask.surface.pixel((8 / k - 1) as i32, 0), vec![1.0]); + assert_eq!(mask.surface.pixel((8 / k) as i32, 0), vec![0.0], "depth {depth:?}, factor {k}"); + assert_eq!(mask.opacity, 0.25); + assert_eq!(mask.surface.format().sample, depth); + } + assert_eq!(doc.quick_mask.as_ref().unwrap().surface.pixel(7, 0), vec![1.0], "source stays intact"); + } + } + #[test] fn proxy_accepts_empty_paths_but_keeps_geometry_and_effect_guards() { let mut doc = two_colors(Size::new(32, 16), SampleType::F32); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 17de8820e3..5b6222b313 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1466,8 +1466,16 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let st = app.session.documents().get(idx)?; // Filters whose features don't scale with a proxy preview at full resolution (#2063). let k = crate::filter_dialog::preview_factor(&cmd, ¶ms, crate::proxy::preview_factor(&st.doc, crate::proxy::reduced_previews(app))); - let request = - crate::filter_dialog::FilterPreviewKey { doc: st.doc.id, revision: st.revision, dialog: d.id, active: st.active_layer, command: cmd, params, k }; + let request = crate::filter_dialog::FilterPreviewKey { + doc: st.doc.id, + revision: st.revision, + dialog: d.id, + active: st.active_layer, + target: st.channel_view.target, + command: cmd, + params, + k, + }; let key = request.doc.0 ^ (1u64 << 61); // Every filter's preview computes off the UI thread in the desktop app: Gaussian Blur at // 1000 px takes long enough to freeze the window. Once the slider settles, a preview still @@ -1492,10 +1500,13 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e let task = request.clone(); if let Err(error) = app.filter_preview_worker.start(request.clone(), ctx.clone(), move |cancel| { let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document_with(&doc, task.active, &task.command, &task.params, task.k, Some(cancel)).map(|doc| { - let buffer = photocraft_compose::flatten(&doc); - (std::sync::Arc::new(doc), buffer) - }); + let result = + crate::filter_dialog::preview_document_for_target(&doc, task.active, &task.command, &task.params, task.k, Some(cancel), task.target).map( + |doc| { + let buffer = photocraft_compose::flatten(&doc); + (std::sync::Arc::new(doc), buffer) + }, + ); crate::filter_preview_worker::Computed { result, ms: crate::gpu_canvas::now_ms() - t0 } }) { app.filter_preview = Some(crate::filter_dialog::FilterPreview { key: request.clone(), result: None, committing: false }); @@ -1512,6 +1523,7 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e || preview.key.revision != request.revision || preview.key.dialog != request.dialog || preview.key.active != request.active + || preview.key.target != request.target || preview.key.k != request.k || preview.key.command != request.command { @@ -1526,7 +1538,9 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e if app.filter_preview.as_ref().is_none_or(|p| p.key != request) { let doc = app.session.documents().get(idx)?.doc.clone(); let t0 = crate::gpu_canvas::now_ms(); - let result = crate::filter_dialog::preview_document(&doc, request.active, &request.command, &request.params, request.k).map(std::sync::Arc::new); + let result = + crate::filter_dialog::preview_document_for_target(&doc, request.active, &request.command, &request.params, request.k, None, request.target) + .map(std::sync::Arc::new); if let Some(r) = &result { let buf = photocraft_compose::flatten(r); let t1 = crate::gpu_canvas::now_ms(); @@ -1544,6 +1558,18 @@ pub(crate) fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize, ctx: &e Some((preview.key.k, key, [result.size.width, result.size.height])) } +/// Channel overlays must use the filter's current proxy too, including on the CPU canvas. +/// The cache key changes with the completed request; unchanged frames reuse the overlay texture. +pub(crate) fn filter_channel_preview(app: &mut PhotocraftApp, idx: usize, ctx: &egui::Context) -> Option<(std::sync::Arc, u64)> { + use std::hash::{Hash, Hasher}; + ensure_filter_preview(app, idx, ctx)?; + let preview = app.filter_preview.as_ref()?; + let doc = preview.result.as_ref()?.clone(); + let mut key = std::collections::hash_map::DefaultHasher::new(); + format!("{:?}", preview.key).hash(&mut key); + Some((doc, key.finish() | (1 << 59))) +} + fn publish_filter_preview( app: &mut PhotocraftApp, idx: usize, @@ -5407,6 +5433,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: json!({"radius": 999.0}), k: 1, @@ -5454,6 +5481,7 @@ mod tests { revision: st.revision, dialog: d.id, active: st.active_layer, + target: st.channel_view.target, command: "image.mode.indexedColor".into(), params: app.with_mask_target("image.mode.indexedColor", crate::filter_dialog::params_of(&d.fields)), k: 1, @@ -7141,6 +7169,200 @@ mod rotation_preview_isolation_tests { use super::*; use serde_json::json; + #[test] + fn channel_overlay_shows_filter_preview_and_restores_on_cancel() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.run("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &ctx).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("filter preview must update channel overlay")[0].image; + let st = app.session.active().unwrap(); + let original = crate::channel_view::render(&before, &st.channel_view, before.bounds(), 1, false, st.active_layer).unwrap(); + assert_ne!(image.pixels, original, "a nontrivial filter must be visible before OK"); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(false)); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Preview off restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + app.ui.dialogs.last_mut().unwrap().fields.insert("__preview".into(), json!(true)); + crate::channel_view::ensure(&mut app, &ctx, 0); + ctx.tex_manager().write().take_delta().clear(); + crate::dialogs::cancel(&mut app, dialog).unwrap(); + crate::channel_view::ensure(&mut app, &ctx, 0); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("Cancel restores channel")[0].image; + assert_eq!(image.pixels, original); + delta.clear(); + assert!(std::sync::Arc::ptr_eq(&before, &app.session.active().unwrap().doc)); + } + + fn assert_filter_preview_target(target: photocraft_engine::channel_cmds::ChannelTarget) { + for depth in [8, 16, 32] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width":16,"height":16,"depth":depth})).unwrap(); + session.execute("channel.new", json!({"name":"Alpha","fill":"black"})).unwrap(); + session + .edit("Fixture", |doc, active| { + let layer = doc.layer_mut(active.unwrap()).unwrap(); + layer.mask = Some(photocraft_doc::LayerMask::hide_all()); + for y in 0..16 { + for x in 0..8 { + layer.surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + layer.mask.as_mut().unwrap().surface.write_pixel(x, y, &[1.0]); + } + } + for y in 0..16 { + for x in 0..8 { + doc.channels[0].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + session.active_mut().unwrap().channel_view.target = target; + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.background_jobs = false; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + ensure_filter_preview(&mut app, 0, &egui::Context::default()).unwrap(); + let preview = app.filter_preview.as_ref().unwrap().result.as_ref().unwrap().clone(); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let final_doc = &app.session.active().unwrap().doc; + let bounds = final_doc.bounds(); + assert_eq!( + preview.layers[0].surface().unwrap().read_region(bounds), + final_doc.layers[0].surface().unwrap().read_region(bounds), + "RGB: {depth} {target:?}" + ); + assert_eq!(preview.channels[0].surface.read_region(bounds), final_doc.channels[0].surface.read_region(bounds), "alpha: {depth} {target:?}"); + assert_eq!( + preview.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + final_doc.layers[0].mask.as_ref().unwrap().surface.read_region(bounds), + "mask: {depth} {target:?}" + ); + } + } + + #[test] + fn filter_preview_matches_applied_alpha_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Alpha(0)); + } + + #[test] + fn filter_preview_matches_applied_color_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::Color(0)); + } + + #[test] + fn filter_preview_matches_applied_layer_mask_target() { + assert_filter_preview_target(photocraft_engine::channel_cmds::ChannelTarget::LayerMask); + } + + #[test] + fn filter_preview_refreshes_when_channel_target_changes() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + for name in ["First", "Second"] { + app.run("channel.new", json!({"name":name,"fill":"black"})).unwrap(); + } + app.session + .edit("Fixture", |doc, _| { + for y in 0..16 { + for x in 0..8 { + doc.channels[1].surface.write_pixel(x, y, &[1.0]); + } + } + Ok(()) + }) + .unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + let ctx = egui::Context::default(); + let (first, first_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + let (second, second_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert_eq!(app.session.active().unwrap().revision, revision); + assert_ne!(first_key, second_key); + assert_ne!(first.channels[1].surface.read_region(first.bounds()), second.channels[1].surface.read_region(second.bounds())); + let (unchanged, unchanged_key) = filter_channel_preview(&mut app, 0, &ctx).unwrap(); + assert!(std::sync::Arc::ptr_eq(&second, &unchanged)); + assert_eq!(second_key, unchanged_key); + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let applied = &app.session.active().unwrap().doc; + assert_eq!(second.channels[1].surface.read_region(second.bounds()), applied.channels[1].surface.read_region(applied.bounds())); + } + + #[test] + fn filter_preview_overlay_matches_quick_mask_and_mixed_color_views() { + for quick_mask in [false, true] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.background_jobs = false; + app.run("file.new", json!({"width":16,"height":16})).unwrap(); + app.session + .edit("Fixture", |doc, active| { + if quick_mask { + doc.quick_mask = + Some(photocraft_doc::AlphaChannel::new("Quick Mask", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8))); + } + for y in 0..16 { + for x in 0..8 { + if let Some(mask) = &mut doc.quick_mask { + mask.surface.write_pixel(x, y, &[1.0]); + } else { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().write_pixel(x, y, &[0.0, 0.25, 0.5, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + if !quick_mask { + let view = &mut app.session.active_mut().unwrap().channel_view; + view.target = photocraft_engine::channel_cmds::ChannelTarget::Color(0); + view.set_color_visible(2, false); + } + let ctx = egui::Context::default(); + let texture = crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + ctx.tex_manager().write().take_delta().clear(); + let dialog = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + app.ui.dialogs.last_mut().unwrap().fields.insert("radius".into(), json!(2.0)); + crate::channel_view::ensure(&mut app, &ctx, 0).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture).expect("preview must refresh overlay")[0].image; + crate::dialogs::confirm(&mut app, dialog).unwrap(); + let st = app.session.active().unwrap(); + let expected = crate::channel_view::render(&st.doc, &st.channel_view, st.doc.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(image.pixels, expected, "quick_mask={quick_mask}"); + delta.clear(); + } + } + /// A CPU-only app must be able to generate the actual rotation preview without a /// GPU adapter; checking the result also verifies Preview off and document integrity. #[test] diff --git a/crates/ui-egui/src/channel_view.rs b/crates/ui-egui/src/channel_view.rs index e4642d946d..d7a8b190f5 100644 --- a/crates/ui-egui/src/channel_view.rs +++ b/crates/ui-egui/src/channel_view.rs @@ -222,7 +222,7 @@ pub fn ensure(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize) -> Optio // Mask and channel views are drawn over the canvas: show the same live document, // otherwise an opaque grayscale mask hides the stroke until it is committed (#2078). // The plain-composite path above needs no preview work. - let (doc, preview_key) = crate::canvas::display_doc(app, idx); + let (doc, preview_key) = crate::canvas::filter_channel_preview(app, idx, ctx).unwrap_or_else(|| crate::canvas::display_doc(app, idx)); // Interface ▸ Show Channels in Color tints single colour channel views on the canvas too, // not just the panel thumbnails (#2385). The view key carries the flag, so toggling it // rebuilds the texture. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73f3357a8c..7d36f29756 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -593,6 +593,7 @@ mod tests { revision: st.revision, dialog: id, active: st.active_layer, + target: st.channel_view.target, command: "filter.blur.gaussianBlur".into(), params: serde_json::json!({"radius": 4.0}), k: 1, diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 29fc436e0f..8f588fbc99 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -745,6 +745,19 @@ pub fn preview_document_with( params: &Value, k: u32, cancel: Option<&photocraft_engine::jobs::JobCtx>, +) -> Option { + preview_document_for_target(doc, active, command, params, k, cancel, photocraft_engine::channel_cmds::ChannelTarget::Composite) +} + +/// Run the preview with the same pixel target as the final command, including individual channels. +pub(crate) fn preview_document_for_target( + doc: &Document, + active: Option, + command: &str, + params: &Value, + k: u32, + cancel: Option<&photocraft_engine::jobs::JobCtx>, + target: photocraft_engine::channel_cmds::ChannelTarget, ) -> Option { if command == crate::select_mask_view::COMMAND { return crate::select_mask_view::preview_document(doc, active, params, k, cancel); @@ -759,6 +772,7 @@ pub fn preview_document_with( if let Some(id) = active { s.select_layer(id).ok()?; } + s.active_mut()?.channel_view.target = target; let mut p = params.clone(); if run_k > 1 && let Some(o) = p.as_object_mut() @@ -802,6 +816,7 @@ pub struct FilterPreviewKey { pub revision: u64, pub dialog: u64, pub active: Option, + pub target: photocraft_engine::channel_cmds::ChannelTarget, pub command: String, pub params: Value, pub k: u32, diff --git a/crates/ui-egui/src/filter_preview_ui.rs b/crates/ui-egui/src/filter_preview_ui.rs index bf2cb47b58..f2b8f89561 100644 --- a/crates/ui-egui/src/filter_preview_ui.rs +++ b/crates/ui-egui/src/filter_preview_ui.rs @@ -54,6 +54,12 @@ pub(crate) fn window(app: &PhotocraftApp, dialog: u64, ui: &mut egui::Ui, f: &mu let t = Tokens::get(ui.ctx()); let cache_id = egui::Id::new(("filter-preview-ui", dialog)); let mut cache: Cache = ui.ctx().data_mut(|d| std::mem::take(d.get_temp_mut_or_default::(cache_id))); + // Channel visibility and the active mask can change without a document revision. Both the + // original (press-and-hold) and filtered textures must follow the canvas's current view. + let view = (st.channel_view.clone(), app.session.prefs().interface.show_channels_in_color, st.active_layer); + if cache.view.as_ref() != Some(&view) { + cache = Cache { view: Some(view), ..Default::default() }; + } // Unfiltered proxy: built once per document revision and factor (cheap at the proxy factor). let orig_key = TexKey::Original(doc_id, revision, base_k); @@ -201,18 +207,18 @@ fn document_size(proxy: [u32; 2], k: u32) -> Vec2 { /// A composite as monitor pixels, matching the canvas (`canvas::display_image`). fn upload(ctx: &egui::Context, app: &PhotocraftApp, name: &str, doc: &Document) -> egui::TextureHandle { - // Never exceed what the GPU accepts (egui panics on an oversized texture): with low-resolution - // previews off the proxy is the full document. The view maps through UV fractions, so a reduced - // texture still lines up. - let k = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); - let reduced; - let doc = if k > 1 { - reduced = crate::proxy::proxy_document(doc, k); - &reduced - } else { - doc - }; - let buf = photocraft_compose::flatten(doc); + // Reduce only the display texture when a full-resolution result exceeds the device limit. + // The cached document and the window's document-pixel zoom coordinates stay at full size. + let factor = texture_factor(doc.size.width, doc.size.height, ctx.input(|i| i.max_texture_side)); + let (width, height) = (doc.size.width.div_ceil(factor), doc.size.height.div_ceil(factor)); + if let Some(st) = app.session.active() + && let Some(pixels) = + crate::channel_view::render(doc, &st.channel_view, doc.bounds(), factor, app.session.prefs().interface.show_channels_in_color, st.active_layer) + { + let image = egui::ColorImage::new([width as usize, height as usize], pixels); + return ctx.load_texture(name, image, egui::TextureOptions::LINEAR); + } + let buf = if factor == 1 { photocraft_compose::flatten(doc) } else { photocraft_compose::render_reduced(doc, width, height) }; let (display, _) = crate::canvas::canvas_display(app, doc, None); let img = crate::canvas::display_image(display.as_deref(), &buf); ctx.load_texture(name, img, egui::TextureOptions::LINEAR) @@ -244,12 +250,111 @@ struct CachedTex { struct Cache { filtered: Option, original: Option, + view: Option<(photocraft_engine::channel_cmds::ChannelView, bool, Option)>, } #[cfg(test)] mod tests { use super::*; + #[test] + fn full_resolution_preview_textures_respect_the_device_limit() { + let ctx = egui::Context::default(); + let limit = ctx.input(|i| i.max_texture_side) as u32; + let width = limit + 2; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": width, "height": 2})).unwrap(); + for alpha in [false, true] { + if alpha { + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, (width / 2) as i32, 2), &[1.0]); + Ok(()) + }) + .unwrap(); + } + let doc = &app.session.active().unwrap().doc; + let texture = upload(&ctx, &app, "full-resolution-preview", doc); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + assert_eq!(texture.size(), [width.div_ceil(2) as usize, 1]); + assert!(texture.size().into_iter().all(|side| side <= limit as usize)); + assert_eq!(doc.size.width, width, "display reduction must not resize the filter result"); + assert_eq!(pixels[0], Color32::WHITE); + assert_eq!(*pixels.last().unwrap(), if alpha { Color32::BLACK } else { Color32::WHITE }); + } + } + + #[test] + fn uploaded_preview_shows_the_filtered_alpha_at_every_depth_and_proxy_scale() { + use photocraft_engine::channel_cmds::ChannelTarget; + for depth in [8, 16, 32] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16, "depth": depth})).unwrap(); + app.run("channel.new", json!({"name": "Alpha", "fill": "black"})).unwrap(); + app.session + .edit("Fixture", |doc, _| { + doc.channels[0].surface.fill_rect(photocraft_geom::Rect::new(0, 0, 8, 16), &[1.0]); + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap(); + for k in [1, 2, 4] { + let preview = crate::filter_dialog::preview_document_for_target( + &st.doc, + st.active_layer, + "filter.blur.gaussianBlur", + &json!({"radius": 2.0}), + k, + None, + ChannelTarget::Alpha(0), + ) + .unwrap(); + let ctx = egui::Context::default(); + let texture = upload(&ctx, &app, "alpha-preview", &preview); + let mut delta = ctx.tex_manager().write().take_delta(); + let egui::ImageData::Color(image) = &delta.set.get(&texture.id()).unwrap()[0].image; + let pixels = image.pixels.clone(); + delta.clear(); + let expected = crate::channel_view::render(&preview, &st.channel_view, preview.bounds(), 1, false, st.active_layer).unwrap(); + assert_eq!(pixels, expected, "depth {depth}, factor {k}"); + assert_ne!(pixels[0], pixels[pixels.len() - 1], "the alpha edge is visible, not the white RGB layer"); + assert!(pixels.iter().any(|p| p.r() > 0 && p.r() < 255), "the blurred edge is visible"); + } + } + } + + #[test] + fn original_texture_refreshes_when_the_channel_view_changes_without_an_edit() { + use photocraft_engine::channel_cmds::ChannelTarget; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.run("channel.new", json!({"name": "White", "fill": "white"})).unwrap(); + app.run("channel.new", json!({"name": "Black", "fill": "black"})).unwrap(); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(0); + let revision = app.session.active().unwrap().revision; + let id = crate::filter_dialog::open(&mut app, "filter.blur.gaussianBlur").unwrap(); + let mut fields = app.ui.dialogs.last().unwrap().fields.clone(); + let ctx = egui::Context::default(); + let cache_id = egui::Id::new(("filter-preview-ui", id)); + ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)).textures_delta.clear(); + let before = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + app.session.active_mut().unwrap().channel_view.target = ChannelTarget::Alpha(1); + assert_eq!(app.session.active().unwrap().revision, revision); + let mut output = ctx.run_ui(Default::default(), |ui| window(&app, id, ui, &mut fields)); + let after = ctx.data(|d| d.get_temp::(cache_id).unwrap().original.unwrap().tex.id()); + let pixels = output.textures_delta.set.get(&after).map(|delta| { + let egui::ImageData::Color(image) = &delta[0].image; + image.pixels.clone() + }); + output.textures_delta.clear(); + assert_ne!(before, after, "press-and-hold must not reuse the old channel"); + assert!(pixels.unwrap().iter().all(|p| *p == Color32::BLACK)); + } + #[test] fn zoom_steps_walk_the_levels_and_stay_in_bounds() { assert_eq!(step_zoom(1.0, true), 2.0); diff --git a/crates/ui-egui/src/filter_preview_worker.rs b/crates/ui-egui/src/filter_preview_worker.rs index 2704f8a6ac..48f29400fb 100644 --- a/crates/ui-egui/src/filter_preview_worker.rs +++ b/crates/ui-egui/src/filter_preview_worker.rs @@ -116,6 +116,7 @@ mod tests { revision, dialog, active: None, + target: Default::default(), command: "image.mode.indexedColor".into(), params: json!({"colors": colors}), k: 1, From 90c42f5555b1ff1cbc708bb6f8bbf28c7387895d Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 18:12:35 -0500 Subject: [PATCH 177/523] Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image.imageSize takes percent (both sides; an explicit side wins). - actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller. - .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs, imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108. - Panel labels conditional steps as 'If cond → then / else'. --- crates/engine/src/actions_cmds.rs | 2 +- crates/engine/src/actions_edit_cmds.rs | 189 +++++++++++++++++++++++++ crates/engine/src/atn_cmds.rs | 137 ++++++++++++++++-- crates/engine/src/atn_cmds/tests.rs | 76 +++++++++- crates/engine/src/image_cmds.rs | 12 +- crates/ui-egui/src/actions.rs | 5 + docs/control-protocol.md | 5 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 11 +- docs/target-app-parity.md | 6 +- 10 files changed, 418 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index edadde2117..3ee242d7d6 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -353,7 +353,7 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio /// Nested playback returns a structured failure even though the command itself succeeded. /// Promote it to the caller's failed step so no later parent steps run after a child fails. pub fn nested_failure(id: &str, value: &Value) -> Option { - (id == "actions.play").then(|| value.get("failed")).flatten().map(|failure| { + (id == "actions.play" || id == "actions.conditional").then(|| value.get("failed")).flatten().map(|failure| { format!( "action `{}` step {} failed: {}", value.get("action").and_then(Value::as_str).unwrap_or(""), diff --git a/crates/engine/src/actions_edit_cmds.rs b/crates/engine/src/actions_edit_cmds.rs index c7cde6869f..1f9cce3431 100644 --- a/crates/engine/src/actions_edit_cmds.rs +++ b/crates/engine/src/actions_edit_cmds.rs @@ -126,6 +126,123 @@ fn assign_set(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The conditions of Photoshop's Insert Conditional, by their string ids. +pub const CONDITIONS: &[&str] = &[ + "always", + "never", + "isOpen", + "landscape", + "square", + "portrait", + "modeIsRGB", + "modeIsCMYK", + "modeIsGrayscale", + "modeIsLab", + "modeIsBitmap", + "modeIsIndexed", + "modeIsDuotone", + "modeIsMultichannel", + "depth8", + "depth16", + "depth32", + "unsaved", + "hasSelection", + "hasLayers", + "hasAlphaChannels", + "isBackground", + "isPixelLayer", + "isAdjustmentLayer", + "isShapeLayer", + "isTypeLayer", + "isSmartObject", + "isLayerGroup", + "isLocked", + "isVisible", + "hasPixelMask", + "hasVectorMask", + "hasEffects", +]; + +/// Whether `condition` holds for the active document and layer. Layer conditions are false +/// without a document or an active layer; `isOpen` is whether a document is open. +pub fn evaluate(s: &Session, condition: &str) -> Result { + use photocraft_color::ColorMode; + use photocraft_doc::LayerContent; + if !CONDITIONS.contains(&condition) { + return Err(bad("actions.conditional", format!("unknown condition `{condition}` ({})", CONDITIONS.join("|")))); + } + let Some(d) = s.active() else { return Ok(condition == "always") }; + let doc = &d.doc; + let layer = d.active_layer.and_then(|id| doc.layer(id)); + let (w, h) = (doc.size.width, doc.size.height); + Ok(match condition { + "always" | "isOpen" => true, + "never" => false, + "landscape" => w > h, + "square" => w == h, + "portrait" => h > w, + "modeIsRGB" => doc.mode == ColorMode::Rgb, + "modeIsCMYK" => doc.mode == ColorMode::Cmyk, + "modeIsGrayscale" => doc.mode == ColorMode::Grayscale, + "modeIsLab" => doc.mode == ColorMode::Lab, + "modeIsBitmap" => doc.mode == ColorMode::Bitmap, + "modeIsIndexed" => doc.mode == ColorMode::Indexed, + "modeIsDuotone" => doc.mode == ColorMode::Duotone, + "modeIsMultichannel" => doc.mode == ColorMode::Multichannel, + "depth8" => doc.depth == photocraft_color::SampleType::U8, + "depth16" => doc.depth == photocraft_color::SampleType::U16, + "depth32" => doc.depth == photocraft_color::SampleType::F32, + "unsaved" => d.is_dirty(), + "hasSelection" => doc.selection.is_some(), + // More than a lone Background: any layer that isn't the background. + "hasLayers" => doc.walk().iter().any(|(_, _, l)| !doc.is_background(l.id)), + "hasAlphaChannels" => !doc.channels.is_empty(), + "isBackground" => layer.is_some_and(|l| doc.is_background(l.id)), + "isPixelLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !doc.is_background(l.id)), + "isAdjustmentLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Adjustment(_))), + "isShapeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Shape(_))), + "isTypeLayer" => layer.is_some_and(|l| matches!(l.content, LayerContent::Text(_))), + "isSmartObject" => layer.is_some_and(|l| matches!(l.content, LayerContent::Smart(_))), + "isLayerGroup" => layer.is_some_and(|l| l.is_group()), + "isLocked" => layer.is_some_and(|l| l.locks.all || l.locks.pixels || l.locks.position || l.locks.transparency), + "isVisible" => layer.is_some_and(|l| l.visible), + "hasPixelMask" => layer.is_some_and(|l| l.mask.is_some()), + "hasVectorMask" => layer.is_some_and(|l| l.vector_mask.is_some()), + "hasEffects" => layer.is_some_and(|l| !l.effects.items.is_empty()), + _ => false, + }) +} + +/// Photoshop's Insert Conditional as a step: test the condition, play `then` or `else`. +fn conditional(s: &mut Session, p: &Value) -> Result { + let cmd = "actions.conditional"; + let condition = match p.get("if") { + Some(Value::String(c)) => c.clone(), + _ => return Err(bad(cmd, format!("needs `if`, one of {}", CONDITIONS.join("|")))), + }; + let branch = |key: &str| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(n)) if !n.trim().is_empty() => Ok(Some(n.clone())), + Some(Value::String(_)) => Ok(None), + Some(_) => Err(bad(cmd, format!("`{key}` must be an action name or null"))), + } + }; + let (then, otherwise) = (branch("then")?, branch("else")?); + let holds = evaluate(s, &condition)?; + let played = if holds { then } else { otherwise }; + let mut out = json!({"if": condition, "holds": holds, "played": played}); + if let Some(name) = played { + let r = s.execute("actions.play", json!({"action": name}))?; + if let Some(f) = r.get("failed") { + out["failed"] = f.clone(); + } + out["ran"] = r.get("ran").cloned().unwrap_or(json!(0)); + out["action"] = json!(name); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ CommandSpec { @@ -148,6 +265,16 @@ pub fn specs() -> Vec { run: dialog, journal: false, }, + CommandSpec { + id: "actions.conditional", + label: "Conditional", + menu: &[], + shortcut: None, + params: r##"{"if":condition, "then":action name|null, "else":action name|null} → {if, holds, played, ran?, failed?}. Photoshop's Insert Conditional: conditions always|never|isOpen|landscape|square|portrait|modeIsRGB|modeIsCMYK|modeIsGrayscale|modeIsLab|modeIsBitmap|modeIsIndexed|modeIsDuotone|modeIsMultichannel|depth8|depth16|depth32|unsaved|hasSelection|hasLayers|hasAlphaChannels|isBackground|isPixelLayer|isAdjustmentLayer|isShapeLayer|isTypeLayer|isSmartObject|isLayerGroup|isLocked|isVisible|hasPixelMask|hasVectorMask|hasEffects (layer conditions test the active layer). The branch plays as actions.play does; its failure stops the calling action."##, + enabled: always, + run: conditional, + journal: true, + }, CommandSpec { id: "actions.assignSet", label: "Move to Set", @@ -223,6 +350,68 @@ mod tests { assert_eq!(s.actions.list[0].dialogs.iter().copied().collect::>(), [0]); } + #[test] + fn conditional_tests_the_document_and_plays_a_branch() { + let mut s = session_with(&[]); + s.actions.list.push(Action::new("Yes", vec![("layer.new.layer".into(), json!({"name": "yes"}))])); + s.actions.list.push(Action::new("No", vec![("layer.new.layer".into(), json!({"name": "no"}))])); + let names = |s: &Session| s.active().unwrap().doc.walk().iter().map(|(_, _, l)| l.name.clone()).collect::>(); + // 8 × 8: square, not landscape; one Background: no layers yet. + for (cond, expect) in [ + ("square", true), + ("landscape", false), + ("portrait", false), + ("modeIsRGB", true), + ("modeIsCMYK", false), + ("depth8", true), + ("hasLayers", false), + ("hasSelection", false), + ("isBackground", true), + ("isPixelLayer", false), + ("hasEffects", false), + ("always", true), + ("never", false), + ("isOpen", true), + ] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "square", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str(), r["ran"].as_u64()), (Some(true), Some("Yes"), Some(1))); + assert_eq!(names(&s).last().map(String::as_str), Some("yes")); + for (cond, expect) in [("hasLayers", true), ("isPixelLayer", true), ("isBackground", false), ("isVisible", true)] { + assert_eq!(evaluate(&s, cond).unwrap(), expect, "{cond}"); + } + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes", "else": "No"})).unwrap(); + assert_eq!((r["holds"].as_bool(), r["played"].as_str()), (Some(false), Some("No"))); + assert_eq!(names(&s).last().map(String::as_str), Some("no")); + // No branch for the outcome: nothing plays, no error. + let r = s.execute("actions.conditional", json!({"if": "landscape", "then": "Yes"})).unwrap(); + assert!(r["played"].is_null()); + // A failing branch reports like a nested Play, and stops a calling action. + s.actions.list.push(Action::new("Bad", vec![("layer.delete".into(), json!({"layer": 999}))])); + let r = s.execute("actions.conditional", json!({"if": "always", "then": "Bad"})).unwrap(); + assert!(r["failed"].is_object(), "{r}"); + assert!(crate::actions_cmds::nested_failure("actions.conditional", &r).is_some()); + s.actions.list.push(Action::new( + "Caller", + vec![("actions.conditional".into(), json!({"if": "always", "then": "Bad"})), ("layer.new.layer".into(), json!({"name": "after"}))], + )); + let played = s.execute("actions.play", json!({"action": "Caller"})).unwrap(); + assert_eq!(played["failed"]["step"], 0, "{played}"); + assert_ne!(names(&s).last().map(String::as_str), Some("after")); + // Without a document only always/never answer; layer conditions are false. + let t = Session::new(); + assert!(evaluate(&t, "always").unwrap()); + assert!(!evaluate(&t, "isOpen").unwrap()); + assert!(!evaluate(&t, "isBackground").unwrap()); + for p in [json!({}), json!({"if": "sometimes"}), json!({"if": 3}), json!({"if": "always", "then": 5}), json!({"if": "always", "then": "Nope"})] { + assert!( + s.execute("actions.conditional", p.clone()).is_err() || s.execute("actions.conditional", p.clone()).unwrap().get("failed").is_some(), + "{p}" + ); + } + } + #[test] fn assign_set_moves_an_action_between_folders() { let mut s = session_with(&["select.all"]); diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index fa9e649c0b..e5c8e74674 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -164,8 +164,9 @@ fn class(c: &str) -> Class { Class { name: UnicodeString::new_nul(""), class_id: code(c) } } -/// The pixel unit of a `UntF` value. +/// The pixel and percent units of a `UntF` value. const PX: [u8; 4] = *b"#Pxl"; +const PCT: [u8; 4] = *b"#Prc"; fn px(v: f64) -> D { D::UnitFloat { unit: PX, value: v } @@ -1338,6 +1339,69 @@ fn clear_to(id: &str, p: &Value) -> Option { } } +/// `placeEvent`: File › Place Embedded / Linked, with the file and the recorded scale. +fn place_from(d: &Descriptor) -> Mapped { + let Some(path) = d.get("null").and_then(path_text) else { return Mapped::Unsupported { harmless: false } }; + let mut p = json!({"path": path}); + if let Some(w) = get_num(d, "Wdth") { + p["scale"] = json!(w); + } + step(if get_bool(d, "Lnkd") == Some(true) { "file.placeLinked" } else { "file.placeEmbedded" }, p) +} + +fn place_to(id: &str, p: &Value) -> Option { + let mut d = desc("null").with("null", path_value(jstr(p, "path")?)).with("FTcs", enm("QCSt", "Qcsa")); + if let Some(s) = jnum(p, "scale") { + d = d.with("Wdth", pct(s)).with("Hght", pct(s)); + } + Some(d.with("Lnkd", D::Boolean(id == "file.placeLinked")).with("Antl", D::Boolean(true))) +} + +/// `open`: File › Open of a recorded path. +fn open_from(d: &Descriptor) -> Mapped { + match d.get("null").and_then(path_text) { + Some(path) => step("file.openAs", json!({"path": path})), + None => Mapped::Unsupported { harmless: false }, + } +} + +fn open_to(_: &str, p: &Value) -> Option { + Some(desc("null").with("null", path_value(jstr(p, "path")?))) +} + +/// Photoshop's four-character condition codes that differ from the string ids. +const CONDITION_CODES: &[(&str, &str)] = &[("Lyrs", "hasLayers")]; + +/// `conditional`: Insert Conditional, with its Then and Else actions by name. +fn conditional_from(d: &Descriptor) -> Mapped { + let Some(c) = get_enum(d, "null") else { return Mapped::Unsupported { harmless: false } }; + let condition = lookup(CONDITION_CODES, &c).unwrap_or(c.as_str()); + if !crate::actions_edit_cmds::CONDITIONS.contains(&condition) { + return Mapped::Unsupported { harmless: false }; + } + let branch = |key: &str| match get_ref(d, key).map(target) { + Some(Target::Name(n)) => Some(n), + _ => None, + }; + step("actions.conditional", json!({"if": condition, "then": branch("then"), "else": branch("else")})) +} + +fn conditional_to(_: &str, p: &Value) -> Option { + let condition = jstr(p, "if")?; + let code = lookup_back(CONDITION_CODES, condition).unwrap_or(condition); + let mut d = desc("null").with("null", enm("Cndt", code)); + for key in ["then", "else"] { + if let Some(name) = jstr(p, key) { + let mut items = vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul(name) }]; + if let Some(set) = jstr(p, "set") { + items.push(ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul(set) }); + } + d = d.with(key, D::Reference(items)); + } + } + Some(d) +} + /// `close`: Photoshop's Save prompt answer becomes a Save step before the Close. fn close_from(d: &Descriptor) -> Mapped { if get_enum(d, "Svng").as_deref() == Some("Ys") { steps(vec![("file.save", json!({})), ("file.close", json!({}))]) } else { none("file.close") } @@ -1712,9 +1776,36 @@ fn play_to(_: &str, p: &Value) -> Option { } fn fill_from(d: &Descriptor) -> Mapped { - if d.get("From").is_some() { - // The Paint Bucket tool's click, not Edit › Fill. - return Mapped::Unsupported { harmless: false }; + if let Some(from) = get_obj(d, "From") { + // The Paint Bucket tool's click: its point is in pixels, or in percent of the canvas + // (not replayable without the document size). + let in_px = |k: &str| matches!(from.get(k), Some(D::UnitFloat { unit: PX, .. })); + let (Some(x), Some(y)) = (get_num(from, "Hrzn"), get_num(from, "Vrtc")) else { return Mapped::Unsupported { harmless: false } }; + if !in_px("Hrzn") || !in_px("Vrtc") { + return Mapped::Unsupported { harmless: false }; + } + let mut p = json!({"x": x.round(), "y": y.round()}); + match get_enum(d, "Usng").as_deref() { + Some("FrgC") | None => {} + Some("Pttn") => p["contents"] = json!("pattern"), + Some(_) => return Mapped::Unsupported { harmless: false }, + } + if let Some(t) = get_num(d, "Tlrn") { + p["tolerance"] = json!(t); + } + if let Some(c) = get_bool(d, "Cntg") { + p["contiguous"] = json!(c); + } + if let Some(a) = get_bool(d, "AntA") { + p["antiAlias"] = json!(a); + } + if let Some(m) = get_bool(d, "Mrgd") { + p["sampleAllLayers"] = json!(m); + } + if let Some(o) = get_num(d, "Opct") { + p["opacity"] = json!(o); + } + return step("paint.bucket", p); } let Some(contents) = get_enum(d, "Usng").and_then(|u| lookup(FILL_CONTENTS, &u)) else { return Mapped::Unsupported { harmless: false } }; let mut p = json!({"contents": contents}); @@ -1739,7 +1830,20 @@ fn fill_from(d: &Descriptor) -> Mapped { step("edit.fill", p) } -fn fill_to(_: &str, p: &Value) -> Option { +fn fill_to(id: &str, p: &Value) -> Option { + if id == "paint.bucket" { + let mut d = desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(jnum(p, "x")?)).with("Vrtc", px(jnum(p, "y")?)))); + d = d.with("Tlrn", long(jnum(p, "tolerance").unwrap_or(32.0))).with("AntA", D::Boolean(jbool(p, "antiAlias").unwrap_or(true))); + d = d.with("Usng", enm("FlCn", if jstr(p, "contents") == Some("pattern") { "Pttn" } else { "FrgC" })); + if let Some(o) = jnum(p, "opacity") { + d = d.with("Opct", pct(o)); + } + d = d.with("Cntg", D::Boolean(jbool(p, "contiguous").unwrap_or(true))); + if let Some(m) = jbool(p, "sampleAllLayers") { + d = d.with("Mrgd", D::Boolean(m)); + } + return Some(d); + } let contents = jstr(p, "contents").unwrap_or("color"); let mut d = desc("null").with("Usng", enm("FlCn", lookup_back(FILL_CONTENTS, contents)?)); if contents == "color" { @@ -1935,16 +2039,25 @@ fn canvas_size_to(_: &str, p: &Value) -> Option { fn image_size_from(d: &Descriptor) -> Mapped { let mut p = Map::new(); + let mut percent: Option = None; for (k, param) in [("Wdth", "width"), ("Hght", "height")] { match d.get(k) { Some(D::UnitFloat { unit: PX, value }) => { p.insert(param.into(), value.round().into()); } - // Percent or physical units need the document's size: not replayable headlessly. + // Percent scales both sides at playback; two different percentages, or physical + // units, need the document's size and resolution and stay unsupported. + Some(D::UnitFloat { unit: PCT, value }) => match percent { + Some(other) if (other - value).abs() > 1e-9 => return Mapped::Unsupported { harmless: false }, + _ => percent = Some(*value), + }, Some(_) => return Mapped::Unsupported { harmless: false }, None => {} } } + if let Some(pc) = percent { + p.insert("percent".into(), pc.into()); + } if let Some(r) = get_num(d, "Rslt") { p.insert("resolution".into(), r.into()); } @@ -1955,7 +2068,7 @@ fn image_size_from(d: &Descriptor) -> Mapped { } None => return Mapped::Unsupported { harmless: false }, } - } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") { + } else if p.contains_key("resolution") && !p.contains_key("width") && !p.contains_key("height") && !p.contains_key("percent") { p.insert("resample".into(), "none".into()); } if p.is_empty() { @@ -1966,6 +2079,9 @@ fn image_size_from(d: &Descriptor) -> Mapped { fn image_size_to(_: &str, p: &Value) -> Option { let mut d = desc("null"); + if let Some(pc) = jnum(p, "percent") { + d = d.with("Wdth", pct(pc)).with("Hght", pct(pc)).with("CnsP", D::Boolean(true)); + } if let Some(w) = jnum(p, "width") { d = d.with("Wdth", px(w)); } @@ -2459,7 +2575,7 @@ const EVENTS: &[Event] = &[ ev!("Grow", "grow", "Grow", "select.grow", &[F("Tlrn", "tolerance", Kind::Long)]), ev!("Smlr", "similar", "Similar", "select.similar", &[F("Tlrn", "tolerance", Kind::Long)]), // Edit. - Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill"], from: fill_from, to: fill_to }, + Event { code: "Fl ", name: "fill", label: "Fill", ids: &["edit.fill", "paint.bucket"], from: fill_from, to: fill_to }, ev!("Strk", "stroke", "Stroke", "edit.stroke", STROKE), ev!("copy", "copyEvent", "Copy", "edit.copy", &[]), ev!("CpyM", "copyMerged", "Copy Merged", "edit.copyMerged", &[]), @@ -2494,6 +2610,9 @@ const EVENTS: &[Event] = &[ // File and actions. Event { code: "Cls ", name: "close", label: "Close", ids: &["file.close"], from: close_from, to: close_to }, Event { code: "Expr", name: "export", label: "Export", ids: &["file.export.saveForWebLegacy"], from: export_from, to: export_to }, + Event { code: "Plc ", name: "placeEvent", label: "Place", ids: &["file.placeEmbedded", "file.placeLinked"], from: place_from, to: place_to }, + Event { code: "Opn ", name: "open", label: "Open", ids: &["file.openAs"], from: open_from, to: open_to }, + Event { code: "", name: "conditional", label: "If", ids: &["actions.conditional"], from: conditional_from, to: conditional_to }, ev!("save", "save", "Save", "file.save", &[]), ev!("Rvrt", "revert", "Revert", "file.revert", &[]), Event { code: "Ply ", name: "play", label: "Play", ids: &["actions.play"], from: play_from, to: play_to }, @@ -2630,7 +2749,7 @@ fn export_item(id: &str, params: &Value, set: &str) -> Option { return Some(AtnItem { event, dictionary: jstr(params, "name").unwrap_or("").to_string(), descriptor, ..Default::default() }); } let mut params = params.clone(); - if id == "actions.play" && params.get("set").is_none() { + if (id == "actions.play" || id == "actions.conditional") && params.get("set").is_none() { params["set"] = json!(set); } let (e, descriptor) = event_and_descriptor(id, ¶ms)?; diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 6d6f7bb68e..fc73a18412 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -286,6 +286,13 @@ fn every_mapped_command_exports_and_imports_back() { ("layer.smartObjects.convertToSmartObject", json!({})), ("layer.smartObjects.editContents", json!({})), ("layer.smartObjects.updateModifiedContent", json!({})), + ("paint.bucket", json!({"x": 10, "y": 20, "tolerance": 32, "antiAlias": true, "contiguous": false, "opacity": 100})), + ("image.imageSize", json!({"percent": 200, "resample": "bicubicAutomatic"})), + ("file.placeEmbedded", json!({"path": "/tmp/logo.png", "scale": 50})), + ("file.placeLinked", json!({"path": "/tmp/logo.png"})), + ("file.openAs", json!({"path": "/tmp/a.psd"})), + ("actions.conditional", json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers", "set": "S"})), + ("actions.conditional", json!({"if": "landscape", "then": "Wide", "else": null, "set": "S"})), ("actions.play", json!({"action": "Other", "set": "S"})), (STOP_MESSAGE, json!({"message": "Hi", "allowContinue": false})), ]; @@ -303,7 +310,7 @@ fn every_mapped_command_exports_and_imports_back() { assert_eq!(steps[0].0, *id, "{id} came back as another command"); let mut back = steps[0].1.clone(); normalise(&mut back); - if *id == "actions.play" { + if *id == "actions.play" || *id == "actions.conditional" { back["set"] = json!("S"); } assert_eq!(back, *params, "{id}"); @@ -401,10 +408,15 @@ fn every_mapped_command_exports_and_imports_back() { #[test] fn unsupported_shapes_become_photoshop_steps_not_errors() { - // Percent image size, a Paint Bucket fill, a move in points, an unknown adjustment class. + // Image size in two different percentages, a Paint Bucket click in percent of the canvas, a + // move in points, an unknown adjustment class. let items = vec![ - item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)))), - item("Fl ", "Fill", Some(desc("null").with("From", D::Descriptor(desc("Pnt "))).with("Usng", enm("FlCn", "FrgC")))), + item("ImgS", "Image Size", Some(desc("null").with("Wdth", pct(200.0)).with("Hght", pct(150.0)))), + item( + "Fl ", + "Fill", + Some(desc("null").with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))).with("Usng", enm("FlCn", "FrgC"))), + ), item( "move", "Move", @@ -662,6 +674,62 @@ fn remaining_events_import_in_their_photoshop_shapes() { assert_eq!(steps[13].1, json!({"points": [[1, 2], [30, 2], [15, 40]]})); assert_eq!(imp.unsupported.len(), 2); + // The frequency-separation set's own conditional, a percent Image Size, a Paint Bucket + // click in pixels (percent stays unsupported) and a Place. + let cond = desc("null") + .with("null", enm("Cndt", "Lyrs")) + .with( + "then", + D::Reference(vec![ + ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-Layers") }, + ReferenceItem::Name { class: class("ASet"), name: UnicodeString::new_nul("FS") }, + ]), + ) + .with("else", D::Reference(vec![ReferenceItem::Name { class: class("Actn"), name: UnicodeString::new_nul("FS8-No-Layers") }])); + let size = desc("null") + .with("Wdth", pct(200.0)) + .with("scaleStyles", D::Boolean(true)) + .with("CnsP", D::Boolean(true)) + .with("Intr", enm("Intp", "bicubicAutomatic")); + let bucket_px = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", px(0.0)).with("Vrtc", px(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")) + .with("Cntg", D::Boolean(false)); + let bucket_pct = desc("null") + .with("From", D::Descriptor(desc("Pnt ").with("Hrzn", pct(0.0)).with("Vrtc", pct(0.0)))) + .with("Tlrn", long(32.0)) + .with("Usng", enm("FlCn", "FrgC")); + let place = desc("null") + .with("null", path_value("/Users/me/logo.png")) + .with("FTcs", enm("QCSt", "Qcsa")) + .with("Wdth", pct(50.0)) + .with("Hght", pct(50.0)) + .with("Lnkd", D::Boolean(false)); + let items = vec![ + item("conditional", "If", Some(cond)), + item("imageSize", "Image Size", Some(size)), + item("fill", "Fill", Some(bucket_px)), + item("fill", "Fill", Some(bucket_pct)), + item("placeEvent", "Place", Some(place)), + item("Opn ", "Open", Some(desc("null").with("null", path_value("/Users/me/in.psd")))), + ]; + let file = AtnFile { + name: UnicodeString::new_nul("Rest"), + expanded: false, + actions: vec![AtnAction { name: UnicodeString::new_nul("A"), items, ..Default::default() }], + warnings: vec![], + }; + let rest = import(&atn::write(&file).unwrap()).unwrap(); + let rest_steps = &rest.actions[0].steps; + let rest_ids: Vec<&str> = rest_steps.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(rest_ids, ["actions.conditional", "image.imageSize", "paint.bucket", PHOTOSHOP_STEP, "file.placeEmbedded", "file.openAs"]); + assert_eq!(rest_steps[0].1, json!({"if": "hasLayers", "then": "FS8-Layers", "else": "FS8-No-Layers"})); + assert_eq!(rest_steps[1].1, json!({"percent": 200, "resample": "bicubicAutomatic"})); + assert_eq!(rest_steps[2].1, json!({"x": 0, "y": 0, "tolerance": 32, "contiguous": false})); + assert_eq!(rest_steps[4].1, json!({"path": "/Users/me/logo.png", "scale": 50})); + assert_eq!(rest_steps[5].1, json!({"path": "/Users/me/in.psd"})); + // The Photoshop path record round-trips and rejects other shapes. assert_eq!(path_text(&path_value("/a/b c.png")).as_deref(), Some("/a/b c.png")); assert_eq!(path_text(&D::Path(b"txtu".to_vec())), None); diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 84474d57d1..d26d87e517 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -165,8 +165,14 @@ fn image_size(s: &mut Session, p: &Value) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let (ow, oh) = (d.doc.size.width, d.doc.size.height); let resample = parse_resample(p.get("resample").and_then(Value::as_str).unwrap_or("bicubic")); - let pw = p.get("width").and_then(Value::as_f64); - let ph = p.get("height").and_then(Value::as_f64); + let mut pw = p.get("width").and_then(Value::as_f64); + let mut ph = p.get("height").and_then(Value::as_f64); + // `percent` scales both sides (what a Photoshop action records as "200%"); an explicit side wins. + if let Some(pc) = p.get("percent") { + let pc = pc.as_f64().filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad("image.imageSize", "`percent` must be a positive number"))?; + pw = pw.or(Some(ow as f64 * pc / 100.0)); + ph = ph.or(Some(oh as f64 * pc / 100.0)); + } // Missing dimension keeps the aspect ratio. let (nw, nh) = match (pw, ph) { (Some(w), Some(h)) => (w, h), @@ -713,7 +719,7 @@ pub fn specs() -> Vec { "image.imageSize", "Image Size…", ["Image"], - r##"{"width":px,"height":px,"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, + r##"{"width":px,"height":px,"percent":%? (both sides; an explicit side wins),"resolution":ppi,"resample":"automatic|preserveDetails|bicubicSmoother|bicubicSharper|bicubicAutomatic|bicubic|nearest|bilinear|lanczos|none"="bicubic"} → {width,height} (the document's size after the command; "none" changes the resolution only)"##, has_doc, image_size ), diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 997770b5f6..7902a39bba 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -248,6 +248,11 @@ fn step_label(id: &str, params: &Value) -> String { if id == "actions.play" { let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default(); format!("{}: {target}", label_of(id)) + } else if id == "actions.conditional" { + // "If hasLayers: FS8-Layers / FS8-No-Layers" + let cond = params.get("if").and_then(Value::as_str).unwrap_or("?"); + let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string(); + format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else")) } else if id == photocraft_engine::atn_cmds::STOP_MESSAGE { format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default()) } else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b775728aff..f23745efdb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -535,7 +535,10 @@ step with its dialog on opens that command's dialog seeded with the recorded val the play result carries `paused: {step, id, dialog}`, OK runs the dialog's values and continues from the next step, Cancel stops the action. Agents (`automation_input`) play headlessly with the recorded values. The panel menu's **Insert Stop…** adds an `actions.stopMessage` step after the -selected step (`actions.insert` takes any command id and params). +selected step (`actions.insert` takes any command id and params). `actions.conditional {if, then, +else}` is Photoshop's Insert Conditional as a step: it tests the document or active layer +(`always`, `landscape`, `modeIsRGB`, `hasLayers`, `isBackground`, `hasPixelMask`… see its params) +and plays the matching action by name, failing the caller when that action fails. Layer-panel **Quick Export as PNG** exports every selected layer as a separate PNG. A single Save dialog chooses the first filename; remaining files are numbered siblings with sanitized diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bc892c0ed3..d6e3bef284 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,7 +130,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | LUTs `.cube` / `.3dl` / `.look` | R/W (Export Color Lookup) | R/W / R / R | `cms/src/lutfile.rs` | | Keyboard shortcuts `.kys` | R/W | R | `engine/src/kys.rs` | | ICC profiles | R | R | Own CMS | -| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (105 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | +| Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | | Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | diff --git a/docs/gaps.md b/docs/gaps.md index c5e1172415..d18a8655c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -89,12 +89,13 @@ what an agent can't finish alone. ### G11. Actions and scripting - **Missing:** document-independent action recording (AUTO-219-1), Batch recursion and naming (AUTO-219-5), ExtendScript/UXP compatibility. `.atn` steps outside the - mapped events (Place, Image Size in percent, paths, conditionals, Generative Fill…) load as - kept Photoshop steps that fail at playback by name. + mapped events (paths, scripts, Generative Fill, offsets in points…) load as kept Photoshop + steps that fail at playback by name. - **Done 2026-10-12:** Load/Save Actions `.atn` in the panel menu, `batch --actions set.atn` - and `actions.load`/`actions.save`; 105 Photoshop events map to commands both ways (Transform, - Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask and polygon selections - since the third PR), Play and Stop steps included; layer steps replay by layer name in any + and `actions.load`/`actions.save`; 108 Photoshop events map to commands both ways (Transform, + Apply Image, Save for Web, smart-object edits, Gradient Map, Quick Mask, polygon selections, + Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional since the third + and fourth PRs), Play, Stop and conditional steps included; layer steps replay by layer name in any document. The panel groups actions in set folders, has the dialog toggle column (playback pauses on the step's dialog and resumes on OK) and Insert Stop…; `actions.insert`, `actions.dialog`, `actions.assignSet` for agents diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ae076b3208..b5e8dd8a04 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -66,7 +66,7 @@ architecture and the PSD writer are the serial parts. | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | | AI / generative | 10% | classical Select Subject/Sky, Remove tool, Content-Aware; no Generative Fill/Expand, Neural Filters, Sky Replacement, Super Zoom | ~15% | estimated | -| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (105 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | +| Ecosystem (plug-ins, scripting, libraries) | 10% | WebAssembly plug-ins, CLI, MCP, actions, `.atn` load/save (108 events); no .8BF, UXP/ExtendScript, Libraries | ~20% | estimated | | **Weighted** | 100% | | **~76%** | | ## Ready for real work: ~45% (weighted) @@ -83,7 +83,7 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | | Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | | Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | -| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 105 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | +| Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -177,7 +177,7 @@ parity for the area and are already contained in the dimension rows above. | Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | -| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (105 events mapped, the rest kept verbatim); no ExtendScript/UXP | +| Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | | File I/O | 60% | 300–500 | See [file-format-parity.md](file-format-parity.md) | | Print, export, Save for Web | 50% | 60–100 | JPEG Options dialog missing (FILE-215-19), no Photoshop PDF, no separations, Save for Web settings not remembered | | Video / timeline, data-driven graphics | 30% | 100–200 | Menu items live; frame model persists in `.pcraft`; no PSD video-layer mapping | From 67c90e307f1dbc4d918423e4a7a8932f73afe0eb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:12:59 +1100 Subject: [PATCH 178/523] Quantize the colour field when Only Web Colors is on. (#4138) * Quantize the colour field when Only Web Colors is on. The picked colour already snapped to the web-safe ramp, but the field and slider stayed continuous, so the restriction was invisible. Fixes #4107 * Only Web Colors: draw the field and slider in flat web-safe bands The coarse interpolated mesh blended between snapped corners, so it showed a smooth low-resolution gradient rather than bands. Draw flat cells sampled at their centres instead, and test that every cell is a single web-safe colour. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/color_picker_ui.rs | 56 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index e9729eab67..30752c462f 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -400,6 +400,36 @@ fn grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh m } +/// Cells per side of the banded Only Web Colors field: fine enough that the band edges follow the +/// colour model, coarse enough to stay cheap to draw each frame. +const WEB_CELLS: usize = 64; + +/// Snap each channel to the web-safe ramp (00, 33, 66, 99, CC, FF). +fn web_safe(c: [f32; 3]) -> [f32; 3] { + c.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0) +} + +/// Like [`grid_mesh`], but every cell is one flat colour sampled at its centre, so a quantized +/// field shows hard bands instead of blending between the snapped corners. +fn flat_grid_mesh(rect: Rect, n: usize, color: impl Fn(f32, f32) -> [f32; 3]) -> Mesh { + let mut m = Mesh::default(); + let n = n.max(1); + let (cw, ch) = (rect.width() / n as f32, rect.height() / n as f32); + for j in 0..n { + for i in 0..n { + let c = c32(color((i as f32 + 0.5) / n as f32, (j as f32 + 0.5) / n as f32)); + let (x0, y0) = (rect.left() + i as f32 * cw, rect.top() + j as f32 * ch); + let a = m.vertices.len() as u32; + for (dx, dy) in [(0.0, 0.0), (cw, 0.0), (cw, ch), (0.0, ch)] { + m.colored_vertex(pos2(x0 + dx, y0 + dy), c); + } + m.add_triangle(a, a + 1, a + 2); + m.add_triangle(a, a + 2, a + 3); + } + } + m +} + /// Room for both component columns, including units, without overflowing the title's width; with /// the frame and title the window fits Photoshop's (about 534 x 376 points). pub(crate) const CONTENT_WIDTH: f32 = 488.0; @@ -469,7 +499,12 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let (fx, fy, fz) = locate(&mode, hsv, rgb, lab); // Colour field. let (field, resp) = ui.allocate_exact_size(vec2(FIELD, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y))); + let web_only = f.get("__webOnly").and_then(Value::as_bool).unwrap_or(false); + // Only Web Colors bands the field to the 216 web-safe colours, as in Photoshop (#4107). + let mesh = |rect: Rect, color: &dyn Fn(f32, f32) -> [f32; 3]| { + if web_only { flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(color(x, y))) } else { grid_mesh(rect, 32, color) } + }; + ui.painter().add(mesh(field, &|x, y| field_color(&mode, fz, x, y))); ui.painter().rect_stroke(field, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); let marker = pos2(field.left() + fx * field.width(), field.top() + fy * field.height()); ui.painter().circle_stroke(marker, 5.0, Stroke::new(1.5, if hsv[2] > 0.6 && hsv[1] < 0.4 { Color32::BLACK } else { Color32::WHITE })); @@ -486,7 +521,7 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { } // Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop). let (strip, sresp) = ui.allocate_exact_size(vec2(18.0, FIELD), Sense::click_and_drag()); - ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); + ui.painter().add(mesh(strip, &|_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); let sy = strip.top() + (1.0 - fz) * strip.height(); for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; @@ -767,6 +802,23 @@ mod tests { use super::*; + /// #4107: with Only Web Colors on, the field is drawn in flat web-safe bands. + #[test] + fn web_only_field_is_flat_web_safe_bands() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 200.0)); + let m = flat_grid_mesh(rect, WEB_CELLS, |x, y| web_safe(field_color("h", 0.3, x, y))); + let ramp = [0u8, 51, 102, 153, 204, 255]; + for v in &m.vertices { + assert!([v.color.r(), v.color.g(), v.color.b()].iter().all(|c| ramp.contains(c)), "{:?}", v.color); + } + // Each cell's four corners share one colour (no blending between bands). + for q in m.vertices.chunks(4) { + assert!(q.iter().all(|v| v.color == q[0].color)); + } + let distinct: std::collections::HashSet<_> = m.vertices.iter().map(|v| v.color).collect(); + assert!(distinct.len() > 1 && distinct.len() <= 216); + } + fn picker(color: &str) -> Harness<'static, Map> { let mut f = Map::new(); f.insert("__colorPicker".into(), json!("foreground")); From fd4ba52f794a53f98abc65850a224fc723db64aa Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:17:25 +0700 Subject: [PATCH 179/523] fix(ui): cancel unfinished polygon on lasso tool switch (#4021) --- crates/ui-egui/src/lib.rs | 5 +++++ crates/ui-egui/src/polygon_lasso_tests.rs | 26 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a144477cdb..c9847d8638 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1056,6 +1056,11 @@ impl PhotocraftApp { type_transform::cancel_stale(self); crate::cam_transform::cancel_stale(self); crate::lasso_ui::cancel_stale(self); + // A polygon under construction belongs to the Polygonal Lasso tool, not the next tool. + if self.ui.tool != Tool::PolygonLasso { + self.ui.polygon.clear(); + self.ui.polygon_mode.clear(); + } crate::crop_ui::cancel_stale(self); let ids: Vec = self.session.documents().iter().map(|d| d.doc.id).collect(); // Where the document of view `i` is now. Views not tracked yet keep their index. diff --git a/crates/ui-egui/src/polygon_lasso_tests.rs b/crates/ui-egui/src/polygon_lasso_tests.rs index ed46a7e50b..1f4de7877e 100644 --- a/crates/ui-egui/src/polygon_lasso_tests.rs +++ b/crates/ui-egui/src/polygon_lasso_tests.rs @@ -82,6 +82,32 @@ fn near(r: Rect, x0: i32, y0: i32, x1: i32, y1: i32) -> bool { r.x0.abs_diff(x0) <= 1 && r.y0.abs_diff(y0) <= 1 && r.x1.abs_diff(x1) <= 1 && r.y1.abs_diff(y1) <= 1 } +#[test] +fn switching_tools_discards_unfinished_polygon_without_changing_selection() { + let mut app = polygon_app(); + app.run("select.rect", json!({"x": 200, "y": 150, "width": 50, "height": 50})).unwrap(); + let before = app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(); + let history = app.session.active().unwrap().history.past_len(); + + ev(&mut app, "down", 20.0, 20.0, Modifiers::NONE); + ev(&mut app, "up", 20.0, 20.0, Modifiers::NONE); + assert_eq!(app.ui.polygon.len(), 1); + assert_eq!(app.ui.polygon_mode, "replace"); + assert!(crate::canvas::polygon_replaces_selection(&app)); + + app.ui.tool = Tool::QuickSelection; + app.sync_views(); + assert!(app.ui.polygon.is_empty()); + assert!(app.ui.polygon_mode.is_empty()); + assert!(!crate::canvas::polygon_replaces_selection(&app)); + assert_eq!(app.session.active().unwrap().doc.selection.as_ref().unwrap().content_bounds(), before); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + + app.ui.tool = Tool::PolygonLasso; + app.sync_views(); + assert!(app.ui.polygon.is_empty(), "returning to the tool must start a fresh outline"); +} + #[test] fn double_click_closes_the_polygon_at_the_last_point() { let mut h = harness(); From 5c4ad593de821e159202a744f3ed1d7fd738c1ed Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:17:31 +0700 Subject: [PATCH 180/523] fix(ui): reject rotation angles outside f32 range (#4044) --- crates/ui-egui/src/rotate_view.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index eee0b29e56..0204ba5b3b 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -71,7 +71,11 @@ pub fn pointer_deg(p: Pos2, pivot: Pos2) -> f32 { fn finite_number(p: &Value, key: &str) -> Result, String> { match p.get(key) { None => Ok(None), - Some(v) => v.as_f64().filter(|n| n.is_finite()).map(|n| Some(n as f32)).ok_or_else(|| format!("{key} must be a finite number")), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && n.abs() <= f64::from(f32::MAX)) + .map(|n| Some(n as f32)) + .ok_or_else(|| format!("{key} must be a finite number representable as f32")), } } @@ -294,6 +298,10 @@ mod tests { assert!(apply_params(0.0, &json!({"angle": f64::NAN})).is_err()); assert!(apply_params(0.0, &json!({"angle": f64::INFINITY})).is_err()); assert!(apply_params(0.0, &json!({"delta": f64::INFINITY})).is_err()); + // JSON can contain finite f64 values whose f32 conversion overflows to infinity. + // Silently wrapping infinity to 0 would reset a rotated view. + assert!(apply_params(32.0, &json!({"angle": 1.0e100})).is_err()); + assert!(apply_params(32.0, &json!({"delta": -1.0e100})).is_err()); assert!(apply_params(0.0, &json!({"reset": "yes"})).is_err()); assert!(apply_params(0.0, &json!({"zoom": 1})).is_err()); let huge = apply_params(0.0, &json!({"angle": 1.0e20})).unwrap(); From 9d37e1518bdf4cdcf4b247a5041443ad8c4af83e Mon Sep 17 00:00:00 2001 From: Alon Date: Tue, 13 Oct 2026 02:17:36 +0300 Subject: [PATCH 181/523] fix(move): no trail when dragging a shape past the canvas edge (#3946) (#4069) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/move_ui.rs | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/ui-egui/src/move_ui.rs b/crates/ui-egui/src/move_ui.rs index 5c2f2e6c60..fb49b02474 100644 --- a/crates/ui-egui/src/move_ui.rs +++ b/crates/ui-egui/src/move_ui.rs @@ -127,6 +127,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc Option<(Arc Option { + let photocraft_doc::LayerContent::Shape(sh) = &l.content else { return None }; + let b = photocraft_vector::CompiledShape::new(sh, photocraft_vector::DEFAULT_TOLERANCE, canvas).bounds()?; + let m = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 }; + Some(b.inflate(m)) +} + /// `ids` and every layer inside them (groups move their contents). fn with_descendants(doc: &Document, ids: &[LayerId]) -> Vec { let walk = doc.walk(); @@ -299,6 +309,37 @@ mod tests { assert_eq!(r, Rect::new(0, 0, 64, 64)); } + /// A shape reaching past the canvas edge renders cut at the canvas, but its path goes on: a + /// drag frame must redraw where the rest of it was, or the cut edge stays behind (#3946). + #[test] + fn dragging_a_shape_past_the_canvas_edge_leaves_no_trail() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 64, "height": 64, "background": "transparent"})).unwrap(); + app.sync_views(); + let id = app.session.execute("shape.create", json!({"kind": "rect", "rect": [40, 16, 50, 32], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let id = photocraft_doc::LayerId(id); + app.ui.tool = Tool::Move; + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + let m = egui::Modifiers::NONE; + crate::canvas::tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 32.0, pressure: 1.0 }, m); + let mut frames = Vec::new(); + for x in [40.0, 10.0] { + crate::canvas::tool_event(&mut app, ToolEvent::Move { x, y: 32.0, pressure: 1.0 }, m); + frames.push(super::display_doc(&mut app, 0).unwrap()); + } + let st = app.session.active().unwrap(); + let r = super::damage(&app, st.doc.id, st.revision, frames[0].1, frames[1].1).unwrap(); + let alpha = |d: &photocraft_doc::Document, x, y| d.layer(id).and_then(|l| l.surface()).map_or(0.0, |s| s.pixel(x, y)[3]); + for y in 0..64 { + for x in 0..64 { + if alpha(&frames[0].0, x, y) != alpha(&frames[1].0, x, y) { + assert!(r.contains(x, y), "({x}, {y}) changed outside the redrawn {r:?}"); + } + } + } + } + /// Dragging with an unlinked mask targeted moves the mask alone, shown live and committed; /// with the mask linked (or the pixels targeted) the layer moves. #[test] From ae72084a9ce02ef4fc5b90c27a04bbdc6efa1fcc Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:19:23 +1100 Subject: [PATCH 182/523] Resolve every pattern name before a batch delete removes any of them. (#4132) * Resolve every pattern name before a batch delete removes any of them. A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone. Fixes #4103 * Pattern batch delete: reject non-string entries and resolve against the library only A document-only pattern resolved through pattern_cmds::resolve, so the batch deleted earlier entries before pattern.delete refused it. Non-string array members were silently dropped (ported from #4127). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/presets/patterns.rs | 27 ++++++++++++++++--- crates/engine/src/presets/tests.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/presets/patterns.rs b/crates/engine/src/presets/patterns.rs index f4ecb15c3c..2425dd0900 100644 --- a/crates/engine/src/presets/patterns.rs +++ b/crates/engine/src/presets/patterns.rs @@ -125,13 +125,32 @@ fn edit(s: &mut Session, p: &Value) -> Result { "rename" => super::call(s, "pattern.rename", json!({"pattern": req_str(p, "preset", CMD)?, "name": req_str(p, "name", CMD)?}))?, "delete" => { let keys: Vec = match p.get("preset") { - Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + Some(Value::Array(a)) => a + .iter() + .map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(CMD, "`preset` must contain only pattern ids or names"))) + .collect::>()?, _ => vec![req_str(p, "preset", CMD)?.to_string()], }; - for k in &keys { - super::call(s, "pattern.delete", json!({"pattern": k}))?; + if keys.is_empty() { + return Err(bad(CMD, "missing `preset`")); } - json!({"deleted": keys.len()}) + // Resolve every reference first. Deleting as we go left the library partly + // removed when a later name was missing, and Undo cannot put a preset back (#4103). + let mut ids = Vec::new(); + for k in &keys { + // Only library patterns can be deleted here; a document-only pattern must + // fail the whole batch up front rather than midway through it. + let id = photocraft_doc::pattern::find(&s.patterns.items, k, k) + .map(|q| q.id.clone()) + .ok_or_else(|| bad(CMD, format!("no library pattern \"{k}\"")))?; + if !ids.contains(&id) { + ids.push(id); + } + } + for id in &ids { + super::call(s, "pattern.delete", json!({"pattern": id}))?; + } + json!({"deleted": ids.len()}) } "deleteGroup" => { // Deleting a folder deletes its patterns, as in Photoshop. diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index 7ebf2145b1..dcfc4e94d8 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -385,6 +385,43 @@ fn pattern_groups_select_apply_and_sync() { assert!(crate::pattern_cmds::resolve(&s, "Weave").is_none()); } +#[test] +fn pattern_batch_delete_keeps_the_library_when_one_name_is_missing() { + let mut s = session(8); + let before = s.execute("pattern.presets.list", json!({})).unwrap(); + let err = s.execute("pattern.presets.edit", json!({"action": "delete", "preset": ["Bricks", "missing"]})).unwrap_err(); + assert!(err.to_string().contains("missing"), "{err}"); + assert_eq!(s.execute("pattern.presets.list", json!({})).unwrap(), before); + assert!(crate::pattern_cmds::resolve(&s, "Bricks").is_some()); +} + +#[test] +fn pattern_batch_delete_rejects_non_string_members_without_deleting() { + for invalid in [json!(null), json!(7), json!({})] { + let mut s = session(8); + let before = s.execute("pattern.presets.list", json!({})).unwrap(); + assert!(s.execute("pattern.presets.edit", json!({"action": "delete", "preset": ["Bricks", invalid]})).is_err()); + assert_eq!(s.execute("pattern.presets.list", json!({})).unwrap(), before); + } +} + +#[test] +fn pattern_batch_delete_refuses_a_document_only_pattern_up_front() { + let mut s = session(8); + let added = s.execute("edit.definePattern", json!({"name": "Document Only"})).unwrap(); + let id = added["pattern"].as_str().unwrap().to_string(); + let pattern = s.patterns.items.iter().find(|p| p.id == id).unwrap().clone(); + s.edit("Embed pattern fixture", |doc, _| { + doc.patterns.push(pattern.clone()); + Ok(()) + }) + .unwrap(); + s.execute("pattern.delete", json!({"pattern": id})).unwrap(); + let before = s.patterns.items.clone(); + assert!(s.execute("pattern.presets.edit", json!({"action": "delete", "preset": ["Bricks", id]})).is_err()); + assert_eq!(s.patterns.items, before, "nothing is deleted when one entry is not in the library"); +} + // ------------------------------------------------------------------ styles #[test] From d303bf9b835d1e581450665dd387ceee949163fe Mon Sep 17 00:00:00 2001 From: Cyrill Vitkovskiy <4040177+Furash@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:22:05 +0100 Subject: [PATCH 183/523] Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip (#4105) * Brush cursor: the tip's centre mark follows Show Crosshair in Brush Tip Brush tips wider than 6 screen pixels always drew the centre crosshair, so Preferences > Cursors > Show Crosshair in Brush Tip could add it but never remove it. Photoshop shows it only when that preference is on. The Background Eraser keeps its sampling hotspot, and Clone Stamp / Healing Brush still show it while Option is held. Co-Authored-By: Claude Opus 5.5 * rustfmt canvas.rs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 45 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b6222b313..6a77d34d48 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -559,15 +559,16 @@ pub(crate) fn pencil_tip_is_round(size: f32) -> bool { /// Whether a brush-tip circle draws its centre mark. /// Clone Stamp and Healing Brush stay an empty circle until Option is held, the source-point -/// cursor. Other brushes follow the cursor preference, the large-tip mark, or the Background Eraser. -fn brush_tip_centre(tool: Tool, option: bool, show_crosshair: bool, radius: f32) -> bool { +/// cursor. Other brushes follow Preferences › Cursors › Show Crosshair in Brush Tip, as in +/// Photoshop; the Background Eraser always shows its sampling hotspot. +fn brush_tip_centre(tool: Tool, option: bool, show_crosshair: bool) -> bool { if tool == Tool::QuickSelection { return false; } if matches!(tool, Tool::CloneStamp | Tool::Healing) { return option || show_crosshair; } - show_crosshair || radius > 6.0 || tool == Tool::BackgroundEraser + show_crosshair || tool == Tool::BackgroundEraser } /// The display colour of an RGB triple (0..1 per channel) as an egui colour. @@ -3515,11 +3516,6 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let painting = app.drag.is_some(); let brush = &app.session.tools.brush; let full = (brush.size / 2.0 * xf.zoom).max(1.0); - let r = if cur.painting == PaintingCursor::NormalTip { - photocraft_paint::half_coverage_radius(full, brush.hardness).max(1.0) - } else { - full - }; match cur.painting { PaintingCursor::Standard => egui::CursorIcon::Default, PaintingCursor::Precise => crate::tool_cursor::crosshair(&painter, p, 6.0, 0.0), @@ -3552,7 +3548,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // A shaped tip (sampled bitmap, or a rotated/flattened round tip) // traces its outline; a plain round tip keeps the circle. _ => { - let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip, r); + let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre) } } @@ -6147,20 +6143,23 @@ mod tests { #[test] fn clone_stamp_centre_appears_only_with_option() { - assert!(!brush_tip_centre(Tool::CloneStamp, false, false, 20.0)); - assert!(brush_tip_centre(Tool::CloneStamp, true, false, 20.0)); - assert!(brush_tip_centre(Tool::Healing, true, false, 20.0)); - assert!(!brush_tip_centre(Tool::Healing, false, false, 20.0)); - assert!(brush_tip_centre(Tool::CloneStamp, false, true, 20.0)); - assert!(brush_tip_centre(Tool::PatternStamp, false, false, 20.0)); - assert!( - brush_tip_centre(Tool::PatternStamp, true, false, 20.0), - "Pattern Stamp keeps the brush centre; Option does not switch it to a clone-source mark" - ); - assert!(!brush_tip_centre(Tool::PatternStamp, false, false, 2.0)); - assert!(brush_tip_centre(Tool::Brush, false, false, 20.0)); - assert!(!brush_tip_centre(Tool::QuickSelection, false, false, 20.0)); - assert!(brush_tip_centre(Tool::BackgroundEraser, false, false, 2.0)); + assert!(!brush_tip_centre(Tool::CloneStamp, false, false)); + assert!(brush_tip_centre(Tool::CloneStamp, true, false)); + assert!(brush_tip_centre(Tool::Healing, true, false)); + assert!(!brush_tip_centre(Tool::Healing, false, false)); + assert!(brush_tip_centre(Tool::CloneStamp, false, true)); + assert!(!brush_tip_centre(Tool::PatternStamp, true, false), "Pattern Stamp keeps the brush cursor; Option does not switch it to a clone-source mark"); + assert!(!brush_tip_centre(Tool::QuickSelection, false, true)); + assert!(brush_tip_centre(Tool::BackgroundEraser, false, false)); + } + + #[test] + fn brush_tip_centre_follows_the_crosshair_preference_at_any_size() { + // A large tip used to show the centre mark whatever Show Crosshair in Brush Tip said. + for tool in [Tool::Brush, Tool::Eraser, Tool::PatternStamp, Tool::Dodge] { + assert!(!brush_tip_centre(tool, false, false), "{tool:?}: off"); + assert!(brush_tip_centre(tool, false, true), "{tool:?}: on"); + } } #[test] From 61c52c08db3bbfc0de79ba78ee8dc645ddf5567b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arif=20U=C4=9Fur=20=C3=87elik?= Date: Tue, 13 Oct 2026 02:23:17 +0300 Subject: [PATCH 184/523] Brush Settings: toggle switch rows without losing smoothing settings (#4463) Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from anywhere on their row while keeping the selected controls open. Keep lock clicks independent and expose each switch as one accessible checkbox. Give Smoothing a default-on enable flag so disabling it preserves the amount and modes. Old brush JSON stays compatible; toolbar amount edits and explicit stroke overrides enable it, and ABR imports retain disabled smoothing values. Cover row, checkbox, lock and keyboard gestures, old section selections, command replay, presets, legacy JSON and 8/16/32-bit rendering. Fixes #4283 Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com> Co-authored-by: GPT-6 Co-authored-by: Brandon Thomas --- crates/engine/src/brush_cmds.rs | 1 + crates/engine/tests/brush_smoothing.rs | 34 ++++++ crates/io/src/abr_map.rs | 5 +- crates/io/src/abr_map/tests.rs | 22 ++++ crates/paint/src/brush.rs | 4 +- crates/paint/src/brush_tests.rs | 33 ++++++ crates/paint/src/dynamics.rs | 2 +- crates/ui-egui/src/brush_panel.rs | 60 ++++++++--- crates/ui-egui/src/brush_panel_tests.rs | 134 ++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 1 + docs/brush-parity.md | 4 +- docs/gaps.md | 6 +- 12 files changed, 282 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index dd5c46608c..16b7d72a29 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -200,6 +200,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result b.spacing = v.clamp(0.01, 10.0); } if let Some(v) = num(p, "smoothing") { + b.smoothing.enabled = true; b.smoothing.amount = v.clamp(0.0, 1.0); } b.color = color(p.get("color"), s.tools.foreground); diff --git a/crates/engine/tests/brush_smoothing.rs b/crates/engine/tests/brush_smoothing.rs index bb624daa7f..b39a7b285f 100644 --- a/crates/engine/tests/brush_smoothing.rs +++ b/crates/engine/tests/brush_smoothing.rs @@ -20,3 +20,37 @@ fn presets_keep_the_tool_smoothing() { s.execute("tools.setBrush", json!({"preset": preset, "brush": {"smoothing": {"amount": 0.0}}})).unwrap(); assert_eq!(s.tools.brush.smoothing.amount, 0.0); } + +#[test] +fn smoothing_switch_is_drivable_and_presets_keep_the_disabled_settings() { + let mut s = Session::new(); + let params = json!({"brush": {"smoothing": {"enabled": false, "amount": 0.6, "pulledString": true, "catchUpOnEnd": false}}}); + s.execute("tools.setBrush", params.clone()).unwrap(); + let off = s.tools.brush.smoothing.clone(); + assert!(!off.enabled && off.amount == 0.6 && off.pulled_string && !off.catch_up_on_end); + let preset = s.tools.presets[0].name.clone(); + s.execute("tools.setBrush", json!({"preset": preset})).unwrap(); + assert_eq!(s.tools.brush.smoothing, off); + s.execute("tools.setBrush", json!({"brush": {"smoothing": {"enabled": true}}})).unwrap(); + assert!(s.tools.brush.smoothing.enabled); + assert_eq!(s.tools.brush.smoothing.amount, off.amount); + assert_eq!(s.tools.brush.smoothing.pulled_string, off.pulled_string); + // The same parameter patch reproduces the off state in another session (CLI/MCP path). + let mut other = Session::new(); + other.execute("tools.setBrush", params).unwrap(); + assert_eq!(other.tools.brush.smoothing, off); + let before = other.tools.brush.clone(); + assert!(other.execute("tools.setBrush", json!({"brush": {"smoothing": {"enabled": "no"}}})).is_err()); + assert_eq!(other.tools.brush, before); +} + +#[test] +fn an_explicit_stroke_smoothing_amount_overrides_the_tool_switch() { + let mut s = Session::new(); + s.execute("tools.setBrush", json!({"brush": {"smoothing": {"enabled": false, "amount": 0.6}}})).unwrap(); + let inherited = photocraft_engine::brush_cmds::resolve_brush(&s, &json!({}), "paint.stroke").unwrap(); + assert!(!inherited.smoothing.enabled && inherited.smoothing.amount == 0.6); + let explicit = photocraft_engine::brush_cmds::resolve_brush(&s, &json!({"smoothing": 0.4}), "paint.stroke").unwrap(); + assert!(explicit.smoothing.enabled && explicit.smoothing.amount == 0.4); + assert!(!s.tools.brush.smoothing.enabled, "a stroke override does not change the tool"); +} diff --git a/crates/io/src/abr_map.rs b/crates/io/src/abr_map.rs index 6230e27649..9338d110a2 100644 --- a/crates/io/src/abr_map.rs +++ b/crates/io/src/abr_map.rs @@ -478,9 +478,8 @@ impl Mapper<'_> { } b.pressure_size = boolean(to, "usePressureOverridesSize").unwrap_or(false); b.pressure_opacity = boolean(to, "usePressureOverridesOpacity").unwrap_or(false); - if boolean(to, "smoothing").unwrap_or(true) - && let Some(v) = pct(to, "smoothingValue") - { + b.smoothing.enabled = boolean(to, "smoothing").unwrap_or(true); + if let Some(v) = pct(to, "smoothingValue") { b.smoothing.amount = v.clamp(0.0, 1.0); } if let Some(v) = boolean(to, "smoothingRadiusMode") { diff --git a/crates/io/src/abr_map/tests.rs b/crates/io/src/abr_map/tests.rs index 013f307f81..a97193457c 100644 --- a/crates/io/src/abr_map/tests.rs +++ b/crates/io/src/abr_map/tests.rs @@ -188,6 +188,28 @@ fn missing_pattern_and_tip_are_reported() { assert!(imp.warnings.iter().any(|w| w.contains("Orphan")), "{:?}", imp.warnings); } +#[test] +fn disabled_smoothing_tool_options_keep_the_amount_and_mode() { + let preset = Descriptor::new("brushPreset") + .with("Nm ", t("Smoothing off")) + .with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 19.0)))) + .with( + "toolOptions", + Value::Descriptor( + Descriptor::new("currentToolOptions") + .with("smoothing", Value::Boolean(false)) + .with("smoothingValue", Value::Integer(40)) + .with("smoothingRadiusMode", Value::Boolean(true)), + ), + ); + for sub in [1, 2] { + let bytes = write_v6(sub, &[], &[], std::slice::from_ref(&preset), true).unwrap(); + let imported = read_abr(&bytes, "Test").unwrap(); + let smoothing = &imported.presets[0].brush.smoothing; + assert!(!smoothing.enabled && smoothing.amount == 0.4 && smoothing.pulled_string); + } +} + #[test] fn file_folders_nest_inside_the_import_group() { let named = |n: &str| Descriptor::new("brushPreset").with("Nm ", t(n)); diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 48e7f48d31..51d8026f31 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -441,6 +441,8 @@ impl Default for Pose { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Smoothing { + /// Switch smoothing off without discarding its amount or mode. Old presets remain enabled. + pub enabled: bool, /// 0..1. pub amount: f32, /// Lazy-mouse: paint only when the pointer pulls a string of `amount × 100 px` taut. @@ -455,7 +457,7 @@ pub struct Smoothing { impl Default for Smoothing { fn default() -> Self { - Self { amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: true, adjust_for_zoom: true } + Self { enabled: true, amount: 0.0, pulled_string: false, catch_up: true, catch_up_on_end: true, adjust_for_zoom: true } } } diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 04f74ed840..371363b02a 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -756,6 +756,39 @@ fn smoothing_modes() { assert!(out.last().unwrap().x < 70.0, "{}", out.last().unwrap().x); } +#[test] +fn disabled_smoothing_keeps_its_settings_and_paints_unsmoothed_at_every_depth() { + let pts = vec![StrokePoint::new(10.0, 20.0, 1.0), StrokePoint::new(30.0, 8.0, 0.5), StrokePoint::new(50.0, 20.0, 0.8)]; + for pulled_string in [false, true] { + let smoothing = Smoothing { enabled: false, amount: 0.8, pulled_string, ..Default::default() }; + let got = smooth_points(&smoothing, 1.0, &pts); + assert_eq!(got.len(), pts.len()); + for (a, b) in got.iter().zip(&pts) { + assert_eq!((a.x, a.y, a.pressure), (b.x, b.y, b.pressure)); + } + let b = BrushSettings { size: 9.0, smoothing: smoothing.clone(), ..brush() }; + let zero = BrushSettings { smoothing: Smoothing { amount: 0.0, ..smoothing.clone() }, ..b.clone() }; + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let (mut off, mut reference) = (Surface::new(fmt), Surface::new(fmt)); + render_stroke(&mut off, &b, &pts, None, false, 1.0); + render_stroke(&mut reference, &zero, &pts, None, false, 1.0); + for y in 0..40 { + for x in 0..64 { + assert_eq!(off.pixel(x, y), reference.pixel(x, y), "{fmt:?} at {x},{y}"); + } + } + } + // Disabling is not an amount=0 rewrite: saving and re-enabling recover the same mode. + let mut back: Smoothing = serde_json::from_value(serde_json::to_value(&smoothing).unwrap()).unwrap(); + assert_eq!(back, smoothing); + back.enabled = true; + assert!(smooth_points(&back, 1.0, &pts).iter().zip(&pts).any(|(a, b)| a.x != b.x || a.y != b.y)); + } + // Existing brush JSON has no switch and must retain its old smoothing behavior. + let legacy: Smoothing = serde_json::from_str(r#"{"amount":0.8,"pulledString":true}"#).unwrap(); + assert!(legacy.enabled && legacy.amount == 0.8 && legacy.pulled_string); +} + #[test] fn smoothing_string_runs_from_the_lagging_brush_to_the_pointer() { // #3479: the canvas draws the line between the brush and the pointer. diff --git a/crates/paint/src/dynamics.rs b/crates/paint/src/dynamics.rs index 6fd4440264..25354dd6d5 100644 --- a/crates/paint/src/dynamics.rs +++ b/crates/paint/src/dynamics.rs @@ -62,7 +62,7 @@ impl Smoother { } fn active(&self) -> bool { - self.cfg.amount > 0.0 + self.cfg.enabled && self.cfg.amount > 0.0 } /// Feed one input point; returns the brush positions to paint through. diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index c0e877ef6d..d018219238 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -1,7 +1,7 @@ //! Window › Brush Settings (F5) and Window › Brushes: Photoshop's floating brush panel. //! -//! Brush Settings: the section list with enable boxes on the left (clicking a name shows and turns -//! on that section), the selected section's controls on the right (see [`crate::brush_sections`]), +//! Brush Settings: the section list with enable boxes on the left (clicking a controls section +//! shows and enables it; the last five rows only toggle), the selected section's controls on the right (see [`crate::brush_sections`]), //! and a live stroke preview strip below, rendered with the real brush engine and cached on the //! settings (it re-renders only when they change). Brushes: the presets in collapsible groups with //! tip thumbnails and stroke previews, a size slider and a search field. @@ -41,10 +41,17 @@ pub const SECTIONS: [(&str, bool); 13] = [ ("Noise", true), ("Wet Edges", true), ("Build-up", true), - ("Smoothing", false), + ("Smoothing", true), ("Protect Texture", true), ]; +/// The final five entries are switches, not sections with a properties pane. +const CHECKBOX_ONLY_START: usize = 8; + +fn checkbox_only(section: usize) -> bool { + (CHECKBOX_ONLY_START..SECTIONS.len()).contains(§ion) +} + /// Panel width and the stroke strip's size. pub(crate) const WIDTH: f32 = 540.0; const STRIP: (u32, u32) = (488, 76); @@ -114,7 +121,7 @@ impl Default for BrushesPanelState { } } -/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing). +/// The enable flag behind section `i` (None for Brush Tip Shape). pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> { Some(match i { 1 => &mut b.shape_dynamics.enabled, @@ -127,6 +134,7 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> { 8 => &mut b.noise, 9 => &mut b.wet_edges, 10 => &mut b.build_up, + 11 => &mut b.smoothing.enabled, 12 => &mut b.protect_texture, _ => return None, }) @@ -233,19 +241,28 @@ pub(crate) fn full_uv() -> egui::Rect { egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)) } -/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section -/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the -/// section when another preset is picked. +/// Clicking a controls section shows and enables it; the final five rows only toggle, keeping +/// the current controls open. The lock on the right is a separate hit target. fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) { let t = Tokens::get(ui.ctx()); ui.vertical(|ui| { ui.set_width(150.0); ui.spacing_mut().item_spacing.y = 1.0; for (i, (name, has_box)) in SECTIONS.iter().enumerate() { - let sel = app.ui.brush_section == i; + let toggle_only = checkbox_only(i); + // Group the switches with whitespace, not separators between individual rows. + if i == CHECKBOX_ONLY_START { + ui.add_space(6.0); + } + let sel = !toggle_only && app.ui.brush_section == i; let (r, resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click()); // Named for accessibility and for tests and agents that look sections up by name. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, tl!(*name))); + if toggle_only { + let checked = section_flag(b, i).is_some_and(|flag| *flag); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, tl!(*name))); + } else { + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, tl!(*name))); + } if sel { ui.painter().rect_filled(r, t.radius_sm, t.accent_soft); } else if resp.hovered() { @@ -254,10 +271,14 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting let mut x = r.left() + 6.0; if *has_box && let Some(flag) = section_flag(b, i) { let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0)); - let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click()); - let checked = *flag; - box_resp.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))])) + // A checkbox-only row is one accessible checkbox across its whole width. A + // controls section keeps its separate enable box and selectable label. + let box_resp = (!toggle_only).then(|| { + let resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click()); + resp.widget_info(|| { + egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, *flag, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))])) + }); + resp }); if *flag { ui.painter().rect_filled(br, 2.0, t.accent); @@ -268,9 +289,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside); } let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0); - if box_resp.clicked() { + if box_resp.is_some_and(|resp| resp.clicked()) || (toggle_only && resp.clicked() && !on_lock) { *flag = !*flag; - } else if resp.clicked() && !on_lock { + } else if !toggle_only && resp.clicked() && !on_lock { *flag = true; } x += 20.0; @@ -301,7 +322,7 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting lock_clicked = true; } } - if resp.clicked() && !lock_clicked { + if !toggle_only && resp.clicked() && !lock_clicked { app.ui.brush_section = i; } if i == 0 { @@ -315,7 +336,12 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let before = app.session.tools.brush.clone(); let mut b = before.clone(); - let section = app.ui.brush_section.min(SECTIONS.len() - 1); + // Older UI state and control clients could select any of the 13 entries. Switch rows no + // longer have a properties pane, so those selections recover to Brush Tip Shape. + if app.ui.brush_section >= CHECKBOX_ONLY_START { + app.ui.brush_section = 0; + } + let section = app.ui.brush_section; ui.horizontal_top(|ui| { section_list(app, ui, &mut b); widgets::vline(ui, 482.0); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 7d5a51e4fb..34dc0d9d4d 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -465,6 +465,140 @@ fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> { h } +fn toggle_value(app: &PhotocraftApp, section: usize) -> bool { + let brush = serde_json::to_value(&app.session.tools.brush).unwrap(); + let path = match section { + 8 => "/noise", + 9 => "/wetEdges", + 10 => "/buildUp", + 11 => "/smoothing/enabled", + 12 => "/protectTexture", + _ => panic!("not a checkbox-only row"), + }; + brush.pointer(path).and_then(Value::as_bool).expect("the checkbox has a boolean brush setting") +} + +#[test] +fn checkbox_only_rows_toggle_without_changing_the_selected_controls() { + use egui_kittest::kittest::Queryable; + for (section, &(name, _)) in SECTIONS.iter().enumerate().skip(8) { + let mut h = harness(0, 1); + let before = toggle_value(h.state(), section); + let n = h.state().session.journal.len(); + h.get_by_label(name).click(); + h.run_steps(3); + assert_eq!(h.state().ui.brush_section, 1, "{name} must leave Shape Dynamics open"); + assert_eq!(toggle_value(h.state(), section), !before, "{name}"); + assert_eq!(h.state().session.journal.len(), n + 1, "one command for {name}"); + assert_eq!(last_journal(h.state()).unwrap().0, "tools.setBrush"); + h.get_by_label(name).click(); + h.run_steps(3); + assert_eq!(toggle_value(h.state(), section), before, "a second row click turns {name} back"); + assert_eq!(h.state().ui.brush_section, 1); + assert_eq!(h.state().session.journal.len(), n + 2); + } +} + +#[test] +fn checkbox_only_box_and_lock_clicks_do_not_select_or_double_toggle() { + use egui_kittest::kittest::Queryable; + for (section, &(name, _)) in SECTIONS.iter().enumerate().skip(8) { + let mut h = harness(0, 1); + let rect = h.get_by_label(name).rect(); + let before = toggle_value(h.state(), section); + let n = h.state().session.journal.len(); + let at = egui::pos2(rect.left() + 12.0, rect.center().y); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.run_steps(2); + h.drop_at(at); + h.run_steps(3); + assert_eq!(toggle_value(h.state(), section), !before, "checkbox click on {name}"); + assert_eq!(h.state().ui.brush_section, 1); + assert_eq!(h.state().session.journal.len(), n + 1); + let at = egui::pos2(rect.right() - 11.0, rect.center().y); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.run_steps(2); + h.drop_at(at); + h.run_steps(3); + assert_eq!(toggle_value(h.state(), section), !before, "lock click must not toggle {name}"); + assert_eq!(h.state().ui.brush_section, 1); + assert_eq!(h.state().session.journal.len(), n + 2); + let mut brush = h.state().session.tools.brush.clone(); + assert!(*section_lock(&mut brush, section).unwrap(), "{name} lock"); + } +} + +#[test] +fn checkbox_only_rows_cannot_restore_an_empty_properties_pane() { + for (section, &(name, _)) in SECTIONS.iter().enumerate().skip(8) { + let h = harness(0, section); + assert_eq!(h.state().ui.brush_section, 0, "an old selection of {name} returns to Brush Tip Shape"); + } +} + +#[test] +fn smoothing_row_preserves_its_amount_and_options_across_toggles() { + use egui_kittest::kittest::Queryable; + let mut h = harness(0, 1); + h.state_mut().run("tools.setBrush", json!({"brush": {"smoothing": {"amount": 0.65, "pulledString": true, "catchUpOnEnd": false}}})).unwrap(); + let before = h.state().session.tools.brush.smoothing.clone(); + h.run_steps(2); + for enabled in [false, true] { + h.get_by_label("Smoothing").click(); + h.run_steps(3); + let mut expected = before.clone(); + expected.enabled = enabled; + assert_eq!(h.state().session.tools.brush.smoothing, expected); + assert_eq!(h.state().ui.brush_section, 1); + assert_eq!(last_journal(h.state()).unwrap().1["brush"], json!({"smoothing": {"enabled": enabled}})); + } +} + +#[test] +fn checkbox_only_rows_are_accessible_checkboxes_and_accept_space() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = harness(0, 1); + for (section, &(name, _)) in SECTIONS.iter().enumerate().skip(8) { + let node = h.get_by_label(name); + assert_eq!(node.accesskit_node().role(), Role::CheckBox, "{name}"); + node.focus(); + h.run_steps(2); + let before = toggle_value(h.state(), section); + h.key_press(egui::Key::Space); + h.run_steps(3); + assert_eq!(toggle_value(h.state(), section), !before, "Space toggles {name}"); + assert_eq!(h.state().ui.brush_section, 1); + } +} + +#[test] +fn editing_the_options_bar_amount_reenables_smoothing() { + use egui_kittest::kittest::Queryable; + let mut h = options_bar_harness(crate::state::Tool::Brush); + h.state_mut().run("tools.setBrush", json!({"brush": {"smoothing": {"enabled": false, "amount": 0.4}}})).unwrap(); + h.run_steps(3); + let n = h.state().session.journal.len(); + let label = h.get_by_label("Smoothing").rect(); + let at = egui::pos2(label.right() + 25.0, label.center().y); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.run_steps(2); + h.hover_at(at + vec2(20.0, 0.0)); + h.run_steps(2); + h.drop_at(at + vec2(20.0, 0.0)); + h.run_steps(3); + let smoothing = &h.state().session.tools.brush.smoothing; + assert!(smoothing.enabled && smoothing.amount != 0.4); + assert_eq!(h.state().session.journal.len(), n + 1, "one options-bar gesture"); + assert_eq!(last_journal(h.state()).unwrap().1["brush"]["smoothing"]["enabled"], true); +} + #[test] fn preview_strip_re_renders_only_when_the_brush_changes() { let mut h = harness(0, 0); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0f6a1b83bf..6193717537 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3607,6 +3607,7 @@ fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::B fn smoothing_field(ui: &mut egui::Ui, b: &mut photocraft_engine::BrushSettings, width: f32) { let mut sm = (b.smoothing.amount * 100.0).round(); if widgets::value_field(ui, &mut sm, 0.0..=100.0, "%", width).changed() { + b.smoothing.enabled = true; b.smoothing.amount = (sm / 100.0).clamp(0.0, 1.0); } } diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 119da10d66..ad1ea5c83c 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`~` held with the Brush or Pencil erases with its brush; Clear painting mode) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -29,6 +29,7 @@ tablets against Photoshop side by side. | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | | Section locks | done | #174 | +| Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | | Anti-aliasing off for brushes | missing | #1666 (Pencil is the aliased path) | @@ -114,3 +115,4 @@ tablets against Photoshop side by side. |---|---|---| | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | +| 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index d18a8655c2..01e54f1f7c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; 2026-10-11: G8: Character panel OpenType buttons landed, TYPE-218-5 stays partial; G8: triple-, quadruple- and five-click type selection landed, TYPE-218-8 done; Czech UI localization evidence added to G13; G14: D5100 crop and Camera Raw tuning validation) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -140,6 +140,9 @@ what an agent can't finish alone. - **Estimate:** 250–400 h (many overlap with other gaps; count each fix once). ### G4. Brush feel +- **Progress (2026-10-12, #4283):** the five checkbox-only Brush Settings rows keep the open + controls selected; Smoothing can be disabled without discarding its amount or mode. This + closes that panel interaction gap, not the stroke-quality or performance gaps below. - **Missing:** lag with big brushes (#2619, #1994) and under the cursor (#2566, #1343); jumpy pressure on Windows (#2046) and tap sensitivity (#1933); stair-stepped line weights (#1776), interpolation and smoothing problems (#2138), edge falloff (#2149), pixelated cursor vs @@ -235,6 +238,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | From 6c0dcf428b3fefac57acd53f5e90d6642ab17ac1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:24:17 +0200 Subject: [PATCH 185/523] Color Table: keep the transparent index inside the palette (#4186) (#4187) Replacing the colours could shrink the table while the transparent index still pointed past its end, so the palette PNG export refused the table. A stale index is now dropped (pixels end up as with "transparent": null), and an explicit invalid index (out of range, negative, non-integer) is a BadParams error before any edit instead of silently keeping the old one. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/mode_cmds.rs | 10 ++++- crates/engine/src/mode_cmds/tests.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 5965303495..319a6e5334 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -321,8 +321,14 @@ fn color_table(s: &mut Session, p: &Value) -> Result { } match p.get("transparent") { Some(Value::Null) => table.transparent = None, - Some(v) => table.transparent = v.as_u64().filter(|&i| (i as usize) < table.colors.len()).map(|i| i as u8).or(table.transparent), - None => {} + Some(v) => { + let len = table.colors.len(); + let i = + v.as_u64().filter(|&i| i < len as u64).ok_or_else(|| bad(CMD, format!("transparent: an index from 0 to {} or null", len.saturating_sub(1))))?; + table.transparent = u8::try_from(i).ok(); + } + // A shrunk table can't keep a transparent entry past its end (#4186). + None => table.transparent = table.transparent.filter(|&i| usize::from(i) < table.colors.len()), } let report = |t: &ColorTable| json!({"colors": t.colors.iter().map(|c| hex(*c)).collect::>(), "transparent": t.transparent}); if table == cur { diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 1fbed4018d..8ac4e6d332 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -409,3 +409,66 @@ fn duotone_to_rgb_inks_fill_layer_colours() { assert!((g - w).abs() < 0.02, "{c:?}"); } } + +/// Three-entry Indexed doc (red, blue, transparent at index 2) from an RGB doc with clear pixels. +fn indexed_with_transparency() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "transparent"})).unwrap(); + paint(&mut s, |x, _| { + if x < 3 { + [1.0, 0.0, 0.0, 1.0] + } else if x < 6 { + [0.0, 0.0, 1.0, 1.0] + } else { + [0.0; 4] + } + }); + s.execute("image.mode.indexedColor", json!({"palette": "exact", "colors": 4, "forced": "none", "dither": "none"})).unwrap(); + assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(2)); + s +} + +#[test] +fn shrinking_the_color_table_drops_a_transparent_index_past_its_end() { + // #4186: the transparent index stayed at 2 after the table shrank to 2 entries, so the palette + // PNG export refused the table. + let mut s = indexed_with_transparency(); + s.execute("image.mode.colorTable", json!({"colors": ["#000000", "#ffffff"]})).unwrap(); + let d = doc(&s); + let t = d.color_table.as_ref().unwrap(); + assert_eq!(t.colors.len(), 2); + assert!(t.transparent.is_none_or(|i| usize::from(i) < t.colors.len()), "stale transparent index {:?}", t.transparent); + // The pixels end up exactly as with an explicit `"transparent": null`. + let mut cleared = indexed_with_transparency(); + cleared.execute("image.mode.colorTable", json!({"colors": ["#000000", "#ffffff"], "transparent": null})).unwrap(); + assert_eq!(doc(&cleared).color_table.as_ref(), Some(t)); + let (a, b) = (d.layers[0].surface().unwrap(), doc(&cleared).layers[0].surface().unwrap()); + for y in 0..8 { + for x in 0..8 { + assert_eq!(a.rgba(x, y), b.rgba(x, y), "({x},{y})"); + } + } + let png = photocraft_io::export(d, "png", &Default::default()).expect("palette PNG export"); + assert!(png.warnings.iter().any(|w| w.contains("palette PNG")), "{:?}", png.warnings); + // Undo restores the transparent entry. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(2)); +} + +#[test] +fn color_table_rejects_an_invalid_transparent_index_without_editing() { + let mut s = indexed_with_transparency(); + let before = doc(&s).color_table.clone(); + let n = s.active().unwrap().history.past_len(); + for t in [json!(999), json!(3), json!(-1), json!(1.5), json!("0"), json!(true)] { + assert!(s.execute("image.mode.colorTable", json!({"transparent": t})).is_err(), "{t}"); + // Validated against the new colours, before any edit. + assert!(s.execute("image.mode.colorTable", json!({"colors": ["#000000", "#ffffff"], "transparent": t})).is_err(), "{t}"); + } + assert!(s.execute("image.mode.colorTable", json!({"colors": ["#000000", "#ffffff"], "transparent": 2})).is_err()); + assert_eq!(doc(&s).color_table, before); + assert_eq!(s.active().unwrap().history.past_len(), n); + // A valid index still applies. + s.execute("image.mode.colorTable", json!({"transparent": 0})).unwrap(); + assert_eq!(doc(&s).color_table.as_ref().unwrap().transparent, Some(0)); +} From 9f98df582a711216c659ee670e45064e1b49cad0 Mon Sep 17 00:00:00 2001 From: Cyrill Vitkovskiy <4040177+Furash@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:27:14 +0100 Subject: [PATCH 186/523] Stylus: a light Windows pen tap is pressure 0, not a full-pressure click (#4072) winit's Windows pointer path reports pen pressure 0 as no force, so a tap lighter than the pen's first pressure level arrived as a force-less touch. Stylus::update ignored those, saw no pen, and the tap painted at 1 like a mouse click. Track touch contacts: a touch move outside a contact (hovering) or any reported force marks a pen, and a pen contact with no force yet is pressure 0. A finger on a screen without force sensing still paints at 1. A contact that starts in the frame another lifted now keeps its force too. Also credit @Furash in contributors/people.toml. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 ++ crates/ui-egui/src/stylus.rs | 101 ++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 82a9e20a49..7a9e10ae5c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -127,3 +127,6 @@ sync_github_name = true [people.aweussom] real_name = "Tommy Leonhardsen" + +[people.Furash] +sync_github_name = true diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index 6bee705147..110f401f63 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -6,7 +6,9 @@ //! - **Windows**: winit turns `WM_POINTER` pen and touch input into `WindowEvent::Touch` with a //! normalised force (`POINTER_PEN_INFO::pressure / 1024`); egui-winit forwards it as //! `egui::Event::Touch { force }` alongside the emulated pointer. [`Stylus::update`] reads it. -//! winit drops the pen's tilt and rotation, so those stay 0. +//! winit drops the pen's tilt and rotation, so those stay 0, and reports pressure 0 (a tap +//! lighter than the pen's first pressure level) as no force, which [`Stylus::update`] reads as +//! pressure 0 once it knows a pen is in use. //! - **Web**: eframe forwards touch force but not pen pointer events, so the web runner listens //! to `pointerdown`/`pointermove` itself and writes `pressure`, `tiltX`, `tiltY`, `twist` and //! the eraser button of `pointerType == "pen"` events into the [`StylusFeed`]. @@ -120,6 +122,11 @@ pub struct Stylus { pub(crate) tool_before_eraser: Option, /// Force of the touch/pen contact currently down (egui `Event::Touch`). touch: Option, + /// The touch contacts currently down, so a touch move outside them is a hovering pen. + contacts: Vec, + /// A pen reported through touch events (it hovered, or a contact carried a force): its + /// contacts without a force are at pressure 0, not a finger's full pressure. + touch_pen: bool, /// The contact ended this frame: keep its force for this frame's last tool events, clear next frame. lifted: bool, /// This frame's pen samples, oldest first (the feed's queue and the touch forces). @@ -152,6 +159,8 @@ impl Default for Stylus { end: None, tool_before_eraser: None, touch: None, + contacts: Vec::new(), + touch_pen: false, lifted: false, frame: Vec::new(), prev: None, @@ -186,15 +195,42 @@ impl Stylus { self.frame = self.feed.drain(); self.current = None; for e in events { - if let egui::Event::Touch { phase, force, .. } = e { + if let egui::Event::Touch { id, phase, force, .. } = e { + let force = force.filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0)); match phase { egui::TouchPhase::Start | egui::TouchPhase::Move => { - if let Some(f) = force.filter(|f| f.is_finite()) { - self.touch = Some(f.clamp(0.0, 1.0)); - self.frame.push(PenSample { pressure: f.clamp(0.0, 1.0), ..Default::default() }); + let start = *phase == egui::TouchPhase::Start; + if start { + // A new contact keeps its force into the next frame, even when an + // earlier contact lifted in this one. + self.lifted = false; + if !self.contacts.contains(id) { + if self.contacts.len() >= 16 { + self.contacts.remove(0); + } + self.contacts.push(*id); + } + } + let down = start || self.contacts.contains(id); + // Only a pen moves without touching, and only a pen (or a force-sensing + // screen) reports a force. + if !down || force.is_some() { + self.touch_pen = true; + } + // Windows reports a pen pressed lighter than its first pressure level with + // no force at all (winit maps pressure 0 to `None`): a light tap is + // pressure 0, never a mouse click's full pressure. A finger on a screen + // without force sensing stays at full pressure. + let force = force.or((down && self.touch_pen && (start || self.touch.is_none())).then_some(0.0)); + if let Some(f) = force { + self.touch = Some(f); + self.frame.push(PenSample { pressure: f, ..Default::default() }); } } - egui::TouchPhase::End | egui::TouchPhase::Cancel => self.lifted = true, + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + self.lifted = true; + self.contacts.retain(|c| c != id); + } } } } @@ -525,6 +561,59 @@ mod tests { assert_eq!(s.pressure(), 1.0); } + fn touch_id(id: u64, phase: egui::TouchPhase, force: Option) -> egui::Event { + egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos: egui::pos2(1.0, 1.0), force } + } + + #[test] + fn a_light_windows_pen_tap_is_not_full_pressure() { + use egui::TouchPhase::{End, Move, Start}; + // winit on Windows reports a pen pressed below its first pressure level with no force + // (pressure 0 maps to `None`). The pen hovered first, so the tap is pressure 0, not a + // mouse click's 1. + let mut s = Stylus::default(); + s.update(&[touch_id(5, Move, None)]); + assert_eq!(s.pressure(), 1.0, "hovering is no contact"); + s.update(&[touch_id(5, Start, None), touch_id(5, End, None)]); + assert_eq!(s.pressure(), 0.0); + assert_eq!(s.tap_pressure(), 0.0); + s.update(&[]); + assert_eq!(s.pressure(), 1.0, "lifted"); + // A pen known from an earlier force: a stroke pressed before its first level starts at 0 + // and then follows the pen. + let mut s = Stylus::default(); + s.update(&[touch_id(7, Start, Some(0.5)), touch_id(7, End, None)]); + s.update(&[]); + s.update(&[touch_id(8, Start, None)]); + assert_eq!(s.pressure(), 0.0); + s.update(&[touch_id(8, Move, Some(0.3))]); + assert_eq!(s.pressure(), 0.3); + // A force-less move mid-stroke keeps the last force. + s.update(&[touch_id(8, Move, None)]); + assert_eq!(s.pressure(), 0.3); + } + + #[test] + fn a_finger_without_force_still_paints_at_full_pressure() { + use egui::TouchPhase::{End, Move, Start}; + let mut s = Stylus::default(); + s.update(&[touch_id(1, Start, None), touch_id(1, Move, None)]); + assert_eq!(s.pressure(), 1.0); + s.update(&[touch_id(1, End, None)]); + s.update(&[]); + assert_eq!(s.pressure(), 1.0); + } + + #[test] + fn a_new_tap_in_the_frame_an_old_one_lifted_keeps_its_force() { + use egui::TouchPhase::{End, Start}; + let mut s = Stylus::default(); + s.update(&[touch_id(1, Start, Some(0.6)), touch_id(1, End, None), touch_id(2, Start, Some(0.2))]); + assert_eq!(s.pressure(), 0.2); + s.update(&[]); + assert_eq!(s.pressure(), 0.2, "the second contact is still down"); + } + #[test] fn feed_wins_and_is_sanitized() { let mut s = Stylus::default(); From a1fbfd9a6c1df22f20346769a6bb10f557cda0a3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:30:58 +0200 Subject: [PATCH 187/523] Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333) * Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes #4303) * Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes #4303) --- crates/engine/src/filters.rs | 2 +- crates/ui-egui/src/dialogs.rs | 15 ++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 8 + crates/ui-egui/src/rasterize_prompt.rs | 88 +++++++ crates/ui-egui/src/rasterize_prompt_tests.rs | 233 +++++++++++++++++++ 6 files changed, 350 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index 40b51189e2..32ae7fb3b8 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -413,7 +413,7 @@ pub fn specs() -> Vec { /// The most recent filter command in the session journal, retargeted at the active layer: dialogs /// such as the Filter Gallery record the layer they ran on, and Last Filter applies to the /// current layer, as in Photoshop (#1270). -pub(crate) fn last_filter(s: &Session) -> Option<(String, Value)> { +pub fn last_filter(s: &Session) -> Option<(String, Value)> { let (id, mut params) = s.journal.iter().rev().find(|(id, _)| params_for(id, &Value::Null).is_some() && id.starts_with("filter.")).cloned()?; if let Value::Object(m) = &mut params { m.remove("layer"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d36f29756..b5e0113784 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -335,6 +335,21 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { fields.insert("choice".into(), json!("apply")); } hit + } else if crate::rasterize_prompt::is_filter(&fields) { + // Photoshop: Convert to Smart Object / Rasterize / Cancel (#4303). + let rasterize = DialogButton::new(ButtonRole::Default, tl!("Rasterize"), 84.0); + let hit = if crate::rasterize_prompt::offers_smart_object(&fields) { + let convert = DialogButton::new(ButtonRole::Alternate, tl!("Convert to Smart Object"), 160.0); + dialog_buttons(ui, &[rasterize, cancel, convert]) + } else { + dialog_buttons(ui, &[rasterize, cancel]) + }; + if hit == Some(ButtonRole::Alternate) { + fields.insert("choice".into(), json!("smartObject")); + } else if hit == Some(ButtonRole::Default) { + fields.insert("choice".into(), json!("rasterize")); + } + hit } else { dialog_buttons(ui, &[ok, cancel]) }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c9847d8638..73cc9b61d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -644,6 +644,9 @@ pub struct PhotocraftApp { pub(crate) raw_redevelop: Option, /// Filter › Adaptive Wide Angle dialog (wide_angle_ui). pub(crate) wide_angle: Option, + /// The filter a "Rasterize?" prompt answered for, run after the dialog closes, where the + /// egui context is available again (rasterize_prompt). + pub(crate) pending_after_prompt: Option<(String, serde_json::Value)>, /// Signature of the image we last put on the OS clipboard (to tell ours from other apps'). os_clip_sig: Option, /// The session clipboard currently holds an image imported from the OS clipboard: it has no @@ -811,6 +814,7 @@ impl PhotocraftApp { pending_raw_open: None, raw_redevelop: None, wide_angle: None, + pending_after_prompt: None, tone_hist: None, doc_hist: None, hist_job: None, @@ -1745,6 +1749,7 @@ impl eframe::App for PhotocraftApp { workspace_ui::windows(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); + rasterize_prompt::run_pending(self, &ctx); jobs_ui::dialog(self, &ctx); discard_ui::show(self, &ctx); tiff_options_ui::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 18f1d6afb3..161c64c0c2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -167,6 +167,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: if let Some(r) = crate::tool_reset::invoke(app, id, ¶ms) { return r; } + // A filter on a type layer asks to rasterize it first (#4303). + if let Some(r) = crate::rasterize_prompt::intercept_filter(app, id, ¶ms) { + return r; + } // A pending Pen anchor is gesture state; Edit › Undo must match the keyboard. if id == "edit.undo" && crate::vector_ui::pen_undo_last_point(app) { return Ok(Value::Null); @@ -671,6 +675,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { if crate::crop_ui::blocks(app, id) { return false; } + // Filters stay live on a type layer: choosing one asks to rasterize it (#4303). + if crate::rasterize_prompt::filter_needs(app, id).is_some() { + return true; + } if let Some(e) = crate::workspace_ui::is_enabled(app, id) { return e; } diff --git a/crates/ui-egui/src/rasterize_prompt.rs b/crates/ui-egui/src/rasterize_prompt.rs index bb1a7570b5..76de6a89a5 100644 --- a/crates/ui-egui/src/rasterize_prompt.rs +++ b/crates/ui-egui/src/rasterize_prompt.rs @@ -7,6 +7,11 @@ //! and `ui.dialog.cancel` answer it). OK runs the matching `layer.rasterize.*` command, then paints //! where the click was, so rasterizing and painting are two history states, as in Photoshop. //! Cancel does nothing. +//! +//! A filter on a type layer asks the same question (#4303), with Photoshop's Convert to Smart +//! Object / Rasterize / Cancel: the layer is converted or rasterized, then the filter is chosen +//! again from the menu (its dialog, window or mode opens, or it runs), a smart filter on the Smart +//! Object. `ui.dialog.set` with `"choice": "smartObject"` picks the conversion. use serde_json::{Map, Value, json}; @@ -120,6 +125,62 @@ pub fn open(app: &mut PhotocraftApp, kind: Kind, layer: photocraft_doc::LayerId, app.ui.open_dialog(DialogKind::Command, f) } +/// The active type layer when `id` is a filter that only needs it rasterized: the engine greys it +/// for not being a pixel layer, and the layer is not locked. The flag says whether the filter also +/// runs on a Smart Object, as a smart filter (Vanishing Point doesn't). +pub fn filter_needs(app: &PhotocraftApp, id: &str) -> Option<(photocraft_doc::LayerId, bool)> { + if !id.starts_with("filter.") { + return None; + } + let st = app.session.active()?; + let layer = st.active_layer?; + if !matches!(st.doc.layer(layer)?.content, photocraft_doc::LayerContent::Text(_)) { + return None; + } + let locks = st.doc.effective_locks(layer); + if locks.all || locks.pixels { + return None; + } + // Last Filter checks the layer before the journal: with no filter to repeat it stays grey. + if id == "filter.lastFilter" && photocraft_engine::filters::last_filter(&app.session).is_none() { + return None; + } + // The engine's reason for a type layer: "filters need a pixel layer (active layer is a Type + // layer)" from filters::has_filterable_layer, which also takes Smart Objects, or a filter's own + // wording ("Vanishing Point needs a pixel layer (…)", "active layer is a Type layer, not a pixel + // layer"). Any other reason (a job running) keeps the item grey. + let reason = app.session.disabled_reason(id)?; + (reason.contains("active layer is a Type layer") && reason.contains("pixel layer")).then(|| (layer, reason.starts_with("filters need a pixel layer"))) +} + +/// A filter chosen on a type layer: open the prompt instead and return its answer to the caller. +pub fn intercept_filter(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option> { + let (layer, smart) = filter_needs(app, id)?; + if let Some(d) = app.ui.dialogs.iter().find(|d| owns(&d.fields)) { + return Some(Ok(json!({"dialog": d.id}))); + } + let mut f = Map::new(); + f.insert(MARK.into(), json!(Kind::Type.key())); + f.insert("__label".into(), json!("PhotoCraft")); + f.insert("message".into(), json!(Kind::Type.message())); + f.insert("layer".into(), json!(layer.0)); + f.insert("filter".into(), json!(id)); + f.insert("params".into(), params.clone()); + f.insert("choice".into(), json!("rasterize")); + f.insert("smartObject".into(), json!(smart)); + Some(Ok(json!({"dialog": app.ui.open_dialog(DialogKind::Command, f)}))) +} + +/// Is this the filter prompt (Convert to Smart Object / Rasterize / Cancel)? +pub fn is_filter(fields: &Map) -> bool { + owns(fields) && fields.contains_key("filter") +} + +/// Does the filter prompt offer Convert to Smart Object? +pub fn offers_smart_object(fields: &Map) -> bool { + fields.get("smartObject").and_then(Value::as_bool) == Some(true) +} + /// Is this dialog the prompt? pub fn owns(fields: &Map) -> bool { fields.contains_key(MARK) @@ -141,6 +202,9 @@ pub fn body(ui: &mut egui::Ui, f: &Map) { pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result { let kind = f.get(MARK).and_then(Value::as_str).and_then(Kind::from_key).ok_or("not a rasterize prompt")?; let layer = f.get("layer").and_then(Value::as_u64).ok_or("the prompt has no layer")?; + if let Some(filter) = f.get("filter").and_then(Value::as_str) { + return confirm_filter(app, f, filter, layer); + } let r = app.run(kind.command(), json!({ "layer": layer }))?; let tool = f.get("tool").and_then(Value::as_str).and_then(Tool::from_name); let at: Vec = f.get("at").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); @@ -159,6 +223,30 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result, filter: &str, layer: u64) -> Result { + let smart = f.get("choice").and_then(Value::as_str) == Some("smartObject"); + if smart && !offers_smart_object(f) { + return Err(format!("{filter} can't run as a smart filter")); + } + let command = if smart { "layer.smartObjects.convertToSmartObject" } else { Kind::Type.command() }; + let r = app.run(command, json!({ "layer": layer }))?; + let params = f.get("params").cloned().unwrap_or(Value::Null); + app.pending_after_prompt = Some((filter.to_string(), params)); + Ok(json!({ "converted": r, "filter": filter })) +} + +/// Runs the filter a prompt was answered for, as the menu would, now that the layer can take it. +pub fn run_pending(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some((filter, params)) = app.pending_after_prompt.take() else { return }; + if let Err(e) = crate::menus::invoke(app, ctx, &filter, params) { + app.ui.status = e; + app.ui.status_error = true; + } +} + #[cfg(test)] #[path = "rasterize_prompt_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/rasterize_prompt_tests.rs b/crates/ui-egui/src/rasterize_prompt_tests.rs index 8eb2dfa58b..510723beb6 100644 --- a/crates/ui-egui/src/rasterize_prompt_tests.rs +++ b/crates/ui-egui/src/rasterize_prompt_tests.rs @@ -169,3 +169,236 @@ fn the_control_channel_sees_and_confirms_the_prompt() { assert_eq!(r["ok"], json!(true), "{r}"); assert_eq!(active_content(&app), "Pixel"); } + +const BLUR: &str = "filter.blur.gaussianBlur"; + +fn menu(app: &mut PhotocraftApp, id: &str) -> Result { + crate::menus::invoke(app, &egui::Context::default(), id, json!({})) +} + +/// Answers the filter prompt, then the frame runs the queued filter. +fn answer(app: &mut PhotocraftApp, id: u64) { + crate::dialogs::confirm(app, id).unwrap(); + run_pending(app, &egui::Context::default()); +} + +fn filter_dialog(app: &PhotocraftApp) -> Option { + app.ui.dialogs.iter().find(|d| d.fields.get("__command") == Some(&json!(BLUR)) && !owns(&d.fields)).map(|d| d.id) +} + +/// #4303: Filter › Gaussian Blur… on a type layer is live and asks to rasterize first. +#[test] +fn a_filter_on_a_type_layer_asks_then_opens_on_the_rasterized_layer() { + let mut app = app_with(Kind::Type); + assert!(!app.session.is_enabled(BLUR), "the engine itself still needs pixels"); + assert!(crate::menus::is_enabled(&app, BLUR)); + let before = past(&app); + menu(&mut app, BLUR).unwrap(); + let d = prompt(&app).expect("the prompt"); + assert_eq!(d.fields["message"], json!(Kind::Type.message())); + assert!(filter_dialog(&app).is_none(), "the filter waits for the answer"); + assert_eq!((past(&app), active_content(&app)), (before, "Type")); + let id = d.id; + answer(&mut app, id); + assert_eq!(active_content(&app), "Pixel"); + assert_eq!(past(&app), before + 1); + let blur = filter_dialog(&app).expect("Gaussian Blur opens on the rasterized layer"); + crate::dialogs::confirm(&mut app, blur).unwrap(); + assert_eq!(past(&app), before + 2, "rasterize, then blur"); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some(BLUR)); +} + +#[test] +fn cancelling_the_filter_prompt_keeps_the_type_layer() { + let mut app = app_with(Kind::Type); + let before = (past(&app), app.session.active().unwrap().doc.clone()); + menu(&mut app, BLUR).unwrap(); + // The menu again while it is up doesn't stack another prompt. + menu(&mut app, BLUR).unwrap(); + assert_eq!(app.ui.dialogs.len(), 1); + let id = prompt(&app).unwrap().id; + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(past(&app), before.0); + assert_eq!(*app.session.active().unwrap().doc, *before.1); + assert_eq!(active_content(&app), "Type"); +} + +/// Convert to Smart Object keeps the text editable and applies the filter as a smart filter. +#[test] +fn convert_to_smart_object_applies_a_smart_filter() { + let mut app = app_with(Kind::Type); + menu(&mut app, BLUR).unwrap(); + let id = prompt(&app).unwrap().id; + app.ui.dialog_mut(id).unwrap().fields.insert("choice".into(), json!("smartObject")); + answer(&mut app, id); + assert_eq!(active_content(&app), "Smart Object"); + let blur = filter_dialog(&app).expect("Gaussian Blur opens on the Smart Object"); + crate::dialogs::confirm(&mut app, blur).unwrap(); + let st = app.session.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("a Smart Object") }; + assert_eq!(sm.smart_filters.iter().map(|f| f.command.as_str()).collect::>(), [BLUR]); +} + +/// A filter without a dialog runs straight after rasterizing. +#[test] +fn a_filter_without_a_dialog_runs_after_rasterizing() { + let mut app = app_with(Kind::Type); + let before = past(&app); + menu(&mut app, "filter.blur.blurMore").unwrap(); + let id = prompt(&app).unwrap().id; + answer(&mut app, id); + assert!(app.ui.dialogs.is_empty()); + assert_eq!(past(&app), before + 2); + assert_eq!(active_content(&app), "Pixel"); +} + +#[test] +fn filters_stay_grey_where_rasterizing_would_not_help() { + // A locked type layer can't be rasterized either. + let mut app = app_with(Kind::Type); + app.run("layer.lockLayers", json!({"pixels": true})).unwrap(); + assert!(!crate::menus::is_enabled(&app, BLUR)); + // Only type layers ask: a shape layer's filters stay grey, as before. + let app = app_with(Kind::Shape); + assert!(!crate::menus::is_enabled(&app, BLUR)); + // Non-filter commands a type layer greys are untouched. + let app = app_with(Kind::Type); + assert_eq!(crate::menus::is_enabled(&app, "edit.fill"), app.session.is_enabled("edit.fill")); +} + +/// The dialogs, and the filter a prompt queued, frame by frame. +fn harness(app: PhotocraftApp) -> egui_kittest::Harness<'static, PhotocraftApp> { + let h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, app| { + crate::dialogs::show(app, ui.ctx()); + run_pending(app, ui.ctx()); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h +} + +/// The prompt's buttons, clicked: Rasterize, then the filter. +#[test] +fn the_filter_prompt_offers_convert_rasterize_and_cancel() { + use egui_kittest::kittest::Queryable; + let app = app_with(Kind::Type); + let mut h = harness(app); + menu(h.state_mut(), BLUR).unwrap(); + h.run_steps(4); + h.get_by_label("Convert to Smart Object"); + h.get_by_label("Cancel"); + h.get_by_label("Rasterize").click(); + h.run_steps(4); + assert_eq!(active_content(h.state()), "Pixel"); + assert!(filter_dialog(h.state()).is_some()); +} + +#[test] +fn the_convert_button_converts_to_a_smart_object() { + use egui_kittest::kittest::Queryable; + let app = app_with(Kind::Type); + let mut h = harness(app); + menu(h.state_mut(), BLUR).unwrap(); + h.run_steps(4); + h.get_by_label("Convert to Smart Object").click(); + h.run_steps(4); + assert_eq!(active_content(h.state()), "Smart Object"); + assert!(filter_dialog(h.state()).is_some()); +} + +/// The filters with their own window or mode open on the rasterized layer, or on the Smart Object. +#[test] +fn filters_with_their_own_window_open_after_the_prompt() { + type IsOpen = fn(&PhotocraftApp) -> bool; + let open: [(&str, IsOpen); 4] = [ + ("filter.liquify", |a| a.distort.liquify.is_some()), + ("filter.filterGallery", |a| a.distort.gallery.is_some()), + ("filter.cameraRaw", |a| a.camera_raw.is_some()), + ("filter.adaptiveWideAngle", |a| a.wide_angle.is_some()), + ]; + for (filter, is_open) in open { + for choice in ["rasterize", "smartObject"] { + let mut app = app_with(Kind::Type); + assert!(crate::menus::is_enabled(&app, filter), "{filter}"); + menu(&mut app, filter).unwrap(); + assert!(!is_open(&app), "{filter}: waits for the answer"); + let id = prompt(&app).unwrap_or_else(|| panic!("{filter}: no prompt")).id; + app.ui.dialog_mut(id).unwrap().fields.insert("choice".into(), json!(choice)); + answer(&mut app, id); + assert!(!app.ui.status_error, "{filter}/{choice}: {}", app.ui.status); + assert_eq!(active_content(&app), if choice == "rasterize" { "Pixel" } else { "Smart Object" }, "{filter}"); + assert!(is_open(&app), "{filter}/{choice}: its window opens"); + } + } +} + +/// Last Filter asks only when there is a filter to repeat, then repeats it on the rasterized layer. +#[test] +fn last_filter_on_a_type_layer_needs_a_filter_to_repeat() { + let mut app = app_with(Kind::Type); + assert!(!crate::menus::is_enabled(&app, "filter.lastFilter"), "nothing to repeat yet"); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#00ff00"})).unwrap(); + app.run("filter.blur.blurMore", json!({})).unwrap(); + let st = app.session.active().unwrap(); + let text = st.doc.layers.iter().find(|l| matches!(l.content, LayerContent::Text(_))).unwrap().id; + app.run("layer.select", json!({"layer": text.0})).unwrap(); + assert!(crate::menus::is_enabled(&app, "filter.lastFilter")); + let before = past(&app); + menu(&mut app, "filter.lastFilter").unwrap(); + let id = prompt(&app).expect("the prompt").id; + answer(&mut app, id); + assert!(!app.ui.status_error, "{}", app.ui.status); + assert_eq!(active_content(&app), "Pixel"); + assert_eq!(past(&app), before + 2, "rasterize, then Blur More again"); + assert_eq!(app.session.journal.iter().filter(|(id, _)| id == "filter.blur.blurMore").count(), 2); +} + +/// Every Filter menu item live on a pixel layer is live on a type layer and gets there through the +/// prompt (Flame, Tree, Picture Frame already run on a type layer: they render on a new layer). +#[test] +fn every_filter_live_on_pixels_asks_on_a_type_layer() { + let ids: Vec<&str> = crate::menu_catalog::CATALOG.iter().filter(|e| e.3.starts_with("filter.")).map(|e| e.3).collect(); + let mut pixels = app_with(Kind::Type); + pixels.run("layer.rasterize.type", json!({})).unwrap(); + let mut asked = 0; + for id in ids { + let mut app = app_with(Kind::Type); + if !crate::menus::is_enabled(&pixels, id) || app.session.is_enabled(id) { + continue; + } + assert!(crate::menus::is_enabled(&app, id), "{id}"); + menu(&mut app, id).unwrap(); + let d = prompt(&app).unwrap_or_else(|| panic!("{id}: no prompt")).id; + answer(&mut app, d); + assert!(!app.ui.status_error, "{id}: {}", app.ui.status); + assert_eq!(active_content(&app), "Pixel", "{id}"); + asked += 1; + } + assert!(asked > 40, "{asked} filters"); +} + +/// Vanishing Point edits pixels only: its prompt offers Rasterize, not Convert to Smart Object. +#[test] +fn vanishing_point_offers_only_rasterize() { + use egui_kittest::kittest::Queryable; + let app = app_with(Kind::Type); + let mut h = harness(app); + assert!(crate::menus::is_enabled(h.state(), "filter.vanishingPoint")); + menu(h.state_mut(), "filter.vanishingPoint").unwrap(); + h.run_steps(4); + assert!(h.query_by_label("Convert to Smart Object").is_none()); + let id = prompt(h.state()).unwrap().id; + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("choice".into(), json!("smartObject")); + assert!(crate::dialogs::confirm(h.state_mut(), id).is_err(), "no conversion behind its back"); + assert_eq!(active_content(h.state()), "Type"); + menu(h.state_mut(), "filter.vanishingPoint").unwrap(); + h.run_steps(4); + h.get_by_label("Rasterize").click(); + h.run_steps(4); + assert_eq!(active_content(h.state()), "Pixel"); + assert!(h.state().distort.vanishing.is_some(), "Vanishing Point opens"); +} From 992d945da7d5d837b10da2ac295a887cecbcec4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bihacsy=20L=C3=A1szl=C3=B3?= <115165402+Skaywalker@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:32:00 +0200 Subject: [PATCH 188/523] i18n(hu): informal address in the rows added on merge (#4334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n(hu): informal address in the rows added on merge Seven of the 23 rows translated when #3831 was merged addressed the user with the formal verb forms; the catalog uses the informal second person throughout. One of them also glued a hyphen to the {key} placeholder; it now reads "{key}+kattintás: …" like the other hints. * i18n(hu): translate the Enable animations preference The Interface › Enable animations preference (#3273) reached main after #3831; preference_labels_are_translated needs a Hungarian row for it. --------- Co-authored-by: Brandon Thomas --- crates/ui-egui/src/i18n/hu.tsv | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a84d19458b..151a997bfe 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3952,10 +3952,10 @@ cameraRaw Yellow Sárga Amount: Mérték: Apply Vanishing Point ({key}) Enyészpont alkalmazása ({key}) Apply the removal ({key}) Eltávolítás alkalmazása ({key}) - Drag to draw the curve · {key}-click draws a straight line Húzással rajzolja meg a görbét · {key}-kattintás egyenes vonalat húz - Draw to modify the curve Rajzoljon a görbe módosításához - Edit points to modify the curve Szerkessze a pontokat a görbe módosításához - Fix the red plane first: its corners must form a convex quad Először javítsa a piros síkot: sarkainak konvex négyszöget kell alkotniuk + Drag to draw the curve · {key}-click draws a straight line Húzással rajzold meg a görbét · {key}+kattintás: egyenes vonal + Draw to modify the curve Rajzolj a görbe módosításához + Edit points to modify the curve Szerkeszd a pontokat a görbe módosításához + Fix the red plane first: its corners must form a convex quad Először javítsd a piros síkot: sarkainak konvex négyszöget kell alkotniuk Grid Size: Rácsméret: Light Blue Világoskék Light Red Világospiros @@ -3965,13 +3965,13 @@ cameraRaw Yellow Sárga Protect: Védelem: Remove after each stroke Eltávolítás minden ecsetvonás után Saving {percent} Mentés: {percent} - Scale or rotate the moved area, then press {key} Méretezze vagy forgassa el az áthelyezett területet, majd nyomja meg: {key} + Scale or rotate the moved area, then press {key} Méretezd vagy forgasd el az áthelyezett területet, majd nyomd meg: {key} Smooth the curve values A görbeértékek simítása Strokes to remove: {n} (Enter applies, Esc cancels) Eltávolítandó ecsetvonások: {n} (Enter: alkalmazás, Esc: mégse) - The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzza újra a maszkot a Kukába. + The layer or its mask changed. Drag the mask onto the Trash again. A réteg vagy a maszkja megváltozott. Húzd újra a maszkot a Kukába. Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív - Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattintson a négy sarokra + Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… @@ -3989,7 +3989,7 @@ cameraRaw Yellow Sárga Message: Üzenet: Allow Continue Folytatás engedélyezése Show dialog during playback Párbeszédablak megjelenítése lejátszáskor - Select an action first Először válasszon ki egy műveletet + Select an action first Először válassz ki egy műveletet Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet From 7366a0b429bbd1fc9c7b8055b85f588d07eba4c8 Mon Sep 17 00:00:00 2001 From: ambro Date: Tue, 13 Oct 2026 01:38:55 +0200 Subject: [PATCH 189/523] Italian: native-speaker review of the UI catalog (#4290) * Italian: native-speaker review of the UI catalog Fix mistranslations: Trap (Trapping, was overprint), HDR Toning (Mappatura tonale, was chemical toning), Display Text (Mostra testo), Vine Color, Override Action "Save" Commands. Improve phrasing: Bevel & Emboss, East Asian / Middle Eastern Features, linked smart objects, Conditional Mode Change (metodo, as in Image > Mode), bit depth labels. Record the review and new glossary terms in the localization docs. Credit lucAmbr0 in people.toml. * Tests: allow Italian's compact 8/16 bit labels, which match the source Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 +++ crates/ui-egui/src/i18n/it.tsv | 38 ++++++++++++++++---------------- crates/ui-egui/src/new_doc_ui.rs | 4 ++-- docs/localization-it.md | 11 +++++++++ docs/localization-parity.md | 11 ++++----- 5 files changed, 41 insertions(+), 26 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 7a9e10ae5c..9377474c8e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -130,3 +130,6 @@ real_name = "Tommy Leonhardsen" [people.Furash] sync_github_name = true + +[people.lucAmbr0] +sync_github_name = true diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1aa22cc2aa..060ac72fc6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -87,14 +87,14 @@ Generate Genera Image Assets Risorse immagine Place Embedded… Inserisci incorporato… - Place Linked… Inserisci collegato… + Place Linked… Inserisci oggetto collegato… Package… Crea pacchetto… Automate Automatizza Batch… Batch… Create Droplet… Crea droplet… Crop and Straighten Photos Ritaglia e raddrizza foto Contact Sheet II… Provino a contatto II… - Conditional Mode Change… Cambio modo condizionale… + Conditional Mode Change… Cambia metodo condizionale… Fit Image… Adatta immagine… Lens Correction… Correzione lente… Merge to HDR Pro… Unisci in HDR Pro… @@ -212,10 +212,10 @@ Lab Color Colore Lab Multichannel Multicanale 8 Bits/Channel 8 bit/canale - 8 bit 8 bit/canale + 8 bit 8 bit Integer Intero 16 Bits/Channel 16 bit/canale - 16 bit 16 bit/canale + 16 bit 16 bit 32 Bits/Channel 32 bit/canale 32 bit (float) 32 bit (virgola mobile) Floating point Virgola mobile @@ -241,7 +241,7 @@ Gradient Map… Mappa sfumatura… Selective Color… Colore selettivo… Shadows/Highlights… Ombre/Luci… - HDR Toning… Viraggio HDR… + HDR Toning… Mappatura tonale HDR… Desaturate Desatura Match Color… Corrispondenza colore… Replace Color… Sostituisci colore… @@ -265,7 +265,7 @@ Data Sets… Set di dati… Apply Data Set… Applica set di dati… Variable Data Sets… Set di dati variabili… - Trap… Sovrastampa… + Trap… Trapping… Analysis Analisi Set Measurement Scale… Imposta scala misurazione… Select Data Points… Seleziona punti dati… @@ -302,7 +302,7 @@ Create Layer Crea livello Layer Style Stile livello Blending Options… Opzioni di fusione… - Bevel & Emboss… Smusso ed effetto rilievo… + Bevel & Emboss… Smusso e rilievo… Use Global Light Usa luce globale Inner Shadow… Ombra interna… Inner Glow… Bagliore interno… @@ -348,7 +348,7 @@ Smart Object Oggetto avanzato Convert to Smart Object Converti in oggetto avanzato Convert to Embedded Converti in incorporato - Convert to Linked… Converti in collegato… + Convert to Linked… Converti in oggetto collegato… Edit Contents Modifica contenuto Replace Contents… Sostituisci contenuto… Relink to File… Ricollega al file… @@ -477,8 +477,8 @@ Convert to Point Text Converti in testo a riga Convert to Paragraph Text Converti in testo a paragrafo Default Features Funzionalità predefinite - East Asian Features Funzionalità Asia orientale - Middle Eastern Features Funzionalità Medio Oriente + East Asian Features Funzionalità est-asiatiche + Middle Eastern Features Funzionalità mediorientali Middle Eastern & South Asian Composer Compositore Medio Oriente e Asia meridionale Update All Text Layers Aggiorna tutti i livelli di testo Replace All Missing Fonts Sostituisci tutti i font mancanti @@ -1037,7 +1037,7 @@ Discord Discord Dismiss Chiudi Display P3 Display P3 - Display Text Testo display + Display Text Mostra testo Dither Dithering Divide… Dividi… Document Documento @@ -1573,7 +1573,7 @@ Art & Illustration Arte e illustrazione ArtCraft ArtCraft Background Eraser Tool Strumento Gomma per sfondo - Bevel & Emboss Smusso ed effetto rilievo + Bevel & Emboss Smusso e rilievo Blend Mode Metodo di fusione Blues Blu Blur Tool Strumento Sfoca @@ -2964,7 +2964,7 @@ curves Smooth Leviga Web Photo Gallery… Galleria fotografica web… Choose… Scegli… Select Folder… Seleziona cartella… - Override Action "Save" Commands Ignora i comandi "Salva" dell'azione + Override Action "Save" Commands Sostituisci i comandi "Salva" dell'azione File Naming Denominazione file Compatibility Compatibilità Mac OS Mac OS @@ -3327,7 +3327,7 @@ layerLabel No Color Nessun colore Frame Cornice Arrangement Disposizione Thickness Spessore - Vine Color Colore rampicante + Vine Color Colore del rampicante Flower Color Colore fiori Leaf Color Colore foglie Base Tree Type Tipo di albero base @@ -3696,9 +3696,9 @@ layerLabel No Color Nessun colore Color Lookup Tables Tabelle ricerca colore Color Settings Impostazioni colore Color Table Tabella colori - Conditional Mode Change Cambio modo condizionale + Conditional Mode Change Cambia metodo condizionale Content-Aware Fill Riempimento in base al contenuto - Convert to Linked Converti in collegato + Convert to Linked Converti in oggetto collegato Convert to Profile Converti in profilo Crystallize Cristallizza Data Dati @@ -3728,7 +3728,7 @@ layerLabel No Color Nessun colore Gaussian Blur Sfocatura gaussiana Global Light Luce globale Group from Layers Gruppo da livelli - HDR Toning Viraggio HDR + HDR Toning Mappatura tonale HDR High Pass Passa alto Highlight Luci Image Processor Elaborazione immagini @@ -3770,7 +3770,7 @@ layerLabel No Color Nessun colore Picture Frame Cornice immagine Pinch Pizzica Place Embedded Inserisci incorporato - Place Linked Inserisci collegato + Place Linked Inserisci oggetto collegato Place Scale Marker Inserisci indicatore scala Plastic Wrap Pellicola plastica Pointillize Puntinatura @@ -3818,7 +3818,7 @@ layerLabel No Color Nessun colore Tiles Mattonelle Tilt-Shift Miniatura Trace Contour Traccia contorno - Trap Sovrastampa + Trap Trapping Tree Albero Trim Elimina bordi Turbulent Turbolento diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 4af4755085..fe37bd0d21 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -775,8 +775,8 @@ mod tests { #[test] fn new_document_depth_labels_and_tooltips_are_translated() { // Bit-depth labels are unit/number strings that some languages legitimately write exactly - // like the English source (Turkish "8 bit"); they only need a catalog entry there. - const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit")]; + // like the English source (Turkish and Italian "8 bit"); they only need a catalog entry there. + const SAME_AS_SOURCE: &[(&str, &str)] = &[("tr", "8 bit"), ("tr", "16 bit"), ("it", "8 bit"), ("it", "16 bit")]; for lang in crate::i18n::Lang::all().filter(|lang| lang.code() != "en") { for (_, label, tooltip) in DEPTH_OPTIONS { if SAME_AS_SOURCE.contains(&(lang.code(), *label)) { diff --git a/docs/localization-it.md b/docs/localization-it.md index d2cf0a516b..7b0444410e 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -39,6 +39,17 @@ command names joined by a slash, which keep both capitals (*Luminosità/Contrast | Footage | Filmato | *Interpreta filmato*, *Sostituisci filmato* | | Facet | Sfaccetta | | | SemiBold / ExtraBold / ExtraLight | Semigrassetto / Extragrassetto / Extraleggero | one spelling for every weight | +| (Color) Mode | Metodo | *Cambia metodo condizionale*, never *modo* | +| Trap | Trapping | the prepress term; *Sovrastampa* is overprint, a different thing | +| HDR Toning | Mappatura tonale HDR | tone mapping; *Viraggio* stays for colour toning (*Viraggio separato*) | +| Bevel & Emboss | Smusso e rilievo | pairs with *Rilievo* (Emboss) | +| Linked (smart object) | Oggetto collegato | *Inserisci oggetto collegato*, *Converti in oggetto collegato* | + +## Native review + +2026-10-12: a native Italian speaker read the catalog and fixed mistranslations (Trap, HDR +Toning, Display Text, Vine Color, Override Action "Save" Commands) and unnatural phrasing (Bevel & +Emboss, East Asian / Middle Eastern Features, linked smart objects, bit depth labels). ## Not covered diff --git a/docs/localization-parity.md b/docs/localization-parity.md index cba241e1be..58c96b4dc6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Italian dynamic labels; previously minor: Czech review follow-ups) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian native review; previously minor: Turkish catalog) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -23,8 +23,8 @@ composer, and its text engine handles RTL, Devanagari and Indic scripts. **Summary.** 7 of the 12 key languages at 100% of measured keys (status `partial` because of the engine messages), 3 absent (Hindi, Arabic, Vietnamese), plus 8 other languages. Weighted by -speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (none recorded for any -catalog). +speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorded only for Italian, +2026-10-12). ## The twelve key languages @@ -48,8 +48,8 @@ catalog). 8 more, each at 1,608 / 1,608 measured keys, status `partial` for the same reason: Traditional Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the -other catalogs translate, [notes](localization-it.md)). Turkish `tr` (3,731 rows) also covers every -measured key. +other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` +(3,731 rows) also covers every measured key. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -81,3 +81,4 @@ engine localization or native review is complete. | 2026-10-11 | minor | Czech review follow-ups: English System Info diagnostics, id-aware shortcut labels, display-only auto translation, warning punctuation and wording; catalog row count refreshed | | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | +| 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | From 3ba0e9419c410c893fb2e70790c1b580782a3dd7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 01:39:55 +0200 Subject: [PATCH 190/523] Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339) The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0 (outside sRGB) came back as 100/28/0/17 when a CMYK document was filled. tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends the entered inks, and Edit > Fill (and the fill keys) write them as they are into a CMYK target. Fill also enters the document's CMYK profile. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 2 +- crates/engine/src/commands.rs | 38 +++++++++++++-- crates/engine/src/fill_cmds.rs | 66 ++++++++++++++++++++------- crates/engine/src/fill_key_cmds.rs | 29 ++++++++++++ crates/engine/src/lib.rs | 23 ++++++++++ crates/ui-egui/src/color_picker_ui.rs | 32 ++++++++++++- 6 files changed, 165 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 16b7d72a29..abc6b49d96 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -249,7 +249,7 @@ fn erase_locked(brush: &mut BrushSettings, lock: bool, bg: [f32; 4]) { /// The document's own CMYK profile (absent, built-in or not a CMYK document: `None`, the /// default conversions), which the stroke's colour conversions must honour — the compositors /// and composite exports enter the same scope. -fn doc_cmyk_space(s: &Session) -> Option> { +pub(crate) fn doc_cmyk_space(s: &Session) -> Option> { photocraft_color::convert::CmykSpace::for_profile(s.active().and_then(|d| d.doc.icc_profile.as_ref())) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7a1cc4ffb8..09db339bfe 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -966,11 +966,39 @@ fn build() -> Vec { has_paintable, crate::brush_cmds::paint_stroke ), - cmd!("tools.setColors", "Set Colors", [], None, r##"{"foreground":"#rrggbb"?,"background":"#rrggbb"?}"##, always, |s, p| { - s.tools.foreground = color_param(p, "foreground", s.tools.foreground); - s.tools.background = color_param(p, "background", s.tools.background); - Ok(Value::Null) - }), + cmd!( + "tools.setColors", + "Set Colors", + [], + None, + 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> { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; + let inks: Option> = 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 (fg_cmyk, bg_cmyk) = (cmyk("foregroundCmyk")?, cmyk("backgroundCmyk")?); + s.tools.foreground = color_param(p, "foreground", s.tools.foreground); + s.tools.background = color_param(p, "background", s.tools.background); + if let Some(k) = fg_cmyk { + let [r, g, b, _] = s.tools.foreground; + s.tools.remember_cmyk([r, g, b], k); + } + if let Some(k) = bg_cmyk { + let [r, g, b, _] = s.tools.background; + s.tools.remember_cmyk([r, g, b], k); + } + Ok(Value::Null) + } + ), cmd!("tools.swapColors", "Switch Foreground and Background Colors", [], Some("X"), "{}", always, |s, _| { std::mem::swap(&mut s.tools.foreground, &mut s.tools.background); Ok(Value::Null) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index 130977ce28..d99ebd9a55 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -25,6 +25,12 @@ fn bad(msg: impl Into) -> EngineError { /// What a pixel of the filled area gets before blending. enum Source { Color([f32; 4]), + /// An opaque colour that was picked as CMYK inks: `rgba` is its sRGB stand-in (what other + /// colour models and blend modes use), `cmyk` the inks a CMYK target gets as they are. + Cmyk { + rgba: [f32; 4], + cmyk: [f32; 4], + }, /// Straight RGBA per pixel of `rect` (row-major). Pixels(Rect, Vec<[f32; 4]>), } @@ -33,6 +39,7 @@ impl Source { fn at(&self, x: i32, y: i32) -> [f32; 4] { match self { Source::Color(c) => *c, + Source::Cmyk { rgba, .. } => *rgba, Source::Pixels(r, px) => { if !r.contains(x, y) { return [0.0; 4]; @@ -97,9 +104,9 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { let mut out = json!({ "contents": contents }); let restore = contents == "history"; let source = match contents { - "foreground" => Source::Color(s.tools.foreground), - "background" => Source::Color(s.tools.background), - "color" => Source::Color(color_param(p, "color", s.tools.foreground)), + "foreground" => color_source(s, s.tools.foreground), + "background" => color_source(s, s.tools.background), + "color" => color_source(s, color_param(p, "color", s.tools.foreground)), "black" => Source::Color([0.0, 0.0, 0.0, 1.0]), "gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]), "white" => Source::Color([1.0; 4]), @@ -145,15 +152,44 @@ pub fn fill(s: &mut Session, p: &Value) -> Result { if area.is_empty() { return Ok(out); } - s.edit("Fill", |doc, _| { - let sel = doc.selection.clone(); - let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; - blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore })?; - Ok(()) + // The document's own CMYK profile converts the fill colour, like the compositor and brush. + let cmyk = crate::brush_cmds::doc_cmyk_space(s); + photocraft_color::convert::with_cmyk_space(cmyk.as_ref(), || { + s.edit("Fill", |doc, _| { + let sel = doc.selection.clone(); + let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; + blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore })?; + Ok(()) + }) })?; Ok(out) } +/// A fill colour: the inks it was picked as when it was picked as CMYK (#4196), else plain sRGB. +fn color_source(s: &Session, c: [f32; 4]) -> Source { + match s.tools.picked_cmyk([c[0], c[1], c[2]]) { + Some(cmyk) if c[3] >= 1.0 => Source::Cmyk { rgba: c, cmyk }, + _ => Source::Color(c), + } +} + +/// `source`'s opaque colour as samples of `fmt` (alpha last, as in [`from_rgba_into`]), or `None` +/// for a source that is not one colour. A CMYK pick keeps its inks in a CMYK target. +fn encode_color(fmt: &photocraft_color::PixelFormat, source: &Source, enc: &mut [f32; 8]) -> Option { + let (rgba, cmyk) = match source { + Source::Color(c) if c[3] >= 1.0 => (*c, None), + Source::Cmyk { rgba, cmyk } => (*rgba, Some(*cmyk)), + _ => return None, + }; + let n = from_rgba_into(fmt, rgba, enc); + if let Some(k) = cmyk + && fmt.mode == photocraft_color::ColorMode::Cmyk + { + enc[..4].copy_from_slice(&k); + } + Some(n) +} + fn read_rgba(src: &Surface, area: Rect, purpose: &str) -> Result> { let len = (area.width() as usize) .checked_mul(area.height() as usize) @@ -336,12 +372,9 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf let fmt = surf.format(); // An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become // one shared solid tile (copy-on-write), which costs nothing per pixel. - let solid = match source { - Source::Color(c) if c[3] >= 1.0 && b.mode == BlendMode::Normal && b.opacity >= 1.0 && (!b.keep_alpha || !fmt.alpha) => { - let mut enc = [0.0f32; 8]; - let n = from_rgba_into(&fmt, *c, &mut enc); - Some(surf.solid_tile(enc.get(..n).unwrap_or(&[]))) - } + let mut enc = [0.0f32; 8]; + let solid = match encode_color(&fmt, source, &mut enc) { + Some(n) if b.mode == BlendMode::Normal && b.opacity >= 1.0 && (!b.keep_alpha || !fmt.alpha) => Some(surf.solid_tile(enc.get(..n).unwrap_or(&[]))), _ => None, }; let count = area.tiles().count(); @@ -409,9 +442,8 @@ fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rec let mut enc = [0.0f32; 8]; // An opaque colour at full coverage in Normal mode just replaces the colour (and the alpha // unless it is kept): copy its encoded bytes. - let opaque = match source { - Source::Color(c) if c[3] >= 1.0 && mode == BlendMode::Normal => { - let m = from_rgba_into(fmt, *c, &mut enc); + let opaque = match encode_color(fmt, source, &mut enc) { + Some(m) if mode == BlendMode::Normal => { let mut bytes = vec![0u8; bpp]; photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), &mut bytes); // Keeping alpha: copy the colour samples only (alpha is the last sample). diff --git a/crates/engine/src/fill_key_cmds.rs b/crates/engine/src/fill_key_cmds.rs index 47559e2374..8beff0dc0e 100644 --- a/crates/engine/src/fill_key_cmds.rs +++ b/crates/engine/src/fill_key_cmds.rs @@ -292,6 +292,35 @@ mod tests { } } + /// The inks of the active layer's pixel at (0, 0), exactly as stored. + fn inks(s: &Session) -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(photocraft_geom::Rect::new(0, 0, 1, 1)).into_iter().take(4).collect() + } + + /// #4196: a foreground picked as CMYK 100/0/0/0 fills a CMYK document with those inks, not with + /// whatever its sRGB stand-in converts back to (100/28/0/17). + #[test] + fn cmyk_pick_fills_a_cmyk_document_with_the_picked_inks() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 4, "height": 4})).unwrap(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb([1.0, 0.0, 0.0, 0.0]).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": [1.0, 0.0, 0.0, 0.0]})).unwrap(); + s.execute("edit.fillForeground", json!({})).unwrap(); + let got = inks(&s); + assert!(got.iter().zip([1.0, 0.0, 0.0, 0.0]).all(|(a, b)| (a - b).abs() < 0.01), "{got:?}"); + } + + #[test] + fn malformed_cmyk_pick_is_an_error_not_a_panic() { + let mut s = session(); + for bad in [json!([1.0]), json!("cyan"), json!([1.0, 0.0, 0.0, "x"]), json!({"c": 1})] { + assert!(s.execute("tools.setColors", json!({"foregroundCmyk": bad})).is_err()); + } + } + #[test] fn registered_with_photoshop_default_shortcuts() { let sc = |id| crate::commands::find(id).and_then(|c| c.shortcut); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4df2a0a81f..27a2f941a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -299,9 +299,31 @@ pub struct ToolState { /// The foreground/background pair of the edit target that isn't current: the layer pixels' /// colours while a mask is targeted, the mask's while the pixels are (#2166). pub other_colors: [[f32; 4]; 2], + /// Colours picked as CMYK, newest last: the sRGB colour that stands for them and the ink + /// values chosen. The tool colours are stored as sRGB, and an sRGB round trip does not give + /// back what was typed (100/0/0/0 cyan is outside sRGB), so a CMYK document is filled with + /// the inks the person picked (#4196). Matched by colour, so it follows a swap, a reset or a + /// mask pair without bookkeeping. + pub cmyk_picks: Vec<([f32; 3], [f32; 4])>, } impl ToolState { + /// How many CMYK picks are remembered. + const CMYK_PICKS: usize = 8; + + /// Remember that sRGB colour `rgb` was picked as the inks `cmyk` (each 0..1). + pub fn remember_cmyk(&mut self, rgb: [f32; 3], cmyk: [f32; 4]) { + self.cmyk_picks.retain(|(c, _)| *c != rgb); + self.cmyk_picks.push((rgb, cmyk)); + let extra = self.cmyk_picks.len().saturating_sub(Self::CMYK_PICKS); + self.cmyk_picks.drain(..extra); + } + + /// The inks `rgb` was picked as, if it was picked as CMYK. + pub fn picked_cmyk(&self, rgb: [f32; 3]) -> Option<[f32; 4]> { + self.cmyk_picks.iter().rev().find(|(c, _)| *c == rgb).map(|(_, k)| *k) + } + /// Photoshop's colour pair for a layer mask that has none yet: white foreground, black /// background (reveal-paint first). pub const MASK_COLORS: [[f32; 4]; 2] = [[1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]]; @@ -340,6 +362,7 @@ impl Default for ToolState { current_preset: None, mask_targeted: false, other_colors: Self::MASK_COLORS, + cmyk_picks: Vec::new(), } } } diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index 30752c462f..24f0afd78a 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -171,7 +171,12 @@ pub fn open(app: &mut PhotocraftApp, target: &str) -> u64 { let c = if target == "background" { app.session.tools.background } else { app.session.tools.foreground }; let rgb = [c[0], c[1], c[2]]; let label = if target == "background" { "Color Picker (Background Color)" } else { "Color Picker (Foreground Color)" }; - app.ui.open_dialog(DialogKind::Command, initial_fields(target, label, rgb)) + let mut fields = initial_fields(target, label, rgb); + // A colour that was picked as CMYK shows the inks it was picked as, not what its sRGB stand-in converts to (#4196). + if let Some(cmyk) = app.session.tools.picked_cmyk(rgb) { + fields.insert("__cmyk".into(), json!(cmyk)); + } + app.ui.open_dialog(DialogKind::Command, fields) } /// Shared initial state for every target; the picker layout is independent of its destination. @@ -787,7 +792,12 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map) -> Result(f, "__cmyk", rgb, photocraft_color::convert::cmyk_to_rgb)) { + params[format!("{target}Cmyk")] = json!(cmyk); + } + let r = app.run("tools.setColors", params)?; if target == "foreground" { crate::type_tool::foreground_changed(app); } @@ -1109,6 +1119,24 @@ mod tests { assert_eq!(hex([app.session.tools.background[0], app.session.tools.background[1], app.session.tools.background[2]]), "#3366cc"); } + /// #4196: CMYK values typed into the picker survive OK, so the colour fills a CMYK document with those inks and + /// reopening the picker shows them again. + #[test] + fn cmyk_values_survive_ok_and_a_reopen() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let cyan = [1.0, 0.0, 0.0, 0.0]; + let id = open(&mut app, "foreground"); + let d = app.ui.dialog_mut(id).unwrap(); + set_rgb(&mut d.fields, photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)), Keep::Cmyk(cyan)); + let fields = d.fields.clone(); + confirm(&mut app, &fields).unwrap(); + let fg = app.session.tools.foreground; + assert_eq!(app.session.tools.picked_cmyk([fg[0], fg[1], fg[2]]), Some(cyan)); + let id = open(&mut app, "foreground"); + let again = current(&app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields); + assert_eq!(again.cmyk, cyan); + } + fn dialog_color(app: &PhotocraftApp, id: u64) -> &str { app.ui.dialogs.iter().find(|d| d.id == id).and_then(|d| d.fields.get("color")).and_then(Value::as_str).unwrap() } From f48a72cbe3488b6baad53a9e43bec31ebd88e7a2 Mon Sep 17 00:00:00 2001 From: Przemyslaw Majdak <31745530+przemyslvw@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:44:04 +0200 Subject: [PATCH 191/523] Codecs: open and save DPX frames (#1049) (#4104) Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16 bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit packing 0 or 1. 10- and 12-bit code values open as U16 round(c * 65535 / (2^b - 1)) and come back exactly when written at their depth. No colour transform: the transfer characteristic and colorimetric specification are reported as import notes; extra image elements are reported; the orientation is applied on import. Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits (EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC; `--dpx-bits` in the CLI), following the document depth by default. Gray with alpha is written as luma. Creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate and time code travel as DPX:* text metadata and are written back. Unsupported descriptors, signed or run-length-encoded data and other bit depths return CodecError::Unsupported; malformed and truncated files return errors. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-cli/src/lib.rs | 17 +- apps/photocraft-cli/tests/cli.rs | 34 ++ apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 5 +- crates/automation/src/rpc.rs | 1 + crates/automation/src/server.rs | 10 + crates/codecs/README.md | 14 + crates/codecs/src/codecs/dpx.rs | 656 +++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/fidelity.rs | 12 +- crates/codecs/src/format.rs | 13 +- crates/codecs/src/image.rs | 33 ++ crates/codecs/src/lib.rs | 7 +- crates/codecs/src/options.rs | 35 ++ crates/codecs/tests/detect_caps.rs | 1 + crates/codecs/tests/dpx.rs | 386 ++++++++++++++++ crates/codecs/tests/malformed.rs | 2 +- crates/codecs/tests/roundtrip.rs | 3 + crates/engine/src/file_cmds.rs | 29 +- crates/engine/src/file_cmds/tests.rs | 26 ++ crates/io/src/lib.rs | 1 + crates/io/tests/flat.rs | 22 + docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 24 files changed, 1296 insertions(+), 26 deletions(-) create mode 100644 crates/codecs/src/codecs/dpx.rs create mode 100644 crates/codecs/tests/dpx.rs diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index 7bf7f8164d..ce1cbeaf9e 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -15,18 +15,19 @@ pub const USAGE: &str = "\ photocraft-cli: headless Photocraft USAGE: - photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli convert [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …). TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data). --quality sets the JPEG or WebP quality; a WebP written with a quality is lossy, without one lossless. --tga-bits sets a TGA's bits per pixel: 32 writes the alpha channel (or else the transparency) as its alpha, 24 none. Without it: 32 with an alpha channel or transparency, else 24. + --dpx-bits sets a DPX's bits per sample. Without it: 8 for an 8-bit document, else 16. photocraft-cli info [--compact] Print the document as JSON (size, mode, depth, layer tree). - photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli run ( | --new ) --cmd [--params ] [--cmd …] [--out ] [--format ] [--quality <1-100>] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Open a file, run engine commands in order, save the result. Each --params applies to the preceding --cmd. Prints each command's JSON result. - photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] + photocraft-cli batch --actions [--action ] --in --out [--format ] [--quality <1-100>] [--in-place] [--tiff-layers] [--tga-bits <24|32>] [--dpx-bits <8|10|12|16>] Apply an action list to every image in a directory. A Photoshop Actions file (.atn) is a set: its action --action names plays, as below. JSON steps are [id, params] pairs, {\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them @@ -68,17 +69,17 @@ struct Subcommand { } const SUBCOMMANDS: &[Subcommand] = &[ - Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits"], bare: &["--tiff-layers"], run: convert }, + Subcommand { name: "convert", values: &["--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: convert }, Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) }, Subcommand { name: "run", - values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits"], + values: &["--new", "--cmd", "--params", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--tiff-layers"], run: run_cmds, }, Subcommand { name: "batch", - values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits"], + values: &["--actions", "--action", "--in", "--out", "--format", "--quality", "--tga-bits", "--dpx-bits"], bare: &["--in-place", "--tiff-layers"], run: batch, }, @@ -214,6 +215,10 @@ fn export_opts(a: &Args) -> Result { if let Some(b) = a.get("--tga-bits") { o.tga_bits = Some(b.parse().ok().and_then(photocraft_io::TgaBits::from_bits).ok_or_else(|| format!("bad --tga-bits `{b}`: expected 24 or 32"))?); } + if let Some(b) = a.get("--dpx-bits") { + o.encode.dpx_bits = + Some(b.parse().ok().and_then(photocraft_io::DpxBits::from_bits).ok_or_else(|| format!("bad --dpx-bits `{b}`: expected 8, 10, 12 or 16"))?); + } Ok(o) } diff --git a/apps/photocraft-cli/tests/cli.rs b/apps/photocraft-cli/tests/cli.rs index 162483bcf9..231eb2c321 100644 --- a/apps/photocraft-cli/tests/cli.rs +++ b/apps/photocraft-cli/tests/cli.rs @@ -455,6 +455,40 @@ fn quality_outside_1_to_100_is_refused() { assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}"); } +/// DPX converts both ways, `info` reports its size, mode and depth, and `--dpx-bits` picks the +/// bits per sample (byte 803 of the header); anything but 8, 10, 12 or 16 is refused (#1049). +#[test] +fn dpx_converts_both_ways_with_the_bits_asked_for() { + let d = tmp("dpx"); + let a = d.join("a.png"); + write_png(&a, 8, 4, 3); + for (bits, want) in [(None, 8), (Some("10"), 10), (Some("12"), 12), (Some("16"), 16)] { + let out = d.join("a.dpx"); + let mut convert = bin(); + convert.arg("convert").arg(&a).arg(&out); + if let Some(b) = bits { + convert.args(["--dpx-bits", b]); + } + ok(&mut convert); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], want), "--dpx-bits {bits:?}"); + } + let (info, _) = ok(bin().arg("info").arg(d.join("a.dpx")).arg("--compact")); + let v: Value = serde_json::from_str(&info).unwrap(); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(8), Some(4)), "{v}"); + assert!(info.contains("16") && info.to_lowercase().contains("rgb"), "{info}"); + let back = d.join("back.png"); + ok(bin().arg("convert").arg(d.join("a.dpx")).arg(&back)); + assert!(back.exists()); + for bad in ["24", "0", "abc"] { + let out = d.join("bad.dpx"); + let o = bin().arg("convert").arg(&a).arg(&out).args(["--dpx-bits", bad]).output().unwrap(); + assert_eq!(o.status.code(), Some(1), "--dpx-bits {bad}"); + assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --dpx-bits `{bad}`: expected 8, 10, 12 or 16")), "--dpx-bits {bad}"); + assert!(!out.exists(), "--dpx-bits {bad} wrote a file"); + } +} + /// `--tga-bits` picks a TGA's bits per pixel (byte 16 of its header); anything but 24 or 32 is /// refused and nothing is written. #[test] diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index 16781e42bb..daf4ed9efb 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd", - "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", + "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index fea0d34541..faf0b6a11f 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", + "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -62,6 +62,7 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("Targa", &["tga"]), ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), + ("DPX", &["dpx"]), ]; /// Non-document files the shell saves (Swatches panel exports): offered alone, so the dialog diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 875850460c..1599eb6a53 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -116,6 +116,7 @@ impl Headless { let tga_bits = photocraft_engine::file_cmds::tga_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; let mut opts = photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits, ..Default::default() }; + opts.encode.dpx_bits = photocraft_engine::file_cmds::dpx_bits_param(&p, "doc.save").map_err(|e| bad(e.to_string()))?; if let Some(q) = p.get("quality").and_then(Value::as_u64) { opts.encode.jpeg_quality = q.clamp(1, 100) as u8; opts.encode.webp_quality = q.clamp(1, 100) as u8; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index c2f18b8bd5..71c290cae7 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -167,6 +167,9 @@ pub struct SaveParams { /// alpha, 24 none. Unset: 32 with an alpha channel or transparency, else 24. #[serde(default, rename = "tgaBits")] pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample. Unset: 8 for an 8-bit document, else 16. + #[serde(default, rename = "dpxBits")] + pub dpx_bits: Option, #[serde(default)] pub index: Option, } @@ -987,6 +990,11 @@ impl PhotocraftMcp { Some((_, Some(bits))) => Some(bits), Some((b, None)) => return Ok(fail(format!("`tgaBits` must be 24 or 32, got {b}"))), }; + let dpx_bits = match p.dpx_bits.map(|b| (b, photocraft_io::DpxBits::from_bits(u64::from(b)))) { + None => None, + Some((_, Some(bits))) => Some(bits), + Some((b, None)) => return Ok(fail(format!("`dpxBits` must be 8, 10, 12 or 16, got {b}"))), + }; if let Some(b) = self.bridge_client() { // The bridge forwards to the running app's `app.save`, which takes only an optional // path (without one it writes back to the document's own layered file, as headless @@ -996,6 +1004,7 @@ impl PhotocraftMcp { p.format.is_some().then_some("format"), p.quality.is_some().then_some("quality"), p.tiff_layers.then_some("tiffLayers"), + dpx_bits.is_some().then_some("dpxBits"), p.index.is_some().then_some("index"), ] .into_iter() @@ -1016,6 +1025,7 @@ impl PhotocraftMcp { let Some(r) = self .headless_op(move |h| { let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, tga_bits, ..Default::default() }; + opts.encode.dpx_bits = dpx_bits; if let Some(q) = p.quality { opts.encode.jpeg_quality = q.clamp(1, 100); opts.encode.webp_quality = q.clamp(1, 100); diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e44890a0d9..8cba2b1f37 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -45,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | | Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` | | FITS | yes | yes | U8, U16, F32 | Gray, RGB | no | no | no | no | no | yes (header cards) | no | built in | +| DPX | yes | yes | U8, U16 (10/12-bit code values read and written exactly through U16) | Gray, RGB, RGBA | yes | no | no | no | no | DPX header fields only (creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate, time code) | no | built in | | AVIF | **no** | only with feature `avif` | U8 | RGB, RGBA | yes | no | no | no | no | no | yes | `image` + `ravif` | | HEIF/HEIC | with feature `heif` | **no** | U8, U16 (10/12-bit decodes to U16) | RGB, RGBA | yes (auxiliary alpha) | yes (`colr` prof) | yes | yes | no | no | n/a | `heic-rs` | @@ -74,6 +75,19 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded kept as text (`HIERARCH` for long names). Tile-compressed (fpack) files are refused with `CodecError::Unsupported`; Bayer mosaics open undebayered. +* **DPX.** SMPTE 268M frames in either byte order (`SDPX`, `XPDS`): the first image element, + descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned and unencoded, at 8, 10, 12 or 16 bits. + 10-bit data may be packed (0) or filled three to a word (1, method A; 2, method B), 12-bit data + packed (0) or one to a 16-bit word (1, method A). 10- and 12-bit code values open as U16 + `round(c × 65535 / (2^b − 1))`, so a frame written back at its depth keeps them exactly. No + colour transform is applied: `DecodeWarning::DpxEncoding` names the transfer characteristic and + colorimetric specification, so a log frame is reported as log. More elements than the first + give `DecodeWarning::MoreElements`; the image orientation is applied on import. Writing is + big-endian, one unencoded element, at `EncodeOptions::dpx_bits` (8/10/12/16; by default 8 for + U8 and 16 otherwise), packing 1 for 10 and 12 bits; gray with alpha is written as luma. + Other descriptors, signed or run-length-encoded data and other bit depths return + `CodecError::Unsupported` naming the field. Cineon (`.cin`) is not supported. + * **Camera raw files** (DNG, CR2, NEF, ARW… which are TIFF-structured) are recognised and refused with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes and develops them, and `photocraft-io` routes them there. diff --git a/crates/codecs/src/codecs/dpx.rs b/crates/codecs/src/codecs/dpx.rs new file mode 100644 index 0000000000..8b772707cc --- /dev/null +++ b/crates/codecs/src/codecs/dpx.rs @@ -0,0 +1,656 @@ +//! DPX (SMPTE 268M Digital Picture Exchange), implemented here from the published layout: the +//! 1664-byte generic header (file and image information, image source), the 384-byte industry +//! header (film and television), then the image data of each element. +//! +//! Reading: +//! +//! * Both byte orders: `SDPX` (big-endian) and `XPDS` (little-endian). +//! * The first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), unsigned, without +//! run-length encoding, at 8, 10, 12 or 16 bits. 10-bit samples are packed into 32-bit words +//! (packing 0), or three to a word with the padding bits at the bottom (1, method A) or the +//! top (2, method B); 12-bit samples are packed (0) or one to a 16-bit word in its top bits +//! (1, method A). Lines start on a 32-bit word boundary. +//! * 8- and 16-bit code values become U8 and U16 samples unchanged; 10- and 12-bit ones become U16 +//! as `round(c × 65535 / (2^b − 1))`, so they come back exactly when written at their depth. +//! * No colour transform: the transfer characteristic and colorimetric specification are reported +//! ([`DecodeWarning::DpxEncoding`]), so a log frame is labelled log, not shown as display-referred. +//! * The image orientation is applied on import (the caller turns the image upright). +//! * Header fields a pipeline relies on travel as text metadata under the [`KEYS`] names, and a +//! DPX export writes them back. +//! +//! Writing: one element, big-endian, unencoded, descriptor 6, 50 or 51; 8 and 16 bits with +//! packing 0, 10 and 12 bits with packing 1 (method A). The bit depth follows the image +//! (8 → 8, 16 → 16) unless [`EncodeOptions::dpx_bits`] picks one. + +use crate::Format; +use crate::error::CodecError; +use crate::fidelity::Plan; +use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType}; +use crate::options::{DpxBits, EncodeOptions, Limits}; + +const F: Format = Format::Dpx; +/// Generic plus industry header: where our image data starts. +const HEADER_LEN: usize = 2048; +const GENERIC_LEN: u32 = 1664; +const INDUSTRY_LEN: u32 = 384; +/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float). +const UNDEF32: u32 = u32::MAX; +const UNDEF8: u8 = u8::MAX; +/// Offset of the first image element and the size of one. +const ELEMENT0: usize = 780; +const ELEMENT_LEN: usize = 72; +const MAX_ELEMENTS: u16 = 8; + +// Header field offsets (generic file header, image information, film and TV headers). +const CREATION_TIME: (usize, usize) = (136, 24); +const CREATOR: (usize, usize) = (160, 100); +const PROJECT: (usize, usize) = (260, 200); +const COPYRIGHT: (usize, usize) = (460, 200); +const ORIENTATION: usize = 768; +const FRAME_POSITION: usize = 1712; +const FRAME_RATE: usize = 1724; +const TIME_CODE: usize = 1920; + +/// Text metadata keys of the header fields kept through a DPX → document → DPX round trip. +pub(crate) const KEY_CREATOR: &str = "DPX:Creator"; +pub(crate) const KEY_PROJECT: &str = "DPX:Project"; +pub(crate) const KEY_COPYRIGHT: &str = "DPX:Copyright"; +pub(crate) const KEY_CREATION_TIME: &str = "DPX:CreationTime"; +pub(crate) const KEY_TRANSFER: &str = "DPX:Transfer"; +pub(crate) const KEY_COLORIMETRIC: &str = "DPX:Colorimetric"; +pub(crate) const KEY_FRAME_POSITION: &str = "DPX:FramePosition"; +pub(crate) const KEY_FRAME_RATE: &str = "DPX:FrameRate"; +pub(crate) const KEY_TIME_CODE: &str = "DPX:TimeCode"; +pub(crate) const KEYS: [&str; 9] = + [KEY_CREATOR, KEY_PROJECT, KEY_COPYRIGHT, KEY_CREATION_TIME, KEY_TRANSFER, KEY_COLORIMETRIC, KEY_FRAME_POSITION, KEY_FRAME_RATE, KEY_TIME_CODE]; + +fn err(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(F, msg) +} + +fn unsupported(msg: impl Into) -> CodecError { + CodecError::unsupported(F, msg) +} + +/// The file's byte order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Order { + Big, + Little, +} + +impl Order { + fn bytes(b: &[u8], at: usize) -> Option<[u8; N]> { + b.get(at..at.checked_add(N)?)?.try_into().ok() + } + + fn u16(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<2>(b, at)?; + Some(if self == Order::Big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + + fn u32(self, b: &[u8], at: usize) -> Option { + let s = Self::bytes::<4>(b, at)?; + Some(if self == Order::Big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } + + /// A 32-bit field that may be undefined (all ones). + fn defined_u32(self, b: &[u8], at: usize) -> Option { + self.u32(b, at).filter(|&v| v != UNDEF32) + } + + fn f32(self, b: &[u8], at: usize) -> Option { + self.defined_u32(b, at).map(f32::from_bits).filter(|v| v.is_finite()) + } +} + +/// The byte order from the magic number, `None` when it is not DPX. +fn order(b: &[u8]) -> Option { + match b.get(..4)? { + b"SDPX" => Some(Order::Big), + b"XPDS" => Some(Order::Little), + _ => None, + } +} + +/// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` when +/// empty or undefined (all ones). +fn string(b: &[u8], (at, len): (usize, usize)) -> Option { + let raw = b.get(at..at.checked_add(len)?)?; + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect(); + Some(s.trim().to_string()).filter(|s| !s.is_empty()) +} + +/// The descriptive fields of image element 0 that decoding needs. +#[derive(Debug, Clone, Copy)] +struct Element { + signed: bool, + descriptor: u8, + transfer: u8, + colorimetric: u8, + bits: u8, + packing: u16, + encoding: u16, + data_offset: usize, + eol_padding: usize, +} + +struct Header { + order: Order, + width: u32, + height: u32, + elements: u16, + element: Element, +} + +fn parse(b: &[u8]) -> Result { + let o = order(b).ok_or_else(|| err("missing SDPX/XPDS magic number"))?; + let missing = |what: &str| err(format!("the header ends before {what}")); + let image_offset = o.u32(b, 4).ok_or_else(|| missing("the image data offset"))?; + o.u16(b, ORIENTATION).ok_or_else(|| missing("the orientation"))?; + let elements = o.u16(b, ORIENTATION + 2).ok_or_else(|| missing("the element count"))?; + if !(1..=MAX_ELEMENTS).contains(&elements) { + return Err(err(format!("invalid number of image elements {elements}"))); + } + let width = o.u32(b, 772).ok_or_else(|| missing("the line length"))?; + let height = o.u32(b, 776).ok_or_else(|| missing("the line count"))?; + let e = ELEMENT0; + let field = |at: usize| o.u32(b, e + at).ok_or_else(|| missing("image element 1")); + let byte = |at: usize| b.get(e + at).copied().ok_or_else(|| missing("image element 1")); + let half = |at: usize| o.u16(b, e + at).ok_or_else(|| missing("image element 1")); + let data_offset = match field(28)? { + 0 | UNDEF32 => image_offset, + v => v, + }; + let eol_padding = match field(32)? { + UNDEF32 => 0, + v => v, + }; + let element = Element { + signed: field(0)? == 1, + descriptor: byte(20)?, + transfer: byte(21)?, + colorimetric: byte(22)?, + bits: byte(23)?, + packing: half(24)?, + encoding: half(26)?, + data_offset: usize::try_from(data_offset).map_err(|_| err("image data offset out of range"))?, + eol_padding: usize::try_from(eol_padding).map_err(|_| err("line padding out of range"))?, + }; + Ok(Header { order: o, width, height, elements, element }) +} + +/// The EXIF orientation (1–8) that turns the stored lines upright: the DPX orientation codes 0–7 +/// in SMPTE 268M order (left-to-right top-to-bottom, mirrored, flipped, rotated, transposed…). +/// Anything else, undefined included, is taken as the default (1). +pub(crate) fn orientation(b: &[u8]) -> u16 { + let Some(o) = order(b).and_then(|o| o.u16(b, ORIENTATION)) else { return 1 }; + match o { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } +} + +/// Bytes in one stored line of `samples` samples, padded to a 32-bit word. +fn line_bytes(samples: usize, bits: u8, packing: u16) -> Option { + let words = match (bits, packing) { + // Three samples to a word, or one to a 16-bit half word. + (10, 1 | 2) => samples.div_ceil(3), + (12, 1 | 2) => samples.div_ceil(2), + _ => samples.checked_mul(usize::from(bits))?.div_ceil(32), + }; + words.checked_mul(4) +} + +/// Code values of one stored line. +fn unpack_line(line: &[u8], o: Order, bits: u8, packing: u16, out: &mut [u16]) -> Result<(), CodecError> { + let short = || err("truncated image data"); + match (bits, packing) { + (8, _) => { + let src = line.get(..out.len()).ok_or_else(short)?; + for (v, &c) in out.iter_mut().zip(src) { + *v = u16::from(c); + } + } + (16, _) => { + for (i, v) in out.iter_mut().enumerate() { + *v = o.u16(line, i * 2).ok_or_else(short)?; + } + } + (10, 1 | 2) => { + let base = if packing == 1 { 22 } else { 20 }; + for (i, v) in out.iter_mut().enumerate() { + let word = o.u32(line, i / 3 * 4).ok_or_else(short)?; + *v = ((word >> (base - 10 * (i % 3) as u32)) & 0x3FF) as u16; + } + } + (12, 1 | 2) => { + let shift = if packing == 1 { 4 } else { 0 }; + for (i, v) in out.iter_mut().enumerate() { + *v = (o.u16(line, i * 2).ok_or_else(short)? >> shift) & 0xFFF; + } + } + // Packing 0: one bit stream through the line's 32-bit words, high bits first. + _ => { + let mask = (1u64 << bits) - 1; + let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize); + for v in out.iter_mut() { + while have < u32::from(bits) { + acc = (acc << 32) | u64::from(o.u32(line, word * 4).ok_or_else(short)?); + word += 1; + have += 32; + } + have -= u32::from(bits); + *v = ((acc >> have) & mask) as u16; + } + } + } + Ok(()) +} + +/// A 10- or 12-bit code value as a 16-bit sample: `round(c × 65535 / max)`. +fn widen(c: u16, max: u64) -> u16 { + ((u64::from(c) * 2 * 65535 + max) / (2 * max)).min(65535) as u16 +} + +/// The inverse of [`widen`]: `round(v × max / 65535)`. +fn narrow(v: u16, max: u64) -> u16 { + ((u64::from(v) * 2 * max + 65535) / (2 * 65535)) as u16 +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + let h = parse(bytes)?; + let e = h.element; + let layout = match e.descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + 51 => ChannelLayout::Rgba, + d => return Err(unsupported(format!("descriptor {d} is not supported (only 6 luma, 50 RGB and 51 RGBA)"))), + }; + if e.signed { + return Err(unsupported("data sign 1 (signed samples) is not supported")); + } + if e.encoding != 0 { + return Err(unsupported(format!("encoding {} (run-length encoded data) is not supported", e.encoding))); + } + let packing_ok = match e.bits { + 8 | 16 => e.packing == 0, + 10 => e.packing <= 2, + 12 => e.packing <= 1, + b => return Err(unsupported(format!("bit size {b} is not supported (only 8, 10, 12 and 16)"))), + }; + if !packing_ok { + return Err(unsupported(format!("packing {} is not supported at {} bits", e.packing, e.bits))); + } + let sample = if e.bits == 8 { SampleType::U8 } else { SampleType::U16 }; + limits.check(h.width, h.height, layout, sample)?; + + let nc = layout.channels(); + let per_line = (h.width as usize).checked_mul(nc).ok_or_else(|| CodecError::LimitExceeded("line too long".into()))?; + let height = h.height as usize; + let data = bytes.get(e.data_offset..).ok_or_else(|| err("the image data offset is past the end of the file"))?; + let too_large = || CodecError::LimitExceeded("image too large".into()); + let mut line_len = line_bytes(per_line, e.bits, e.packing).ok_or_else(too_large)?; + let mut stride = line_len.checked_add(e.eol_padding).ok_or_else(too_large)?; + // Lines run `stride` apart; the last one needs no padding after it. + let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len); + // Some writers pack 8- and 16-bit lines without padding them to a word; take that layout + // when the file is too short for padded lines but holds the unpadded ones. + let tight = per_line * usize::from(e.bits / 8); + if matches!(e.bits, 8 | 16) && e.eol_padding == 0 && line_len != tight && span(stride, line_len).is_none_or(|need| data.len() < need) { + (line_len, stride) = (tight, tight); + } + if span(stride, line_len).ok_or_else(too_large)? > data.len() { + return Err(err("truncated image data")); + } + + let mut codes = vec![0u16; per_line.checked_mul(height).ok_or_else(too_large)?]; + for (y, row) in codes.chunks_exact_mut(per_line.max(1)).enumerate() { + let start = y * stride; + let line = data.get(start..).ok_or_else(|| err("truncated image data"))?; + unpack_line(line, h.order, e.bits, e.packing, row)?; + } + + let mut img = match e.bits { + 8 => Image::from_u8(h.width, h.height, layout, codes.iter().map(|&c| c as u8).collect())?, + 16 => Image::from_u16(h.width, h.height, layout, &codes)?, + b => { + let max = (1u64 << b) - 1; + let wide: Vec = codes.iter().map(|&c| widen(c, max)).collect(); + Image::from_u16(h.width, h.height, layout, &wide)? + } + }; + if h.elements > 1 { + img.warnings.push(DecodeWarning::MoreElements { total: u32::from(h.elements) }); + } + if e.transfer != UNDEF8 || e.colorimetric != UNDEF8 { + img.warnings.push(DecodeWarning::DpxEncoding { transfer: e.transfer, colorimetric: e.colorimetric }); + } + img.meta.text = header_text(bytes, h.order, e); + Ok(img) +} + +/// The kept header fields as text metadata, in [`KEYS`] order; undefined ones are left out. +fn header_text(b: &[u8], o: Order, e: Element) -> Vec<(String, String)> { + let mut text = Vec::new(); + let mut put = |k: &str, v: Option| { + if let Some(v) = v { + text.push((k.to_string(), v)); + } + }; + put(KEY_CREATOR, string(b, CREATOR)); + put(KEY_PROJECT, string(b, PROJECT)); + put(KEY_COPYRIGHT, string(b, COPYRIGHT)); + put(KEY_CREATION_TIME, string(b, CREATION_TIME)); + put(KEY_TRANSFER, (e.transfer != UNDEF8).then(|| e.transfer.to_string())); + put(KEY_COLORIMETRIC, (e.colorimetric != UNDEF8).then(|| e.colorimetric.to_string())); + // The industry header is there when the generic header says so and the file holds it. + let industry = o.u32(b, 28).is_some_and(|l| l != 0 && l != UNDEF32) && b.len() >= HEADER_LEN; + if industry { + put(KEY_FRAME_POSITION, o.defined_u32(b, FRAME_POSITION).map(|v| v.to_string())); + put(KEY_FRAME_RATE, o.f32(b, FRAME_RATE).map(|v| v.to_string())); + put(KEY_TIME_CODE, o.defined_u32(b, TIME_CODE).map(format_time_code)); + } + text +} + +/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD. +fn format_time_code(v: u32) -> String { + let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF); + if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") } +} + +/// The inverse of [`format_time_code`]; `None` for anything else. +fn parse_time_code(s: &str) -> Option { + if let Some(hex) = s.strip_prefix("0x") { + return u32::from_str_radix(hex, 16).ok(); + } + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) { + return None; + } + parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0'))) +} + +/// Bits written for `sample` when the options pick none: 8 for U8, 16 otherwise. +pub(crate) fn bits_for(opt: Option, sample: SampleType) -> u8 { + match opt { + Some(b) => b.bits(), + None if sample == SampleType::U8 => 8, + None => 16, + } +} + +/// The plan's sample type for the chosen bits: U8 for 8 bits, U16 for 10, 12 and 16. +pub(crate) fn plan_sample(opt: Option, src: SampleType) -> SampleType { + match opt { + Some(DpxBits::Bits8) => SampleType::U8, + Some(_) => SampleType::U16, + None if src == SampleType::U8 => SampleType::U8, + None => SampleType::U16, + } +} + +struct Writer { + h: Vec, +} + +impl Writer { + fn u8(&mut self, at: usize, v: u8) { + if let Some(b) = self.h.get_mut(at) { + *b = v; + } + } + + fn u16(&mut self, at: usize, v: u16) { + if let Some(s) = self.h.get_mut(at..at + 2) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + fn u32(&mut self, at: usize, v: u32) { + if let Some(s) = self.h.get_mut(at..at + 4) { + s.copy_from_slice(&v.to_be_bytes()); + } + } + + /// Marks a run of numeric fields undefined (all ones). + fn undefined(&mut self, from: usize, to: usize) { + if let Some(s) = self.h.get_mut(from..to) { + s.fill(0xFF); + } + } + + /// An ASCII string field, cut to leave room for a terminating NUL. + fn string(&mut self, (at, len): (usize, usize), v: &str) { + let ascii: Vec = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect(); + if let Some(s) = self.h.get_mut(at..at + ascii.len()) { + s.copy_from_slice(&ascii); + } + } +} + +fn text<'a>(img: &'a Image, key: &str) -> Option<&'a str> { + img.meta.text.iter().find(|(k, _)| k == key).map(|(_, v)| v.trim()).filter(|v| !v.is_empty()) +} + +/// Appends one line's code values, padded to a 32-bit word. +fn pack_line(out: &mut Vec, codes: &[u16], bits: u8) { + let start = out.len(); + match bits { + 8 => out.extend(codes.iter().map(|&c| c as u8)), + 10 => { + for chunk in codes.chunks(3) { + let word = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (22 - 10 * i as u32))); + out.extend_from_slice(&word.to_be_bytes()); + } + } + 12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())), + _ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())), + } + let written = out.len() - start; + out.resize(start + written.div_ceil(4) * 4, 0); +} + +pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result, CodecError> { + let img = src.converted(plan.layout, plan.sample); + let (w, h) = img.dimensions(); + let (descriptor, layout) = match img.layout() { + ChannelLayout::Gray => (6u8, ChannelLayout::Gray), + ChannelLayout::Rgb => (50, ChannelLayout::Rgb), + ChannelLayout::Rgba => (51, ChannelLayout::Rgba), + l => return Err(CodecError::encode(F, format!("DPX cannot store {l:?}"))), + }; + let bits = bits_for(opts.dpx_bits, img.sample_type()); + let codes: Vec = match (img.sample_type(), bits) { + (SampleType::U8, 8) => img.data().iter().map(|&v| u16::from(v)).collect(), + (SampleType::U16, 16) => img.to_u16_samples().unwrap_or_default(), + (SampleType::U16, 10 | 12) => { + let max = (1u64 << bits) - 1; + img.to_u16_samples().unwrap_or_default().into_iter().map(|v| narrow(v, max)).collect() + } + (s, b) => return Err(CodecError::encode(F, format!("cannot write {s:?} samples at {b} bits"))), + }; + let per_line = w as usize * layout.channels(); + if codes.len() != per_line * h as usize { + return Err(CodecError::encode(F, "the image has the wrong number of samples")); + } + let data_len = line_bytes(per_line, bits, if matches!(bits, 10 | 12) { 1 } else { 0 }) + .and_then(|l| l.checked_mul(h as usize)) + .ok_or_else(|| CodecError::encode(F, "image too large"))?; + let total = u32::try_from(HEADER_LEN + data_len).map_err(|_| CodecError::encode(F, "image too large for a DPX file (4 GiB)"))?; + + let mut wr = Writer { h: vec![0; HEADER_LEN] }; + // Generic file header. + wr.h[..4].copy_from_slice(b"SDPX"); + wr.u32(4, HEADER_LEN as u32); + wr.string((8, 8), "V2.0"); + wr.u32(16, total); + wr.u32(20, 1); + wr.u32(24, GENERIC_LEN); + wr.u32(28, INDUSTRY_LEN); + wr.u32(32, 0); + let keep = |key| if opts.embed_metadata { text(&img, key) } else { None }; + wr.string(CREATION_TIME, keep(KEY_CREATION_TIME).unwrap_or("")); + wr.string(CREATOR, keep(KEY_CREATOR).unwrap_or("PhotoCraft")); + wr.string(PROJECT, keep(KEY_PROJECT).unwrap_or("")); + wr.string(COPYRIGHT, keep(KEY_COPYRIGHT).unwrap_or("")); + wr.u32(660, UNDEF32); + // Image information: default orientation, one element. + wr.u16(ORIENTATION, 0); + wr.u16(ORIENTATION + 2, 1); + wr.u32(772, w); + wr.u32(776, h); + let e = ELEMENT0; + wr.u32(e, 0); + wr.u32(e + 4, 0); + wr.u32(e + 8, UNDEF32); + wr.u32(e + 12, ((1u32 << bits) - 1).min(65535)); + wr.u32(e + 16, UNDEF32); + wr.u8(e + 20, descriptor); + // No claim about the encoding unless the document came with one. + let code = |key| keep(key).and_then(|v| v.parse::().ok()).unwrap_or(UNDEF8); + wr.u8(e + 21, code(KEY_TRANSFER)); + wr.u8(e + 22, code(KEY_COLORIMETRIC)); + wr.u8(e + 23, bits); + wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 }); + wr.u16(e + 26, 0); + wr.u32(e + 28, HEADER_LEN as u32); + wr.u32(e + 32, 0); + wr.u32(e + 36, 0); + // The seven unused elements, the image source header's numbers and the industry header's + // numbers are undefined; their strings stay empty. + wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS)); + wr.undefined(1408, 1432); + wr.undefined(1620, 1644); + wr.undefined(FRAME_POSITION, 1732); + wr.undefined(TIME_CODE, 1972); + if let Some(v) = keep(KEY_FRAME_POSITION).and_then(|v| v.parse::().ok()) { + wr.u32(FRAME_POSITION, v); + } + if let Some(v) = keep(KEY_FRAME_RATE).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { + wr.u32(FRAME_RATE, v.to_bits()); + } + if let Some(v) = keep(KEY_TIME_CODE).and_then(parse_time_code) { + wr.u32(TIME_CODE, v); + } + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| CodecError::encode(F, "out of memory"))?; + for row in codes.chunks_exact(per_line.max(1)) { + pack_line(&mut out, row, bits); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal header for `w`×`h` with element 0 set; image data at 2048. + fn header(o: Order, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16) -> Vec { + let mut b = vec![0u8; HEADER_LEN]; + let put32 = |b: &mut Vec, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + let put16 = |b: &mut Vec, at: usize, v: u16| b[at..at + 2].copy_from_slice(&if o == Order::Big { v.to_be_bytes() } else { v.to_le_bytes() }); + b[..4].copy_from_slice(if o == Order::Big { b"SDPX" } else { b"XPDS" }); + put32(&mut b, 4, HEADER_LEN as u32); + put32(&mut b, 28, INDUSTRY_LEN); + put16(&mut b, ORIENTATION, 0); + put16(&mut b, ORIENTATION + 2, 1); + put32(&mut b, 772, w); + put32(&mut b, 776, h); + b[ELEMENT0 + 20] = descriptor; + b[ELEMENT0 + 21] = UNDEF8; + b[ELEMENT0 + 22] = UNDEF8; + b[ELEMENT0 + 23] = bits; + put16(&mut b, ELEMENT0 + 24, packing); + put32(&mut b, ELEMENT0 + 28, HEADER_LEN as u32); + b[FRAME_POSITION..1732].fill(0xFF); + b[TIME_CODE..1972].fill(0xFF); + b + } + + fn words(o: Order, ws: &[u32]) -> Vec { + ws.iter().flat_map(|w| if o == Order::Big { w.to_be_bytes() } else { w.to_le_bytes() }).collect() + } + + #[test] + fn ten_bit_method_a_and_b_unpack_three_to_a_word() { + for o in [Order::Big, Order::Little] { + for (packing, base) in [(1u16, 22u32), (2, 20)] { + let mut b = header(o, 1, 1, 50, 10, packing); + b.extend(words(o, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))])); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(512, 1023), widen(1, 1023)], "{o:?} packing {packing}"); + } + } + } + + #[test] + fn ten_bit_packing_0_is_one_bit_stream_per_line() { + // Four luma samples: 40 bits over two words, the line padded to the second word. + let samples = [1u64, 2, 1022, 1023]; + let stream = samples.iter().fold(0u64, |acc, &s| (acc << 10) | s) << 24; + for o in [Order::Big, Order::Little] { + let mut b = header(o, 4, 2, 6, 10, 0); + let line = words(o, &[(stream >> 32) as u32, stream as u32]); + b.extend(&line); + b.extend(&line); + let img = decode(&b, &Limits::default()).unwrap(); + let want: Vec = samples.iter().map(|&s| widen(s as u16, 1023)).collect(); + assert_eq!(img.to_u16_samples().unwrap(), [want.clone(), want].concat(), "{o:?}"); + } + } + + #[test] + fn twelve_bit_method_a_keeps_the_top_bits() { + let mut b = header(Order::Big, 2, 1, 6, 12, 1); + b.extend_from_slice(&[0xFF, 0xF0, 0x00, 0x10]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.to_u16_samples().unwrap(), vec![65535, widen(1, 4095)]); + } + + #[test] + fn widen_and_narrow_round_trip_every_code() { + for bits in [10u8, 12] { + let max = (1u64 << bits) - 1; + for c in 0..=max as u16 { + assert_eq!(narrow(widen(c, max), max), c, "{bits}-bit {c}"); + } + } + assert_eq!(widen(512, 1023), ((512.0f64 * 65535.0 / 1023.0).round()) as u16); + } + + #[test] + fn orientation_codes_map_to_exif() { + let mut b = header(Order::Big, 1, 1, 6, 8, 0); + for (dpx, exif) in [(0u16, 1u16), (1, 2), (2, 4), (3, 3), (4, 5), (5, 6), (6, 8), (7, 7), (0xFFFF, 1)] { + b[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes()); + assert_eq!(orientation(&b), exif, "DPX {dpx}"); + } + } + + #[test] + fn time_codes_round_trip() { + assert_eq!(format_time_code(0x0102_0304), "01:02:03:04"); + assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304)); + assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000)); + assert_eq!(parse_time_code("1:2:3:4"), None); + } + + #[test] + fn eight_bit_lines_without_word_padding_still_open() { + // 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8. + let mut b = header(Order::Big, 1, 2, 50, 8, 0); + b.extend_from_slice(&[1, 2, 3, 4, 5, 6]); + let img = decode(&b, &Limits::default()).unwrap(); + assert_eq!(img.data(), &[1, 2, 3, 4, 5, 6]); + } +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 22845d930b..2a1407d1c4 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod deep_exr; +pub(crate) mod dpx; pub(crate) mod exr; pub(crate) mod exr_cryptomatte; pub(crate) mod fits; diff --git a/crates/codecs/src/fidelity.rs b/crates/codecs/src/fidelity.rs index 720f9421a2..d5654d27a0 100644 --- a/crates/codecs/src/fidelity.rs +++ b/crates/codecs/src/fidelity.rs @@ -145,13 +145,19 @@ fn pick_layout(src: ChannelLayout, layouts: &[ChannelLayout], alpha: bool) -> Ch layouts[0] } -pub(crate) fn plan(image: &Image, format: Format, _opts: &EncodeOptions) -> Plan { +pub(crate) fn plan(image: &Image, format: Format, opts: &EncodeOptions) -> Plan { let c = caps(format); let (layout, sample) = (image.layout(), image.sample_type()); match format { // PNM: integer data can use any layout (PAM); float goes to PFM // which only has gray and RGB. Format::Pnm if sample.is_float() => Plan { layout: pick_layout(layout, &[ChannelLayout::Gray, ChannelLayout::Rgb], false), sample: SampleType::F32 }, + // DPX has no luma + alpha descriptor: gray with alpha is written as luma, alpha dropped. + // The sample type follows the chosen bits (U8 for 8, U16 for 10, 12 and 16). + Format::Dpx => Plan { + layout: if layout == ChannelLayout::GrayA { ChannelLayout::Gray } else { pick_layout(layout, c.layouts, c.alpha) }, + sample: crate::codecs::dpx::plan_sample(opts.dpx_bits, sample), + }, _ => Plan { layout: pick_layout(layout, c.layouts, c.alpha), sample: pick_sample(sample, c.depths) }, } } @@ -237,7 +243,9 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption if m.dpi.is_some() && !c.dpi { w.push(W::DpiDropped); } - if !m.text.is_empty() && !c.text { + // DPX keeps its own header fields, not free-form text. + let text_kept = |k: &str| c.text && (format != Format::Dpx || crate::codecs::dpx::KEYS.contains(&k)); + if m.text.iter().any(|(k, _)| !text_kept(k)) { w.push(W::TextDropped); } } diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ddb7587141..4a76c14458 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -27,6 +27,8 @@ pub enum Format { Heif, /// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data. Fits, + /// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames. + Dpx, } /// What a format can hold **and** what this crate reads/writes for it. @@ -84,7 +86,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 16] = [ + pub const ALL: [Format; 17] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +103,7 @@ impl Format { Format::Avif, Format::Heif, Format::Fits, + Format::Dpx, ]; pub fn caps(self) -> FormatCaps { @@ -131,6 +134,7 @@ impl Format { Format::Avif => "AVIF", Format::Heif => "HEIF", Format::Fits => "FITS", + Format::Dpx => "DPX", } } @@ -154,6 +158,7 @@ impl Format { Format::Avif => &["avif"], Format::Heif => &["heic", "heif", "hif"], Format::Fits => &["fits", "fit", "fts"], + Format::Dpx => &["dpx"], } } @@ -175,6 +180,7 @@ impl Format { Format::Avif => "image/avif", Format::Heif => "image/heif", Format::Fits => "image/fits", + Format::Dpx => "image/x-dpx", } } @@ -226,6 +232,8 @@ pub fn caps(format: Format) -> FormatCaps { FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base } } Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base }, + // `text`: the DPX header fields only (see `fidelity_warnings_with`). + Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base }, } } @@ -274,6 +282,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() { return Some(Format::Pnm); } + if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") { + return Some(Format::Dpx); + } if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } diff --git a/crates/codecs/src/image.rs b/crates/codecs/src/image.rs index 92d8fb5823..c1f9d7b75f 100644 --- a/crates/codecs/src/image.rs +++ b/crates/codecs/src/image.rs @@ -156,6 +156,32 @@ pub enum DecodeWarning { /// Deep image data (per-pixel sample lists) was composited into the flat image; the samples /// themselves (Z and other deep channels) are not part of the flat result. DeepFlattened { format: Format, max_samples_per_pixel: u32 }, + /// Only the first image element of a DPX file was decoded; `total` counts them all. + MoreElements { total: u32 }, + /// A DPX element's transfer characteristic and colorimetric specification (SMPTE 268M codes, + /// 255 when undefined). The code values are imported as stored, without a colour transform. + DpxEncoding { transfer: u8, colorimetric: u8 }, +} + +/// The SMPTE 268M name of a transfer characteristic or colorimetric code. +fn dpx_code_name(code: u8) -> &'static str { + match code { + 0 => "user-defined", + 1 => "printing density", + 2 => "linear", + 3 => "logarithmic", + 4 => "unspecified video", + 5 => "SMPTE 274M", + 6 => "ITU-R BT.709", + 7 => "ITU-R BT.601 625-line", + 8 => "ITU-R BT.601 525-line", + 9 => "NTSC composite video", + 10 => "PAL composite video", + 11 => "Z linear", + 12 => "Z homogeneous", + 255 => "undefined", + _ => "unknown", + } } impl fmt::Display for DecodeWarning { @@ -175,6 +201,13 @@ impl fmt::Display for DecodeWarning { "{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept", format = format.name() ), + DecodeWarning::MoreElements { total } => write!(f, "only the first of {total} image elements was imported"), + DecodeWarning::DpxEncoding { transfer, colorimetric } => write!( + f, + "DPX transfer characteristic: {} ({transfer}), colorimetric: {} ({colorimetric}); the code values were imported as stored, without a colour transform", + dpx_code_name(*transfer), + dpx_code_name(*colorimetric) + ), } } } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 16025a5a46..e90b5235c4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -37,13 +37,13 @@ pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, pub use crate::image::{ ChannelLayout, CryptomatteBuffer, CryptomatteLayer, DecodeWarning, DeepChannel, DeepImage, ExrChannelInfo, ExrPartInfo, Image, Metadata, SampleType, }; -pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; +pub use crate::options::{DecodeOptions, DpxBits, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression}; pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp}; pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_resolution, xmp_resolution}; pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), Format::Fits => fits::decode(bytes, l), + Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), Format::Heif => heif::decode(bytes, l, opts.keep_orientation), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::decode(format, bytes, l), @@ -93,6 +94,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res let o = match format { Format::Tiff => tiff::orientation(bytes, None), Format::Heif => 1, + Format::Dpx => dpx::orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; finish_decode(img, o, opts) @@ -224,6 +226,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), + Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), Format::Heif => Err(CodecError::unsupported(format, "encoding is not available in this build")), Format::Gif | Format::Bmp | Format::Tga | Format::Ico | Format::Qoi | Format::Hdr | Format::Avif => via_image::encode(format, image, plan, opts), diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 9337f1e6f1..b955e42d3f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -121,6 +121,38 @@ pub enum ExrCompression { Piz, } +/// Bits per sample of a DPX file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpxBits { + Bits8, + Bits10, + Bits12, + Bits16, +} + +impl DpxBits { + /// `8`, `10`, `12` or `16` as [`DpxBits`]; any other number is `None`. + pub fn from_bits(bits: u64) -> Option { + match bits { + 8 => Some(DpxBits::Bits8), + 10 => Some(DpxBits::Bits10), + 12 => Some(DpxBits::Bits12), + 16 => Some(DpxBits::Bits16), + _ => None, + } + } + + /// The bits per sample. + pub fn bits(self) -> u8 { + match self { + DpxBits::Bits8 => 8, + DpxBits::Bits10 => 10, + DpxBits::Bits12 => 12, + DpxBits::Bits16 => 16, + } + } +} + /// Options for [`crate::encode`]. #[derive(Debug, Clone)] pub struct EncodeOptions { @@ -141,6 +173,8 @@ pub struct EncodeOptions { /// only when it could pass the 4 GiB a classic TIFF can address. pub tiff_bigtiff: bool, pub exr_compression: ExrCompression, + /// DPX bits per sample. `None` follows the image: 8 for 8-bit, 16 otherwise. + pub dpx_bits: Option, /// Embed the ICC profile when the format supports it. pub embed_icc: bool, /// Embed EXIF/XMP/DPI/text when the format supports it. @@ -159,6 +193,7 @@ impl Default for EncodeOptions { tiff_compression: TiffCompression::Deflate, tiff_bigtiff: false, exr_compression: ExrCompression::Zip16, + dpx_bits: None, embed_icc: true, embed_metadata: true, } diff --git a/crates/codecs/tests/detect_caps.rs b/crates/codecs/tests/detect_caps.rs index 367a8bd813..22bf227eb8 100644 --- a/crates/codecs/tests/detect_caps.rs +++ b/crates/codecs/tests/detect_caps.rs @@ -59,6 +59,7 @@ fn default_build_formats_all_symmetric() { Format::OpenExr, Format::Hdr, Format::Fits, + Format::Dpx, ]; for f in expected { assert!(caps(f).read && caps(f).write, "{f:?}"); diff --git a/crates/codecs/tests/dpx.rs b/crates/codecs/tests/dpx.rs new file mode 100644 index 0000000000..8fdeb398c3 --- /dev/null +++ b/crates/codecs/tests/dpx.rs @@ -0,0 +1,386 @@ +//! DPX (SMPTE 268M) read and write, against fixtures packed here independently of the codec: +//! every descriptor, bit depth and packing it reads, both byte orders, the header fields kept +//! through a round trip, the bit-depth choice on export and the files it refuses (#1049). + +use photocraft_codecs::*; + +const HEADER: usize = 2048; +const ELEMENT0: usize = 780; + +/// A DPX file built field by field. +#[derive(Clone)] +struct Fixture { + big: bool, + width: u32, + height: u32, + descriptor: u8, + bits: u8, + packing: u16, + encoding: u16, + signed: bool, + transfer: u8, + colorimetric: u8, + orientation: u16, + elements: u16, + strings: Vec<((usize, usize), &'static str)>, + frame_position: u32, + frame_rate: f32, + time_code: u32, + /// Code values, interleaved, top line first. + codes: Vec, +} + +impl Fixture { + fn new(descriptor: u8, bits: u8, packing: u16, width: u32, height: u32) -> Self { + let channels = channels(descriptor); + let max = (1u32 << bits) - 1; + // Spread over the whole range, ends included. + let n = (width * height) as usize * channels; + let codes = (0..n) + .map(|i| { + if i == 0 { + 0 + } else if i == 1 { + max as u16 + } else { + ((i as u64 * 2654435761) % (u64::from(max) + 1)) as u16 + } + }) + .collect(); + Fixture { + big: true, + width, + height, + descriptor, + bits, + packing, + encoding: 0, + signed: false, + transfer: 255, + colorimetric: 255, + orientation: 0, + elements: 1, + strings: Vec::new(), + frame_position: u32::MAX, + frame_rate: f32::from_bits(u32::MAX), + time_code: u32::MAX, + codes, + } + } + + fn u16(&self, v: u16) -> [u8; 2] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn u32(&self, v: u32) -> [u8; 4] { + if self.big { v.to_be_bytes() } else { v.to_le_bytes() } + } + + fn line(&self, codes: &[u16]) -> Vec { + let mut out = Vec::new(); + match (self.bits, self.packing) { + (8, _) => out.extend(codes.iter().map(|&c| c as u8)), + (16, _) => out.extend(codes.iter().flat_map(|&c| self.u16(c))), + (10, 1 | 2) => { + let base = if self.packing == 1 { 22 } else { 20 }; + for chunk in codes.chunks(3) { + let w = chunk.iter().enumerate().fold(0u32, |w, (i, &c)| w | (u32::from(c) << (base - 10 * i as u32))); + out.extend(self.u32(w)); + } + } + (12, 1) => out.extend(codes.iter().flat_map(|&c| self.u16(c << 4))), + _ => { + // One bit stream, high bits first, cut into 32-bit words. + let mut bits: Vec = codes.iter().flat_map(|&c| (0..self.bits).rev().map(move |b| (c >> b) & 1 == 1)).collect(); + bits.resize(bits.len().div_ceil(32) * 32, false); + for word in bits.chunks(32) { + out.extend(self.u32(word.iter().fold(0u32, |w, &b| (w << 1) | u32::from(b)))); + } + } + } + out.resize(out.len().div_ceil(4) * 4, 0); + out + } + + fn bytes(&self) -> Vec { + let mut b = vec![0u8; HEADER]; + let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v); + put(0, if self.big { b"SDPX" } else { b"XPDS" }); + put(4, &self.u32(HEADER as u32)); + put(8, b"V2.0"); + put(24, &self.u32(1664)); + put(28, &self.u32(384)); + put(768, &self.u16(self.orientation)); + put(770, &self.u16(self.elements)); + put(772, &self.u32(self.width)); + put(776, &self.u32(self.height)); + for el in 0..usize::from(self.elements.min(8)) { + let e = ELEMENT0 + el * 72; + put(e, &self.u32(u32::from(self.signed))); + put(e + 20, &[self.descriptor, self.transfer, self.colorimetric, self.bits]); + put(e + 24, &self.u16(self.packing)); + put(e + 26, &self.u16(self.encoding)); + put(e + 28, &self.u32(HEADER as u32)); + } + for &((at, len), s) in &self.strings { + assert!(s.len() < len); + put(at, s.as_bytes()); + } + put(1712, &self.u32(self.frame_position)); + put(1724, &self.u32(self.frame_rate.to_bits())); + put(1920, &self.u32(self.time_code)); + let per_line = self.width as usize * channels(self.descriptor); + for line in self.codes.chunks(per_line) { + b.extend(self.line(line)); + } + b + } +} + +fn channels(descriptor: u8) -> usize { + match descriptor { + 6 => 1, + 50 => 3, + _ => 4, + } +} + +fn layout(descriptor: u8) -> ChannelLayout { + match descriptor { + 6 => ChannelLayout::Gray, + 50 => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } +} + +/// The 16-bit sample a code value opens as: `round(c × 65535 / (2^b − 1))`. +fn expected(c: u16, bits: u8) -> u16 { + (f64::from(c) * 65535.0 / f64::from((1u32 << bits) - 1)).round() as u16 +} + +/// Reads the code values of a big-endian file we wrote (packing 0 at 8/16 bits, 1 at 10/12). +fn written_codes(b: &[u8]) -> (u8, u16, Vec) { + let be16 = |at: usize| u16::from_be_bytes([b[at], b[at + 1]]); + let be32 = |at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]); + let (w, h) = (be32(772) as usize, be32(776) as usize); + let nc = channels(b[ELEMENT0 + 20]); + let (bits, packing) = (b[ELEMENT0 + 23], be16(ELEMENT0 + 24)); + let off = be32(ELEMENT0 + 28) as usize; + let per_line = w * nc; + let stride = match bits { + 10 => per_line.div_ceil(3) * 4, + _ => (per_line * if bits == 8 { 1 } else { 2 }).div_ceil(4) * 4, + }; + let mut codes = Vec::new(); + for y in 0..h { + let line = &b[off + y * stride..]; + for i in 0..per_line { + codes.push(match bits { + 8 => u16::from(line[i]), + 10 => ((be32_at(line, i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3FF) as u16, + 12 => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]) >> 4, + _ => u16::from_be_bytes([line[i * 2], line[i * 2 + 1]]), + }); + } + } + (bits, packing, codes) +} + +fn be32_at(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) +} + +fn opts(bits: Option) -> EncodeOptions { + EncodeOptions { dpx_bits: bits, ..EncodeOptions::default() } +} + +const COMBOS: [(u8, u16); 7] = [(8, 0), (10, 0), (10, 1), (10, 2), (12, 0), (12, 1), (16, 0)]; + +#[test] +fn every_descriptor_depth_and_packing_opens_with_its_model_and_values() { + for descriptor in [6, 50, 51] { + for (bits, packing) in COMBOS { + // Widths that leave partly filled words at the end of a line. + let fx = Fixture::new(descriptor, bits, packing, 5, 3); + let img = decode(&fx.bytes()).unwrap_or_else(|e| panic!("{descriptor} {bits}/{packing}: {e}")); + let what = format!("descriptor {descriptor}, {bits} bits, packing {packing}"); + assert_eq!(detect(&fx.bytes()), Some(Format::Dpx)); + assert_eq!((img.width(), img.height()), (5, 3), "{what}"); + assert_eq!(img.layout(), layout(descriptor), "{what}"); + assert_eq!(img.layout().has_alpha(), descriptor == 51, "{what}"); + match bits { + 8 => assert_eq!(img.as_u8().unwrap(), fx.codes.iter().map(|&c| c as u8).collect::>(), "{what}"), + 16 => assert_eq!(img.to_u16_samples().unwrap(), fx.codes, "{what}"), + _ => assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, bits)).collect::>(), "{what}"), + } + } + } +} + +#[test] +fn big_and_little_endian_files_open_identically() { + for (bits, packing) in COMBOS { + let be = Fixture::new(50, bits, packing, 4, 2); + let le = Fixture { big: false, ..be.clone() }; + assert_eq!(&le.bytes()[..4], b"XPDS"); + assert_eq!(decode(&be.bytes()).unwrap(), decode(&le.bytes()).unwrap(), "{bits} bits, packing {packing}"); + } +} + +#[test] +fn ten_bit_code_value_opens_as_the_rounded_16_bit_value() { + let mut fx = Fixture::new(6, 10, 1, 1, 1); + fx.codes = vec![512]; + assert_eq!(decode(&fx.bytes()).unwrap().to_u16_samples().unwrap(), vec![(512.0f64 * 65535.0 / 1023.0).round() as u16]); +} + +#[test] +fn ten_and_twelve_bit_frames_written_back_at_their_depth_keep_their_code_values() { + for (bits, choice) in [(10, DpxBits::Bits10), (12, DpxBits::Bits12)] { + for packing in [0, 1] { + let fx = Fixture::new(50, bits, packing, 7, 3); + let out = encode(&decode(&fx.bytes()).unwrap(), Format::Dpx, &opts(Some(choice))).unwrap(); + let (wbits, wpacking, codes) = written_codes(&out); + assert_eq!((wbits, wpacking), (bits, 1)); + assert_eq!(codes, fx.codes, "{bits} bits, packing {packing}"); + } + } +} + +#[test] +fn sixteen_bit_frames_round_trip_losslessly_and_files_start_with_sdpx() { + let fx = Fixture { big: false, ..Fixture::new(51, 16, 0, 6, 4) }; + let img = decode(&fx.bytes()).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(&out[..4], b"SDPX"); + let (bits, packing, codes) = written_codes(&out); + assert_eq!((bits, packing, codes), (16, 0, fx.codes)); + assert_eq!(decode(&out).unwrap().to_u16_samples(), img.to_u16_samples()); +} + +#[test] +fn log_frames_report_their_encoding_and_keep_the_plain_mapping() { + let mut fx = Fixture::new(50, 10, 1, 3, 2); + (fx.transfer, fx.colorimetric) = (3, 1); + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings, vec![DecodeWarning::DpxEncoding { transfer: 3, colorimetric: 1 }]); + let note = img.warnings[0].to_string(); + assert!(note.contains("logarithmic (3)") && note.contains("printing density (1)"), "{note}"); + assert_eq!(img.to_u16_samples().unwrap(), fx.codes.iter().map(|&c| expected(c, 10)).collect::>()); +} + +#[test] +fn only_the_first_of_several_image_elements_is_imported() { + let mut fx = Fixture::new(6, 8, 0, 2, 2); + fx.elements = 2; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!(img.warnings.iter().map(ToString::to_string).collect::>(), ["only the first of 2 image elements was imported"]); +} + +#[test] +fn listed_header_fields_survive_a_round_trip() { + let mut fx = Fixture::new(50, 10, 1, 2, 2); + fx.strings = vec![((160, 100), "Resolve 20.1"), ((260, 200), "Reel 7"), ((460, 200), "(c) 2026 Studio"), ((136, 24), "2026:10:12:09:30:00UTC")]; + (fx.transfer, fx.colorimetric, fx.frame_position, fx.frame_rate, fx.time_code) = (3, 1, 86400, 23.976, 0x0102_0304); + let img = decode(&fx.bytes()).unwrap(); + for (k, v) in [ + ("DPX:Creator", "Resolve 20.1"), + ("DPX:Project", "Reel 7"), + ("DPX:Copyright", "(c) 2026 Studio"), + ("DPX:CreationTime", "2026:10:12:09:30:00UTC"), + ("DPX:Transfer", "3"), + ("DPX:Colorimetric", "1"), + ("DPX:FramePosition", "86400"), + ("DPX:FrameRate", "23.976"), + ("DPX:TimeCode", "01:02:03:04"), + ] { + assert!(img.meta.text.contains(&(k.into(), v.into())), "{k} = {v} in {:?}", img.meta.text); + } + let out = encode(&img, Format::Dpx, &opts(Some(DpxBits::Bits10))).unwrap(); + let src = fx.bytes(); + // Field by field: the strings, the element's encoding codes, film and TV fields. + for (at, len) in [(160, 100), (260, 200), (460, 200), (136, 24), (ELEMENT0 + 21, 2), (1712, 4), (1724, 4), (1920, 4)] { + assert_eq!(out[at..at + len], src[at..at + len], "field at {at}"); + } + assert_eq!(decode(&out).unwrap().meta.text, img.meta.text); +} + +#[test] +fn chosen_bit_depth_sets_the_header_and_packing() { + let img = Image::from_u16(5, 2, ChannelLayout::Rgb, &(0..30u16).map(|i| i * 2000).collect::>()).unwrap(); + for (choice, bits, packing) in [(DpxBits::Bits8, 8, 0), (DpxBits::Bits10, 10, 1), (DpxBits::Bits12, 12, 1), (DpxBits::Bits16, 16, 0)] { + let out = encode(&img, Format::Dpx, &opts(Some(choice))).unwrap(); + assert_eq!((out[ELEMENT0 + 23], u16::from_be_bytes([out[ELEMENT0 + 24], out[ELEMENT0 + 25]])), (bits, packing), "{choice:?}"); + let back = decode(&out).unwrap(); + assert_eq!((back.width(), back.height(), back.layout()), (5, 2, ChannelLayout::Rgb)); + } + // Without a choice the depth follows the image. + let gray8 = Image::from_u8(3, 1, ChannelLayout::Gray, vec![0, 128, 255]).unwrap(); + assert_eq!(encode(&gray8, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 8); + assert_eq!(encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + let float = img.convert(ChannelLayout::Rgb, SampleType::F32); + assert_eq!(encode(&float, Format::Dpx, &EncodeOptions::default()).unwrap()[ELEMENT0 + 23], 16); + assert!(fidelity_warnings(&float, Format::Dpx).contains(&FidelityWarning::DepthReduced { from: SampleType::F32, to: SampleType::U16 })); +} + +#[test] +fn gray_with_alpha_is_written_as_luma_with_a_warning() { + let img = Image::from_u8(2, 1, ChannelLayout::GrayA, vec![10, 128, 200, 255]).unwrap(); + let out = encode(&img, Format::Dpx, &EncodeOptions::default()).unwrap(); + assert_eq!(out[ELEMENT0 + 20], 6); + assert_eq!(decode(&out).unwrap().as_u8().unwrap(), &[10, 200]); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::AlphaDiscarded)); +} + +#[test] +fn only_free_form_text_is_reported_as_dropped() { + let mut img = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![1, 2, 3]).unwrap(); + img.meta.text = vec![("DPX:Project".into(), "Reel 7".into())]; + assert!(!fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); + img.meta.text.push(("Comment".into(), "from a PNG".into())); + assert!(fidelity_warnings(&img, Format::Dpx).contains(&FidelityWarning::TextDropped)); +} + +#[test] +fn stored_orientation_is_turned_upright() { + // Orientation 4: the stored lines are the image's columns, left to right. + let mut fx = Fixture::new(6, 8, 0, 3, 1); + fx.codes = vec![1, 2, 3]; + fx.orientation = 4; + let img = decode(&fx.bytes()).unwrap(); + assert_eq!((img.width(), img.height(), img.as_u8().unwrap()), (1, 3, &[1u8, 2, 3][..])); +} + +#[test] +fn unsupported_and_damaged_files_error_instead_of_panicking() { + let unsupported = |bytes: Vec, what: &str| { + assert!(matches!(decode(&bytes), Err(CodecError::Unsupported { format: Format::Dpx, .. })), "{what}"); + }; + unsupported(Fixture { descriptor: 100, ..Fixture::new(50, 8, 0, 2, 2) }.bytes(), "ABGR descriptor"); + unsupported(Fixture { encoding: 1, ..Fixture::new(50, 10, 1, 2, 2) }.bytes(), "run-length encoding"); + unsupported(Fixture { signed: true, ..Fixture::new(50, 16, 0, 2, 2) }.bytes(), "signed samples"); + let mut float = Fixture::new(50, 16, 0, 2, 2).bytes(); + float[ELEMENT0 + 23] = 32; + unsupported(float, "32-bit float"); + let mut method_b = Fixture::new(50, 12, 1, 2, 2).bytes(); + method_b[ELEMENT0 + 24..ELEMENT0 + 26].copy_from_slice(&2u16.to_be_bytes()); + unsupported(method_b, "12-bit method B"); + + let good = Fixture::new(51, 10, 1, 9, 9).bytes(); + let mut no_elements = good.clone(); + no_elements[770..772].copy_from_slice(&0u16.to_be_bytes()); + let mut past_end = good.clone(); + past_end[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes()); + let mut huge = good.clone(); + huge[772..780].copy_from_slice(&[0xFF; 8]); + for (what, bytes) in [ + ("header cut short", good[..900].to_vec()), + ("truncated element", good[..good.len() - 1].to_vec()), + ("no image elements", no_elements), + ("data offset past the end", past_end), + ("absurd size", huge), + ("magic alone", b"SDPX".to_vec()), + ] { + assert!(decode_as(Format::Dpx, &bytes).is_err(), "{what}"); + } +} diff --git a/crates/codecs/tests/malformed.rs b/crates/codecs/tests/malformed.rs index fea7aedf3a..b6da84b072 100644 --- a/crates/codecs/tests/malformed.rs +++ b/crates/codecs/tests/malformed.rs @@ -60,7 +60,7 @@ fn truncated_files_mostly_error() { // Chopping off the second half must not silently succeed for formats // with length-checked rasters. for (f, bytes) in samples() { - if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { + if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) { let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight()); assert!(r.is_err(), "{f:?} decoded half a file"); } diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index 742de21598..4cb50c0057 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -134,6 +134,9 @@ mod pnm { mod fits { cases!(Format::Fits); } +mod dpx { + cases!(Format::Dpx); +} mod qoi { cases!(Format::Qoi); } diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 74069c8b8c..2757df5696 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -118,7 +118,7 @@ pub(crate) fn list_images(dir: &str) -> Result> { #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", - "fits", "fit", "fts", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", + "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone @@ -229,6 +229,8 @@ pub(crate) struct SaveOpts { /// Targa: 24 or 32 bits per pixel (`"tgaBits"`); unset, 32 with an alpha channel or /// transparency, else 24. pub tga_bits: Option, + /// DPX: 8, 10, 12 or 16 bits per sample (`"dpxBits"`); unset, 8 for an 8-bit document, else 16. + pub dpx_bits: Option, /// PSD/PSB: embed the thumbnail resource (Preferences › File Handling › Image Previews). pub image_previews: bool, /// PSD/PSB: write the real merged image (Preferences › File Handling › Maximize PSD and PSB @@ -238,7 +240,7 @@ pub(crate) struct SaveOpts { impl Default for SaveOpts { fn default() -> Self { - SaveOpts { quality: None, tiff_layers: false, tga_bits: None, image_previews: true, psd_maximize_compatibility: true } + SaveOpts { quality: None, tiff_layers: false, tga_bits: None, dpx_bits: None, image_previews: true, psd_maximize_compatibility: true } } } @@ -253,6 +255,7 @@ impl SaveOpts { quality: f64_param(p, "quality"), tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), tga_bits: tga_bits_param(p, cmd)?, + dpx_bits: dpx_bits_param(p, cmd)?, image_previews: file_handling.image_previews != crate::prefs::Ask::Never, psd_maximize_compatibility: file_handling.maximize_psd_compatibility != crate::prefs::Ask::Never, }) @@ -284,6 +287,19 @@ pub fn tga_bits_param(p: &Value, cmd: &str) -> Result Result> { + match p.get("dpxBits") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(photocraft_io::DpxBits::from_bits) + .map(Some) + .ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`dpxBits` must be 8, 10, 12 or 16, got {v}") }), + } +} + /// Encodes `doc` for `path`'s extension. pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> Result<(Vec, Vec)> { crate::allocation::checkpoint("saving document")?; @@ -298,6 +314,7 @@ pub(crate) fn encode(doc: &Document, path: &str, save: impl Into) -> R // No quality chosen: write the file the way it was opened (a JPEG at its own quality, a WebP // lossy or lossless as it was), as Photoshop's Save does. photocraft_io::apply_file_encoding(doc, &mut opts.encode); + opts.encode.dpx_bits = save.dpx_bits; if let Some(q) = save.quality { let q = (q.clamp(0.0, 12.0) / 12.0 * 99.0 + 1.0).round() as u8; opts.encode.jpeg_quality = q; @@ -1405,7 +1422,7 @@ pub fn specs() -> Vec { "Save a Copy…", &["File"], Some("Cmd+Alt+S"), - r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24)}"##, + r##"{"path":str (format from the extension),"quality":0..12? (JPEG),"layers":bool=true,"tiffLayers":bool=false (TIFF: keep the layers; flat by default),"tgaBits":24|32? (TGA: 32 writes the alpha channel, or else the transparency, as the alpha; unset, 32 with an alpha channel or transparency, else 24),"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)}"##, native_doc, save_a_copy ), @@ -1468,7 +1485,7 @@ pub fn specs() -> Vec { "Batch…", &["File", "Automate"], None, - r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"steps":[[commandId,params]|{"command":id,"params":{}}…] (a recorded action),"input":folder|[paths],"output":folder,"format":"same|png|jpg|psd|tiff|…"="same","quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"calledActions":[{"name","steps"}…]? (the actions Play Action steps call; default: the session's)} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, batch ), @@ -1477,7 +1494,7 @@ pub fn specs() -> Vec { "Image Processor…", &["File", "Scripts"], None, - r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, + r##"{"input":folder|[paths],"output":folder,"format":"jpg|png|psd|tiff|…"="jpg","quality":0..12=8,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16),"width":px?,"height":px? (fit, never enlarge),"convertToSrgb":bool=false} → {files, errors} (an input whose output name was already written in the run goes to errors)"##, native, image_processor ), @@ -1505,7 +1522,7 @@ pub fn specs() -> Vec { "Layers to Files…", &["File", "Export"], None, - r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?} → {files}"##, + r##"{"dir":folder,"format":"png|jpg|psd|tiff|…"="png","prefix":str=document name,"visibleOnly":bool=true,"quality":0..12?,"tiffLayers":bool=false,"tgaBits":24|32?,"dpxBits":8|10|12|16? (DPX: bits per sample; unset, 8 for an 8-bit document, else 16)} → {files}"##, native_doc, layers_to_files ), diff --git a/crates/engine/src/file_cmds/tests.rs b/crates/engine/src/file_cmds/tests.rs index 8cab1ce635..737b14ad8d 100644 --- a/crates/engine/src/file_cmds/tests.rs +++ b/crates/engine/src/file_cmds/tests.rs @@ -155,6 +155,32 @@ fn save_a_copy_writes_the_targa_bits_asked_for() { } } +/// `dpxBits` picks a DPX's bits per sample (byte 803 of the header); unset, it follows the +/// document's depth. Anything but 8, 10, 12 or 16 is refused before anything is written. +#[test] +fn save_a_copy_writes_the_dpx_bits_asked_for() { + let dir = tmp("dpx-bits"); + for (depth, params, bits) in + [(8, json!({}), 8), (16, json!({}), 16), (16, json!({"dpxBits": 10}), 10), (8, json!({"dpxBits": 12}), 12), (16, json!({"dpxBits": null}), 16)] + { + let mut s = session(12, 6, depth); + let out = join(&dir, "a.dpx"); + let mut p = params.clone(); + p["path"] = json!(out); + s.execute("file.saveACopy", p).unwrap(); + let bytes = std::fs::read(&out).unwrap(); + assert_eq!((&bytes[..4], bytes[803]), (&b"SDPX"[..], bits), "{depth}-bit {params}"); + assert_eq!(photocraft_io::import("a.dpx", &bytes).unwrap().document.size, photocraft_geom::Size::new(12, 6)); + } + let mut s = session(8, 8, 8); + for bad in [json!(24), json!(0), json!("10"), json!(10.5)] { + let out = join(&dir, "bad.dpx"); + let r = s.execute("file.saveACopy", json!({"path": out, "dpxBits": bad})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{bad}: {r:?}"); + assert!(!std::path::Path::new(&out).exists(), "{bad}: nothing written"); + } +} + /// A `tgaBits` other than 24 or 32 is a bad parameter, reported before anything is written. #[test] fn bad_targa_bits_are_refused_before_writing() { diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 6d33c332a0..0a1577714d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -61,6 +61,7 @@ pub mod text_styles_map; pub mod tiff_layers; pub mod vector_map; +pub use photocraft_codecs::DpxBits; use photocraft_codecs::{CodecError, EncodeOptions}; use photocraft_doc::Document; use photocraft_psd::{PsdError, PsdFile}; diff --git a/crates/io/tests/flat.rs b/crates/io/tests/flat.rs index 48d6944c72..35a5e5ad46 100644 --- a/crates/io/tests/flat.rs +++ b/crates/io/tests/flat.rs @@ -53,6 +53,9 @@ codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0); codec_rt!(fits_rgb8, "fits", ColorMode::Rgb, SampleType::U8, false, 0.0); codec_rt!(fits_gray16, "fit", ColorMode::Grayscale, SampleType::U16, false, 0.0); codec_rt!(fits_rgb32, "fits", ColorMode::Rgb, SampleType::F32, false, 0.0); +codec_rt!(dpx_gray8, "dpx", ColorMode::Grayscale, SampleType::U8, false, 0.0); +codec_rt!(dpx_rgb16, "dpx", ColorMode::Rgb, SampleType::U16, false, 0.0); +codec_rt!(dpx_rgba16, "dpx", ColorMode::Rgb, SampleType::U16, true, 0.0); fn indexed_bmp_8() -> Vec { const FILE_HEADER_SIZE: usize = 14; @@ -111,6 +114,25 @@ fn fits_header_cards_round_trip_as_text() { } } +/// DPX header fields travel as the document's text metadata and come back on a DPX export at the +/// chosen bit depth; the transfer characteristic is reported when the frame is opened (#1049). +#[test] +fn dpx_header_fields_and_encoding_notes_round_trip_through_the_document() { + let mut d = single(ColorMode::Rgb, SampleType::U16, false); + d.metadata.text = vec![("DPX:Project".into(), "Reel 7".into()), ("DPX:Transfer".into(), "3".into()), ("DPX:TimeCode".into(), "01:00:00:12".into())]; + let mut opts = ExportOptions::default(); + opts.encode.dpx_bits = Some(DpxBits::Bits10); + let r = export(&d, "a.dpx", &opts).unwrap(); + assert!(r.bytes.starts_with(b"SDPX")); + assert_eq!(r.bytes[803], 10, "bits per sample"); + let back = import("a.dpx", &r.bytes).unwrap(); + for kv in &d.metadata.text { + assert!(back.document.metadata.text.contains(kv), "{kv:?}: {:?}", back.document.metadata.text); + } + assert!(back.warnings.iter().any(|w| w.contains("transfer characteristic: logarithmic (3)")), "{:?}", back.warnings); + assert_eq!(back.document.depth, SampleType::U16); +} + /// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is /// linearised on export and comes back tagged linear sRGB with the same colours. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d6e3bef284..0aa2984e23 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -43,7 +43,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | | JPEG 2000 | R/W | no (#1047) | no | | -| Cineon / DPX | R (DPX), R/W (CIN) | no (#1049) | no | | +| Cineon / DPX | R (DPX), R/W (CIN) | DPX: luma, RGB, RGBA at 8/10/12/16 bits, both byte orders (#1049); Cineon no | DPX: 8/10/12/16 bits (`dpxBits`, `--dpx-bits`); Cineon no | Writes DPX, which Photoshop only reads; header fields (creator, project, copyright, transfer, frame rate, time code…) round-trip; no Cineon, no RLE | | DICOM | R/W | no | no | | | PCX | R/W | no | no | | | Pixar | R/W | no | no | | @@ -140,6 +140,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | diff --git a/docs/gaps.md b/docs/gaps.md index 01e54f1f7c..d505e8f447 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -195,7 +195,7 @@ what an agent can't finish alone. ### G10. Missing and partial file formats - **Missing:** JPEG Options dialog (FILE-215-19), Photoshop PDF read and write and multi-page PDF (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), - DPX/Cineon (#1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop + Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). @@ -238,6 +238,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | From 46740ad4bcaee6c2018ec9f99e4db9dd194dc3aa Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 01:47:14 +0200 Subject: [PATCH 192/523] Brush: CMYK foreground paints the picked inks into a CMYK document (#4344) (#4347) * Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0 (outside sRGB) came back as 100/28/0/17 when a CMYK document was filled. tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends the entered inks, and Edit > Fill (and the fill keys) write them as they are into a CMYK target. Fill also enters the document's CMYK profile. Co-Authored-By: Claude Opus 5.5 * Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/brush_cmds.rs | 2 ++ crates/engine/src/brush_cmds/tests.rs | 18 ++++++++++++++++++ crates/paint/src/brush.rs | 6 ++++++ crates/paint/src/render.rs | 8 +++++++- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index abc6b49d96..321973b8d2 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -204,6 +204,7 @@ pub fn resolve_brush(s: &Session, p: &Value, cmd: &str) -> Result b.smoothing.amount = v.clamp(0.0, 1.0); } b.color = color(p.get("color"), s.tools.foreground); + b.color_cmyk = s.tools.picked_cmyk([b.color[0], b.color[1], b.color[2]]).map(|k| ([b.color[0], b.color[1], b.color[2]], k)); b.background = s.tools.background; b.erase = flag(p, "erase", false); b.seed = match p.get("seed").and_then(Value::as_u64) { @@ -367,6 +368,7 @@ fn block_brush(b: BrushSettings, p: &Value) -> Result { pressure_opacity: false, spacing: 0.25, color: b.color, + color_cmyk: b.color_cmyk, background: b.background, erase: b.erase, smoothing: b.smoothing, diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 3a04a470fb..8d718c13c7 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -883,3 +883,21 @@ fn clear_mode_erases_with_the_brush_or_pencil() { assert_eq!(s.active().unwrap().history.undo_label(), Some(label), "{cmd}"); } } + +/// #4344: a foreground picked as CMYK 100/0/0/0 is painted into a CMYK document as those inks by +/// the Brush and the Pencil, not as whatever its sRGB stand-in converts back to (100/28/0/17). +#[test] +fn brush_and_pencil_paint_a_cmyk_pick_with_the_picked_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + for cmd in ["paint.stroke", "paint.pencil"] { + let mut s = session(16, 16); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + s.execute(cmd, json!({"points": [[8.0, 8.0]], "size": 6.0, "hardness": 1.0})).unwrap(); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let got: Vec = surf.read_region(Rect::new(8, 8, 9, 9)).into_iter().take(4).collect(); + assert!(got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02), "{cmd}: {got:?}"); + } +} diff --git a/crates/paint/src/brush.rs b/crates/paint/src/brush.rs index 51d8026f31..bdef893fde 100644 --- a/crates/paint/src/brush.rs +++ b/crates/paint/src/brush.rs @@ -486,6 +486,11 @@ pub struct BrushSettings { pub pressure_opacity: bool, /// Straight RGBA colour (display RGB). Commands fill it from the foreground colour. pub color: [f32; 4], + /// The inks `color` was picked as (its sRGB colour, the CMYK values), for a CMYK target: the + /// stroke paints exactly these, since an sRGB round trip does not give them back (#4344). + /// Only valid while `color` is that sRGB colour; ignored with Color Dynamics. + #[serde(skip)] + pub color_cmyk: Option<([f32; 3], [f32; 4])>, /// Background colour for Color Dynamics' foreground/background jitter. pub background: [f32; 4], /// Erase instead of paint. @@ -545,6 +550,7 @@ impl Default for BrushSettings { pressure_size: true, pressure_opacity: false, color: [0.0, 0.0, 0.0, 1.0], + color_cmyk: None, background: [1.0, 1.0, 1.0, 1.0], erase: false, mode: BlendMode::Normal, diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 688232c93e..34c296fb0e 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use photocraft_color::{BlendMode, PixelFormat, SampleType}; +use photocraft_color::{BlendMode, ColorMode, PixelFormat, SampleType}; use photocraft_geom::Rect; use photocraft_raster::{Surface, from_rgba_into, to_rgba}; @@ -966,6 +966,12 @@ impl StrokeRenderer { let opacity = b.opacity.clamp(0.0, 1.0); let mut src = [0.0f32; 8]; from_rgba_into(&fmt, b.color, &mut src); + // A colour picked as CMYK paints its inks as they are in a CMYK target (#4344). + if let (ColorMode::Cmyk, Some((rgb, inks))) = (fmt.mode, b.color_cmyk) + && rgb == [b.color[0], b.color[1], b.color[2]] + { + src[..4].copy_from_slice(&inks); + } if let Some(a) = a_idx { src[a] = b.color[3]; } From dc8a3f0b353760c9a5509f222f3ee534de71c98f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 01:54:16 +0200 Subject: [PATCH 193/523] Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352) * Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0 (outside sRGB) came back as 100/28/0/17 when a CMYK document was filled. tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends the entered inks, and Edit > Fill (and the fill keys) write them as they are into a CMYK target. Fill also enters the document's CMYK profile. Co-Authored-By: Claude Opus 5.5 * Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344) Co-Authored-By: Claude Sonnet 5.5 * Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/src/paint.rs | 54 +++++++++++++++++++++++++++++++-- crates/engine/src/extra_cmds.rs | 4 ++- crates/engine/src/fill_cmds.rs | 8 ++++- crates/engine/src/paint_cmds.rs | 32 +++++++++++++++++-- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/crates/algo/src/paint.rs b/crates/algo/src/paint.rs index 9376102efe..c32f28c648 100644 --- a/crates/algo/src/paint.rs +++ b/crates/algo/src/paint.rs @@ -63,6 +63,7 @@ fn composite_area( coverage: impl Fn(i32, i32) -> f32 + Sync, color: impl Fn(i32, i32) -> [f32; 4] + Sync, lock_transparency: bool, + inks: Option<[f32; 4]>, ) { // Composite tile by tile (in parallel on native); tiles with zero coverage are left untouched, // so their copy-on-write storage (and GPU residency) survives. @@ -93,6 +94,14 @@ fn composite_area( o[3] = d[3]; } from_rgba_into(&fmt, o, &mut enc); + // A colour picked as CMYK inks replaces fully covered pixels of a CMYK target as it is. + if let Some(i) = inks + && fmt.mode == photocraft_color::ColorMode::Cmyk + && blend_mode == BlendMode::Normal + && k >= 1.0 + { + enc[..4].copy_from_slice(&i); + } px.copy_from_slice(&enc[..n]); } } @@ -144,6 +153,7 @@ pub fn paint_gradient( c }, lock_transparency, + None, ); } @@ -179,6 +189,25 @@ pub fn bucket_fill_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) -> bool { + bucket_fill_inks(s, area, seed, tolerance, contiguous, anti_alias, opacity, selection, lock_transparency, src, None) +} + +/// [`bucket_fill_src`] where `inks` (CMYK, 0..1) are what a fully covered pixel of a CMYK target +/// gets instead of `src`'s sRGB stand-in (a colour that was picked as CMYK). +#[allow(clippy::too_many_arguments)] +pub fn bucket_fill_inks( + s: &mut Surface, + area: Rect, + seed: (i32, i32), + tolerance: f32, + contiguous: bool, + anti_alias: bool, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) -> bool { if !area.contains(seed.0, seed.1) { return false; @@ -186,7 +215,7 @@ pub fn bucket_fill_src( let img = crate::selection::rgba8_image(s, area); let Some(region) = crate::selection::wand_region(&img, area, seed, tolerance, contiguous, anti_alias) else { return false }; drop(img); - bucket_fill_region_src(s, ®ion, opacity, selection, lock_transparency, src); + bucket_fill_region_inks(s, ®ion, opacity, selection, lock_transparency, src, inks); true } @@ -199,10 +228,31 @@ pub fn bucket_fill_region_src( selection: Option<&Surface>, lock_transparency: bool, src: impl Fn(i32, i32) -> [f32; 4] + Sync, +) { + bucket_fill_region_inks(s, region, opacity, selection, lock_transparency, src, None); +} + +/// [`bucket_fill_region_src`] with the CMYK `inks` of [`bucket_fill_inks`]. +pub fn bucket_fill_region_inks( + s: &mut Surface, + region: &crate::selection::Region, + opacity: f32, + selection: Option<&Surface>, + lock_transparency: bool, + src: impl Fn(i32, i32) -> [f32; 4] + Sync, + inks: Option<[f32; 4]>, ) { // Composite only over the filled region's box. let b = region.bbox; - composite_area(s, b, BlendMode::Normal, |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), src, lock_transparency); + composite_area( + s, + b, + BlendMode::Normal, + |x, y| region.at(x, y) * opacity * selection.map_or(1.0, |m| m.sample_channel(x, y, 0)), + src, + lock_transparency, + inks, + ); } #[cfg(test)] diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 1399784b1d..c4fdebf214 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -136,6 +136,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result { // Whole pixels, rounded up, as in Photoshop (0.5 px strokes 1 px). let width = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()).unwrap_or(1.0).clamp(1.0, 250.0).ceil() as usize; let color = color_param(p, "color", s.tools.foreground); + // A colour picked as CMYK strokes a CMYK document with the picked inks (#4196). + let picked = s.tools.picked_cmyk([color[0], color[1], color[2]]); let mode = match p.get("mode") { None | Some(Value::Null) => photocraft_color::BlendMode::Normal, Some(Value::String(m)) => crate::commands::blend_from_str(m) @@ -214,7 +216,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result { let surf = crate::commands::paint_surface(doc, id, &Value::Null)?; let area = band.content_bounds().intersect(&canvas); if !area.is_empty() { - crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?; + crate::fill_cmds::blend_color_mask(surf, area, color, picked, &band, mode, opacity, preserve || lock)?; surf.prune(); } Ok(()) diff --git a/crates/engine/src/fill_cmds.rs b/crates/engine/src/fill_cmds.rs index d99ebd9a55..30c3ea1dd2 100644 --- a/crates/engine/src/fill_cmds.rs +++ b/crates/engine/src/fill_cmds.rs @@ -215,16 +215,22 @@ pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: O /// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band /// is already a grayscale coverage mask; opacity, blend modes and alpha locking /// must behave identically for both commands at 8/16/32-bit depth. +#[allow(clippy::too_many_arguments)] pub(crate) fn blend_color_mask( surf: &mut Surface, area: Rect, color: [f32; 4], + picked: Option<[f32; 4]>, coverage: &Surface, mode: BlendMode, opacity: f32, preserve_transparency: bool, ) -> Result<()> { - blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) + let source = match picked { + Some(cmyk) if color[3] >= 1.0 => Source::Cmyk { rgba: color, cmyk }, + _ => Source::Color(color), + }; + blend_into(surf, area, &source, Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false }) } /// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index ad16cd85d9..d97ca5b319 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -1,6 +1,6 @@ //! Paint helpers: Paint Bucket and Gradient tool. -use photocraft_algo::paint::{GradientShape, bucket_fill, bucket_fill_region_src, bucket_fill_src, paint_gradient}; +use photocraft_algo::paint::{GradientShape, bucket_fill_inks, bucket_fill_region_inks, bucket_fill_region_src, bucket_fill_src, paint_gradient}; use serde_json::{Value, json}; use crate::commands::{CommandSpec, blend_from_str}; @@ -61,6 +61,8 @@ fn bucket(s: &mut Session, p: &Value) -> Result { } else { None }; + // A foreground picked as CMYK fills a CMYK document with the picked inks (#4196). + let inks = s.tools.picked_cmyk([c[0], c[1], c[2]]).filter(|_| c[3] >= 1.0 && pattern.is_none()); let filled = s.edit("Paint Bucket", |doc, active| { let area = doc.bounds(); let sel = doc.selection.clone(); @@ -80,10 +82,10 @@ fn bucket(s: &mut Session, p: &Value) -> Result { bucket_fill_src(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, src) } } else if let Some(region) = ®ion { - bucket_fill_region_src(surf, region, opacity, sel.as_ref(), lock, |_, _| c); + bucket_fill_region_inks(surf, region, opacity, sel.as_ref(), lock, |_, _| c, inks); true } else { - bucket_fill(surf, area, (x, y), tol, contiguous, aa, c, opacity, sel.as_ref(), lock) + bucket_fill_inks(surf, area, (x, y), tol, contiguous, aa, opacity, sel.as_ref(), lock, |_, _| c, inks) }; surf.prune(); Ok(ok) @@ -330,6 +332,30 @@ mod tests { assert_eq!(px(&s, 5, 5), vec![0.0, 0.0, 1.0, 1.0]); } + /// #4196: the Paint Bucket and Edit > Stroke write a CMYK pick as its inks into a CMYK document, + /// not as its sRGB stand-in converted back (100/0/0/0 cyan would come out as about 100/28/0/17). + #[test] + fn bucket_and_stroke_write_a_cmyk_pick_as_its_inks() { + let cyan = [1.0f32, 0.0, 0.0, 0.0]; + let mut s = session(); + s.execute("image.mode.cmyk", json!({})).unwrap(); + let rgb = photocraft_color::convert::cmyk_to_rgb(cyan).map(|c| c.clamp(0.0, 1.0)); + s.execute("tools.setColors", json!({"foreground": rgb, "foregroundCmyk": cyan})).unwrap(); + let inks_at = |s: &Session, x: i32, y: i32| -> Vec { + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + surf.read_region(Rect::new(x, y, x + 1, y + 1)).into_iter().take(4).collect() + }; + let is_cyan = |got: &[f32]| got.iter().zip(cyan).all(|(a, b)| (a - b).abs() < 0.02); + s.execute("paint.bucket", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + let got = inks_at(&s, 5, 5); + assert!(is_cyan(&got), "bucket: {got:?}"); + s.execute("select.all", json!({})).unwrap(); + s.execute("edit.stroke", json!({"width": 2, "location": "inside", "color": rgb})).unwrap(); + let got = inks_at(&s, 0, 5); + assert!(is_cyan(&got), "stroke: {got:?}"); + } + #[test] fn bucket_fills_with_a_pattern() { // Paint Bucket with contents="pattern" flood-fills the region with a pattern, not a colour. From 97448b27f39770cf8ca256e9047d43adc42e10b8 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:54:50 +0300 Subject: [PATCH 194/523] perf(raster): moved fills keep sharing one copy-on-write tile (#4414) Surface::translated copied every target tile of a move that isn't a whole number of tiles, so a solid fill (one shared tile) became one unique tile per coordinate after its first nudge: each later move, undo and refresh then rescanned and re-uploaded all of them, and history kept them all. A target tile whose window reads only tiles holding one single pixel value (each source tile is scanned once per call; one tile is kept per value) now shares that tile. Fully covered targets also skip the default-pixel pre-fill they are about to overwrite. 12 MP RGBA fill nudged by +-(10, 7) twenty times (release, 4-core container): unique tiles 192 -> 76; RGBA16 move p50 55 -> 21 ms; RGBA8 unchanged within noise. Pixels are unchanged (the test compares every depth with the copied pixels, and checks that a shared but non-uniform tile is still copied). Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/raster/src/lib.rs | 126 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dbb6c0a107..74a5870123 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -90,6 +90,49 @@ pub struct Surface { tiles: BTreeMap>, } +/// Tiles of one pixel value met while moving a surface ([`Surface::translated`]): each scanned +/// once, and one tile kept per value so the targets share it. +#[derive(Default)] +struct UniformTiles { + /// Per source tile (by address): the index of its value's tile in `canonical`, or `None`. + of: std::collections::HashMap<*const Tile, Option>, + canonical: Vec>, +} + +impl UniformTiles { + /// The tile to share for a target reading window `r` of `s`, when every tile under it holds + /// one and the same pixel value (`r` must lie on existing tiles). + fn window(&mut self, s: &Surface, r: Rect) -> Option> { + let bpp = s.format.bytes_per_pixel(); + let mut found = None; + for c in r.tiles() { + let t = s.tiles.get(&c)?; + let k = self.value_of(t, bpp)?; + if found.is_some_and(|f| f != k) { + return None; + } + found = Some(k); + } + self.canonical.get(found?).cloned() + } + + fn value_of(&mut self, t: &Arc, bpp: usize) -> Option { + if let Some(k) = self.of.get(&Arc::as_ptr(t)) { + return *k; + } + let mut px = t.data.chunks_exact(bpp); + let k = px.next().filter(|p0| px.all(|p| p == *p0)).map(|p0| match self.canonical.iter().position(|c| c.data.get(..bpp) == Some(p0)) { + Some(k) => k, + None => { + self.canonical.push(t.clone()); + self.canonical.len() - 1 + } + }); + self.of.insert(Arc::as_ptr(t), k); + k + } +} + impl PartialEq for Surface { fn eq(&self, other: &Self) -> bool { self.format == other.format && self.default_pixel == other.default_pixel && self.tiles == other.tiles @@ -693,7 +736,9 @@ impl Surface { } /// The surface moved by whole pixels: its encoded samples copied tile to tile (no float - /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves. + /// round trip, no intermediate buffer); moves by whole tiles share the tiles themselves, and + /// a target tile read entirely from tiles of one single pixel value shares one such tile (the + /// inside of a solid fill stays one copy-on-write tile however often it moves). /// `content` must hold every pixel that isn't the default one (`content_bounds`, say): only /// tiles it reaches are kept. pub fn translated(&self, dx: i32, dy: i32, content: Rect) -> Surface { @@ -707,10 +752,24 @@ impl Surface { let bpp = self.format.bytes_per_pixel(); let moved = content.translate(dx, dy); let targets: std::collections::BTreeSet = sources().flat_map(|(c, _)| c.rect().translate(dx, dy).intersect(&moved).tiles()).collect(); + let mut uniform = UniformTiles::default(); + let zero = vec![0u8; bpp]; for dc in targets { - let mut tile = Tile::filled(&self.format, &self.default_pixel); let dr = dc.rect(); let sr = dr.translate(dx.saturating_neg(), dy.saturating_neg()); + // Every pixel of the target comes from a source tile (none from the default pixel). + // `sr` must be the exact preimage: an offset of `i32::MIN` saturates its negation. + let covered = sr.width() == TILE_SIZE.unsigned_abs() + && sr.height() == TILE_SIZE.unsigned_abs() + && sr.translate(dx, dy) == dr + && content.contains_rect(&sr) + && sr.tiles().all(|sc| self.tiles.contains_key(&sc)); + if covered && let Some(shared) = uniform.window(self, sr) { + out.tiles.insert(dc, shared); + continue; + } + // A covered target is overwritten whole, so skip filling it with the default pixel. + let mut tile = Tile::filled(&self.format, if covered { &zero } else { &self.default_pixel }); for sc in sr.intersect(&content).tiles() { let Some(st) = self.tiles.get(&sc) else { continue }; let part = sc.rect().intersect(&sr); @@ -1210,6 +1269,69 @@ mod tests { padded.fill_rect(Rect::new(2000, 2000, 2100, 2100), &s.default_pixel()); assert_eq!(padded.translated(5, 5, s.content_bounds()).tile_count(), s.translated(5, 5, s.content_bounds()).tile_count()); } + // A solid fill moved off the tile grid keeps sharing its one tile inside, at every depth + // and over a non-zero default pixel, and reads exactly like the copied pixels. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAY8] { + let mut s = Surface::with_default(fmt, &vec![0.25; fmt.channels()]); + let r = Rect::new(-300, -10, 1300, 1030); + s.fill_rect(r, &vec![0.75; fmt.channels()]); + let fill = s.tile(TileCoord::new(1, 1)).cloned().expect("an inner fill tile"); + for (dx, dy) in [(10, 7), (-1, 0), (0, 300), (-257, 513)] { + let t = s.translated(dx, dy, s.content_bounds()); + let moved = r.translate(dx, dy); + assert_eq!(t.read_region(moved.inflate(3)), s.read_region(moved.inflate(3).translate(-dx, -dy)), "{fmt:?} by ({dx}, {dy})"); + // Targets read only from the fill's whole tiles (x -256..1280, y 0..1024). + let whole = Rect::new(-256, 0, 1280, 1024).translate(dx, dy); + let inner: Vec<_> = t.tiles().filter(|(c, _)| whole.contains_rect(&c.rect())).collect(); + assert!(!inner.is_empty() && inner.iter().all(|(_, a)| Arc::ptr_eq(a, &fill)), "{fmt:?} by ({dx}, {dy}): inner tiles copied"); + } + // Nudged back and forth, the inside keeps sharing one tile (copies of uniform edge + // tiles count as that value too) and the pixels stay exact. + let mut t = s.clone(); + for k in 0..8 { + let d = if k % 2 == 0 { 10 } else { -10 }; + t = t.translated(d, d / 2, t.content_bounds()); + } + // (Targets whose last window read uniform tiles only: one tile in from the fill's whole + // tiles on the sides the last move came from.) + let inner: Vec<_> = t.tiles().filter(|(c, _)| Rect::new(-256, 0, 1024, 768).contains_rect(&c.rect())).map(|(_, a)| a).collect(); + assert!(inner.len() >= 4 && inner.iter().all(|a| Arc::ptr_eq(a, inner[0])), "{fmt:?}: repeated moves copied the inside"); + assert_eq!(t.read_region(r.inflate(3)), s.read_region(r.inflate(3)), "{fmt:?}: back where it started"); + } + // Two solid fills of different values side by side: windows across the seam are + // copied (each tile is uniform, but not with the same value), the rest shared. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 1024, 768), &vec![0.2; fmt.channels()]); + s.fill_rect(Rect::new(1024, 0, 2048, 768), &vec![0.9; fmt.channels()]); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 2100, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7)), "{fmt:?}: two fills"); + let seam = t.tile(TileCoord::new(4, 1)).expect("seam tile"); + let (left, right) = (t.tile(TileCoord::new(2, 1)).expect("left"), t.tile(TileCoord::new(6, 1)).expect("right")); + assert!(!Arc::ptr_eq(seam, left) && !Arc::ptr_eq(seam, right) && !Arc::ptr_eq(left, right), "{fmt:?}: seam aliased"); + } + // An offset of i32::MIN saturates its negation, so the source window isn't the exact + // preimage: no shortcut may apply (a zero pre-fill would leak into the output). + let mut s = Surface::with_default(PixelFormat::GRAY8, &[0.5]); + s.fill_rect(Rect::new(i32::MAX - 1024, 0, i32::MAX - 512, 512), &[0.25]); + let (fill, default) = (s.sample_channel(i32::MAX - 600, 10, 0), s.default_pixel()[0]); + for (dx, dy) in [(i32::MIN, 1), (1, i32::MIN), (i32::MIN, 0)] { + let t = s.translated(dx, dy, s.content_bounds()); + for (c, _) in t.tiles() { + assert!(t.read_region(c.rect()).iter().all(|v| *v == fill || *v == default), "({dx}, {dy}): tile {c:?} holds a pre-fill"); + } + } + // One tile shared but not uniform: each target is copied, not aliased. + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + let odd = s.tile(TileCoord::new(0, 0)).cloned().expect("written tile"); + let grid: Vec<_> = (0..9).map(|i| (TileCoord::new(i % 3, i / 3), odd.clone())).collect(); + s.put_tiles(grid); + let t = s.translated(10, 7, s.content_bounds()); + let all = Rect::new(-20, -20, 800, 800); + assert_eq!(t.read_region(all), s.read_region(all.translate(-10, -7))); + assert!(t.tiles().all(|(_, a)| !Arc::ptr_eq(a, &odd))); // Offsets at the limits don't panic. let mut s = Surface::new(PixelFormat::GRAY8); s.write_pixel(5, 5, &[1.0]); From be43d58f1a1ce798dd2f1c6fdeec90309cc03b11 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:54:57 +0300 Subject: [PATCH 195/523] perf(compose): stop sweeping the bounds cache on every call once it is large (#4415) compose::bounds caches per-tile content bounds and drops entries of dead tiles when it holds more than 65,536 of them. Once more than 65,536 tiles were alive (a large document plus its history), every call (every render tile, every GPU plan) walked the whole map again and kept it all. The sweep now runs past a high-water mark: twice the entries that survived the last sweep, and at least 65,536. The per-tile scan moves to raster as Tile::content_bounds, used by both Surface::content_bounds and the cache instead of two copies of the loop; an empty pixel yields no content instead of a chunks_exact(0) panic. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/compose/src/bounds.rs | 67 +++++++++++++++++++++++------------- crates/raster/src/lib.rs | 49 ++++++++++++++++++-------- 2 files changed, 79 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/bounds.rs b/crates/compose/src/bounds.rs index 1055487f13..985a400e08 100644 --- a/crates/compose/src/bounds.rs +++ b/crates/compose/src/bounds.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, Weak}; -use photocraft_geom::{Rect, TILE_SIZE}; +use photocraft_geom::Rect; use photocraft_raster::{Surface, Tile}; struct Entry { @@ -15,25 +15,23 @@ struct Entry { bounds: Option<(u16, u16, u16, u16)>, } -fn cache() -> &'static Mutex> { - static C: std::sync::OnceLock>> = std::sync::OnceLock::new(); - C.get_or_init(Default::default) +/// The entries, and the size past which dead ones are dropped (see [`next_sweep`]). +struct Cache { + entries: HashMap, + sweep_at: usize, } -/// Pixels of `t` that differ from `dp` (tile-local half-open bounds). -fn scan(t: &Tile, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { - let bpp = dp.len(); - let ts = TILE_SIZE as usize; - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.bytes().chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) +const MIN_SWEEP: usize = 1 << 16; + +/// The size at which to sweep again after a sweep left `survivors` live entries: twice that (at +/// least [`MIN_SWEEP`]), so a cache full of live tiles isn't swept again on every call. +fn next_sweep(survivors: usize) -> usize { + survivors.saturating_mul(2).max(MIN_SWEEP) +} + +fn cache() -> &'static Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(|| Mutex::new(Cache { entries: HashMap::new(), sweep_at: MIN_SWEEP })) } /// `Surface::content_bounds`, cached per tile. @@ -45,18 +43,19 @@ pub fn content_bounds(s: &Surface) -> Rect { let mut dp = vec![0u8; fmt.bytes_per_pixel()]; photocraft_raster::encode_pixel(&fmt, &s.default_pixel(), &mut dp); let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - if c.len() > 1 << 16 { - c.retain(|_, e| e.tile.strong_count() > 0); + if c.entries.len() > c.sweep_at { + c.entries.retain(|_, e| e.tile.strong_count() > 0); + c.sweep_at = next_sweep(c.entries.len()); } let mut out = Rect::EMPTY; for (coord, t) in s.tiles() { let key = Arc::as_ptr(t) as usize; - let hit = c.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); + let hit = c.entries.get(&key).filter(|e| e.tile.upgrade().is_some_and(|u| Arc::ptr_eq(&u, t)) && *e.default == *dp).map(|e| e.bounds); let b = match hit { Some(b) => b, None => { - let b = scan(t, &dp); - c.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); + let b = t.content_bounds(&dp); + c.entries.insert(key, Entry { tile: Arc::downgrade(t), default: dp.clone().into_boxed_slice(), bounds: b }); b } }; @@ -68,3 +67,25 @@ pub fn content_bounds(s: &Surface) -> Rect { } out } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sweeps_again_only_past_twice_the_survivors() { + // A cache of live tiles past the minimum isn't swept on every call (it used to be). + assert_eq!(next_sweep(0), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP / 2), MIN_SWEEP); + assert_eq!(next_sweep(MIN_SWEEP), 2 * MIN_SWEEP); + assert_eq!(next_sweep(usize::MAX), usize::MAX); + } + + #[test] + fn a_shared_tile_reads_the_same_bounds_at_every_coordinate() { + let mut s = Surface::new(photocraft_color::PixelFormat::RGBA8); + s.fill_rect(Rect::new(-300, 10, 1300, 900), &[0.2, 0.4, 0.6, 1.0]); + assert_eq!(content_bounds(&s), Rect::new(-300, 10, 1300, 900)); + assert_eq!(content_bounds(&s), s.content_bounds(), "cached"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 74a5870123..2d2d26b2d2 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -81,6 +81,27 @@ impl Tile { pub fn bytes_mut(&mut self) -> &mut [u8] { &mut self.data } + + /// Tile-local half-open bounds `(x0, y0, x1, y1)` of the pixels that differ from the encoded + /// pixel `dp` (one pixel of the tile's format), or `None` when every pixel equals it (or `dp` + /// is empty). + pub fn content_bounds(&self, dp: &[u8]) -> Option<(u16, u16, u16, u16)> { + let bpp = dp.len(); + if bpp == 0 { + return None; + } + let ts = TILE_SIZE as usize; + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); + for (y, row) in self.data.chunks_exact(ts * bpp).enumerate() { + let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; + let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); + x0 = x0.min(first); + x1 = x1.max(last + 1); + y0 = y0.min(y); + y1 = y + 1; + } + (x0 != usize::MAX).then_some((x0 as u16, y0 as u16, x1 as u16, y1 as u16)) + } } #[derive(Clone, Debug)] @@ -265,7 +286,6 @@ impl Surface { /// Exact bounds of pixels that differ from the default pixel. pub fn content_bounds(&self) -> Rect { - let bpp = self.format.bytes_per_pixel(); if self.tiles.is_empty() { return Rect::EMPTY; } @@ -278,25 +298,14 @@ impl Surface { } let mut order: Vec<(&TileCoord, &Arc)> = self.tiles.iter().collect(); order.sort_by_key(|(c, _)| (c.tx - gx0).min(gx1 - c.tx).min(c.ty - gy0).min(gy1 - c.ty)); - let dp = &*self.default_pixel; - let ts = TILE_SIZE as usize; let mut out = Rect::EMPTY; for (c, t) in order { let origin = c.rect(); if !out.is_empty() && out.contains_rect(&origin) { continue; } - let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize); - for (y, row) in t.data.chunks_exact(ts * bpp).enumerate() { - let Some(first) = row.chunks_exact(bpp).position(|px| px != dp) else { continue }; - let last = ts - 1 - row.chunks_exact(bpp).rev().position(|px| px != dp).unwrap_or(0); - x0 = x0.min(first); - x1 = x1.max(last + 1); - y0 = y0.min(y); - y1 = y + 1; - } - if x0 != usize::MAX { - out = out.union(&Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32).translate(origin.x0, origin.y0)); + if let Some((x0, y0, x1, y1)) = t.content_bounds(&self.default_pixel) { + out = out.union(&Rect::new(x0.into(), y0.into(), x1.into(), y1.into()).translate(origin.x0, origin.y0)); } } out @@ -1246,6 +1255,18 @@ mod tests { } } + #[test] + fn tile_content_bounds_scans_against_the_given_pixel() { + let mut s = Surface::new(PixelFormat::GRAY8); + s.write_pixel(3, 4, &[1.0]); + s.write_pixel(200, 9, &[1.0]); + let t = s.tile(TileCoord::new(0, 0)).expect("written tile"); + assert_eq!(t.content_bounds(&[0]), Some((3, 4, 201, 10))); + assert_eq!(t.content_bounds(&[255]).map(|b| (b.0, b.1)), Some((0, 0))); + // A malformed pixel is no content, not a panic. + assert_eq!(t.content_bounds(&[]), None); + } + #[test] fn translated_matches_reading_the_moved_pixels() { // Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default. From 3c469bfd05b57cf6953e8131a30b95cfb1aa099f Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:05 +0300 Subject: [PATCH 196/523] perf(engine): move layers without a second document clone (#4422) move_layers cloned the document on every layer.translate, Align and Distribute only to read the pre-move state while type and shapes re-render. Session::edit already clones the document for the edit, and the callers hold the starting state (an Arc), so they pass it in. The clone copies the layer tree and every surface's tile map (pixel tiles themselves are shared), so its cost grows with the layer and tile count: a nudge in a document with many layers or very large layers goes from two such clones per command to one. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/engine/src/layer_multi_cmds.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/layer_multi_cmds.rs b/crates/engine/src/layer_multi_cmds.rs index 0d5985e339..b26e46358b 100644 --- a/crates/engine/src/layer_multi_cmds.rs +++ b/crates/engine/src/layer_multi_cmds.rs @@ -261,9 +261,9 @@ pub(crate) fn with_links(doc: &Document, ids: &[LayerId]) -> Vec { } /// Move layers by whole pixels the way the Move tool does: pixels, linked masks, type and -/// vector content (shapes, linked vector masks) all move together. -pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { - let snapshot = doc.clone(); +/// vector content (shapes, linked vector masks) all move together. `before` is `doc` as it was +/// before any of the moves (the edit's starting document), read when type and shapes re-render. +pub(crate) fn move_layers(doc: &mut Document, before: &Document, moves: &[(LayerId, i32, i32)]) -> Result<()> { for &(id, dx, dy) in moves { if dx == 0 && dy == 0 { continue; @@ -273,8 +273,8 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) -> if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name))); } - crate::commands::translate_layer(&snapshot, l, dx, dy); - crate::vector_cmds::translate_vectors(&snapshot, l, dx as f64, dy as f64); + crate::commands::translate_layer(before, l, dx, dy); + crate::vector_cmds::translate_vectors(before, l, dx as f64, dy as f64); } Ok(()) } @@ -309,7 +309,7 @@ pub fn translate(s: &mut Session, p: &Value) -> Result { return Err(EngineError::NoLayer(roots[0])); } let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect(); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; for id in alone { let locks = doc.effective_locks(id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; @@ -873,6 +873,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { let edge = Edge::parse(kind).ok_or_else(|| bad(&cmd, "unknown edge"))?; let sel = selected(s); let to = p.get("to").and_then(Value::as_str).unwrap_or("auto").to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Align", |doc, _| { let items = bounded_selection(doc, &sel); if items.is_empty() { @@ -904,7 +905,7 @@ fn align(s: &mut Session, p: &Value, kind: &str) -> Result { }) .collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) @@ -927,6 +928,7 @@ fn dedup_moves(doc: &Document, moves: Vec<(LayerId, i32, i32)>) -> Vec<(LayerId, fn distribute(s: &mut Session, kind: &str) -> Result { let sel = selected(s); let kind = kind.to_string(); + let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let moved = s.edit("Distribute", |doc, _| { let mut items = bounded_selection(doc, &sel); if items.len() < 3 { @@ -966,7 +968,7 @@ fn distribute(s: &mut Session, kind: &str) -> Result { } let moves: Vec<_> = moves.into_iter().flat_map(|(id, dx, dy)| with_links(doc, &[id]).into_iter().map(move |l| (l, dx, dy))).collect(); let moves = dedup_moves(doc, moves); - move_layers(doc, &moves)?; + move_layers(doc, &before, &moves)?; Ok(moves.iter().filter(|m| m.1 != 0 || m.2 != 0).count()) })?; Ok(json!({"moved": moved})) From e6831086137b6662596ff7580ae2fd035289f470 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:13 +0300 Subject: [PATCH 197/523] perf(compose): read PHOTOCRAFT_FX_NOCACHE once (#4423) effect_maps looked the environment variable up (taking the process environment lock) for every effect-map lookup, i.e. per styled layer per render tile. It is read once, like PHOTOCRAFT_FX_TRACE in the GPU compositor. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/compose/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index cb88d002d9..eafdb44616 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1527,7 +1527,8 @@ fn effect_region(layer: &Layer, rect: Rect, cx: &Ctx) -> (Rect, bool) { fn effect_maps(layer: &Layer, rect: Rect, cx: &Ctx) -> std::sync::Arc { use std::hash::{Hash, Hasher}; let (region, clipped) = effect_region(layer, rect, cx); - if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() { + static NO_CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + if *NO_CACHE.get_or_init(|| std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some()) { let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) }; return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns)); } From 1e9f1ae4862280f6f90b696a8fdb6287cee918e4 Mon Sep 17 00:00:00 2001 From: SckraT <70698629+SckraT@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:55:21 +0300 Subject: [PATCH 198/523] perf(engine): read selection coverage per row when mixing edits back (#4424) Destructive adjustments (pixels::adjusted) and the RGBA filter helper (adjust_cmds process_surface) each had the same loop mixing the edited pixels back over the original by selection coverage, looking the selection's tile up for every pixel (sample_channel, a BTreeMap lookup per pixel). They now share pixels::mix_through_selection, which reads the coverage one row at a time with sample_row_strided: one tile lookup per tile per row, and a row-sized buffer. Results are unchanged: a new test compares the helper with the per-pixel sample_channel mix, bit for bit, for 8-bit, 8-bit-with-alpha and 32-bit selections over 8- and 16-bit layers, across tile edges and untouched (default) selection tiles. Claude-Session: https://claude.ai/code/session_01QUda3K786uop8kvEQoUjSf Co-authored-by: Claude --- crates/engine/src/adjust_cmds.rs | 13 ++--------- crates/engine/src/pixels.rs | 35 ++++++++++++++++++++++------- crates/engine/src/pixels_tests.rs | 37 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 48f6e56246..15c3af2060 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -11,7 +11,7 @@ use photocraft_algo::tone::{self, HdrToning, MatchColor, ShadowsHighlights}; use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Rect}; -use photocraft_raster::{Surface, from_rgba_into, to_rgba}; +use photocraft_raster::{Surface, to_rgba}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -202,17 +202,8 @@ fn process_surface(surf: &mut Surface, selection: Option<&Surface>, f: &mut dyn let raw = surf.read_region(r); let mut px: Vec<[f32; 4]> = raw.chunks_exact(n).map(|q| to_rgba(&fmt, q)).collect(); let orig = px.clone(); - let w = r.width() as usize; f(&mut px, r); - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in px.iter().zip(&orig).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); - } - surf.write_region(r, &out); + surf.write_region(r, &crate::pixels::mix_through_selection(&fmt, r, &orig, &px, selection)); } /// One history step running `f` on the active pixel layer (or targeted channel or layer mask). diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index b19a1b270f..f176315ca5 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -99,14 +99,33 @@ fn adjusted(s: &Surface, r: Rect, adj: &Adjustment, selection: Option<&Surface>, // integer depths keep the unrounded, clipped curves. let depth = (fmt.sample == photocraft_color::SampleType::F32).then_some(fmt.sample); adjust::apply_in(adj, &mut buf, adjust::Transfer::for_document(mode, fmt.sample), depth, mode); - let w = r.width() as usize; - let mut out = Vec::with_capacity(raw.len()); - for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { - let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0)); - let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); - let mut enc = [0.0f32; 8]; - let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); - out.extend_from_slice(&enc[..m]); + mix_through_selection(&fmt, r, &orig.px, &buf.px, selection) +} + +/// `edited` mixed back over `orig` (straight RGBA over `r`, row-major) by the selection's +/// coverage, encoded in `fmt`: what a destructive edit writes back through a selection. +pub(crate) fn mix_through_selection( + fmt: &photocraft_color::PixelFormat, + r: Rect, + orig: &[[f32; 4]], + edited: &[[f32; 4]], + selection: Option<&Surface>, +) -> Vec { + let w = (r.width() as usize).max(1); + let mut out = Vec::with_capacity(orig.len() * fmt.channels()); + // The selection's coverage (channel 0) one row at a time, each of its tiles looked up once + // per row rather than once per pixel. + let mut coverage = vec![1.0f32; w]; + for (j, (edited_row, orig_row)) in edited.chunks(w).zip(orig.chunks(w)).enumerate() { + if let Some(sel) = selection { + sel.sample_row_strided(r.y0.saturating_add(j as i32), r.x0, 1, 0, &mut coverage); + } + for ((a, o), k) in edited_row.iter().zip(orig_row).zip(&coverage) { + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(fmt, mixed, &mut enc); + out.extend_from_slice(&enc[..m]); + } } out } diff --git a/crates/engine/src/pixels_tests.rs b/crates/engine/src/pixels_tests.rs index c0dc368e40..4a9e577a32 100644 --- a/crates/engine/src/pixels_tests.rs +++ b/crates/engine/src/pixels_tests.rs @@ -259,3 +259,40 @@ fn invert_in_a_lab_document_mirrors_l_a_and_b() { assert!(got.iter().zip([70.0, -13.0, 17.0]).all(|(g, w)| (g - w).abs() < 1.0), "{depth:?}: {got:?}"); } } + +#[test] +fn mixing_through_a_selection_matches_sampling_it_per_pixel() { + // A rectangle across tile edges (negative x included), a selection with fractional + // coverage, untouched tiles (default coverage) and a 16-bit as well as an 8-bit layer. + let r = Rect::new(-300, 200, 330, 290); + let gray32 = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }; + for sel_fmt in [PixelFormat::GRAY8, PixelFormat::GRAYA8, gray32] { + let mut sel = Surface::new(sel_fmt); + sel.fill_rect(Rect::new(-260, 210, 40, 280), &vec![0.6; sel_fmt.channels()]); + for x in 0..120 { + sel.write_pixel(x * 2, 230 + x % 40, &vec![x as f32 / 119.0; sel_fmt.channels()]); + } + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let n = (r.width() * r.height()) as usize; + let orig: Vec<[f32; 4]> = (0..n).map(|i| [(i % 7) as f32 / 7.0, 0.5, (i % 13) as f32 / 13.0, 0.25 + (i % 3) as f32 / 4.0]).collect(); + let edited: Vec<[f32; 4]> = orig.iter().map(|p| [1.0 - p[0], p[1] * 0.5, p[2], 1.0]).collect(); + let w = r.width() as usize; + let mut expected = Vec::new(); + for (i, (a, o)) in edited.iter().zip(&orig).enumerate() { + let k = sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0); + let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k); + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc); + expected.extend_from_slice(&enc[..m]); + } + assert_eq!(mix_through_selection(&fmt, r, &orig, &edited, Some(&sel)), expected, "{sel_fmt:?} over {fmt:?}"); + } + } + // Without a selection the edit is taken whole; an empty rectangle mixes nothing. + let px = [[0.1, 0.2, 0.3, 1.0]]; + let fmt = PixelFormat::RGBA8; + let mut enc = [0.0f32; 8]; + let m = photocraft_raster::from_rgba_into(&fmt, px[0], &mut enc); + assert_eq!(mix_through_selection(&fmt, Rect::new(0, 0, 1, 1), &[[0.0; 4]], &px, None), enc[..m].to_vec()); + assert!(mix_through_selection(&fmt, Rect::EMPTY, &[], &[], None).is_empty()); +} From b26bab69eec557082d859b7813af3a9bda6081c1 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:56:47 +0200 Subject: [PATCH 199/523] Layer Style: OK keeps an Inner Glow's Center source (#4152) (#4120) The dialog's values_of() started each Inner Glow from the factory defaults ("source": "edge") and never loaded the effect's own source, so a Center-source glow (from a command or a PSD) showed Edge, previewed as Edge and became Edge on OK, since the engine's overlay applies the "source" key it receives. values_of() now loads the source for Inner Glow. Regression test: inner_glow_center_source_survives_ok. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/layer_style.rs | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index b350cef521..9a64a1f731 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -14,7 +14,7 @@ //! the page-only `blendIfChannel`; `channelNames`: the document's colour channels. use egui::{Color32, RichText, Sense, Stroke, StrokeKind, vec2}; -use photocraft_doc::effects::{FxPaint, StrokePosition}; +use photocraft_doc::effects::{FxPaint, GlowSource, StrokePosition}; use photocraft_doc::{Effect, Layer}; use serde_json::{Map, Value, json}; @@ -247,6 +247,9 @@ fn values_of(e: &Effect, light: f32) -> Value { set(&mut v, "contour", photocraft_engine::layer_style::contour_param(&g.contour)); set(&mut v, "antiAlias", json!(g.anti_alias)); set(&mut v, "noise", json!((g.noise * 100.0).round())); + if matches!(e, Effect::InnerGlow(_)) { + set(&mut v, "source", json!(if g.source == GlowSource::Center { "center" } else { "edge" })); + } } Effect::Stroke(s) => { set(&mut v, "blend", json!(s.common.blend.label())); @@ -1730,6 +1733,30 @@ mod tests { assert_eq!(fx[1]["params"]["spread"], json!(12.0)); } + /// OK with no edits used to turn a Center-source Inner Glow into an Edge one: the dialog never + /// loaded the source, so it showed the default and applied it. + #[test] + fn inner_glow_center_source_survives_ok() { + let mut s = session(); + s.execute("layer.layerStyle.innerGlow", json!({"source": "center"})).unwrap(); + let source = |s: &photocraft_engine::Session| { + let d = s.active().unwrap(); + let id = d.active_layer.unwrap(); + d.doc.layer(id).unwrap().effects.items.iter().find_map(|e| match e { + Effect::InnerGlow(g) => Some(g.source), + _ => None, + }) + }; + assert_eq!(source(&s), Some(GlowSource::Center)); + let st = s.active().unwrap(); + let id = st.active_layer.unwrap(); + let f = initial_fields(st.doc.layer(id).unwrap(), None, st.doc.global_light.angle); + let glow = effects_of(&f).iter().find(|e| e["kind"] == json!("innerGlow")).unwrap(); + assert_eq!(glow["params"]["source"], json!("center"), "the dialog shows the effect's source"); + apply(&f, |cmd, p| s.execute(cmd, p).map_err(|e| e.to_string())).unwrap(); + assert_eq!(source(&s), Some(GlowSource::Center), "OK keeps the source"); + } + #[test] fn drop_shadow_angle_reaches_the_effect() { // #350: the Angle slider was dead — the dialog never sent `useGlobalLight`, so the engine From d323d872b2b2a4d109177fd3f711e060288bfd6f Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:56:50 +0200 Subject: [PATCH 200/523] Layers panel: double-clicking an effect row opens that layer's Layer Style (#4157) (#4131) Root cause: the effect rows' double-click handler called layer_style::open, which always reads the active layer. Double-clicking an effect row of a layer that wasn't active opened (and on OK edited) the active layer's style instead. Fix: new layer_style::open_for_layer selects the row's layer with layer.select first, as the row's right-click menu does, then opens the dialog. Regression test covers two layers with only the inactive one carrying an effect. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/layer_style.rs | 34 +++++++++++++++++++++++++++++++ crates/ui-egui/src/panels.rs | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layer_style.rs b/crates/ui-egui/src/layer_style.rs index 9a64a1f731..d3606e30b7 100644 --- a/crates/ui-egui/src/layer_style.rs +++ b/crates/ui-egui/src/layer_style.rs @@ -515,6 +515,16 @@ pub fn open(app: &mut PhotocraftApp, select: Option<&str>) -> Option { Some(app.ui.open_dialog(crate::state::DialogKind::LayerStyle, f)) } +/// Double-click on an effect row of the Layers panel: open Layer Style for that row's layer. A +/// row of another layer selects it first, as its right-click menu does (Photoshop does the same). +pub fn open_for_layer(app: &mut PhotocraftApp, layer: photocraft_doc::LayerId, select: Option<&str>) -> Option { + let active = app.session.active().is_some_and(|s| s.active_layer == Some(layer)); + if !active { + app.run("layer.select", json!({"layer": layer.0})).ok()?; + } + open(app, select) +} + /// `[[id, name], …]` of the patterns a style can use (document's, then the library's). pub fn pattern_list(app: &PhotocraftApp) -> Value { let mut out: Vec = Vec::new(); @@ -1383,6 +1393,30 @@ mod tests { assert_eq!(rows, KINDS.len(), "one empty checkbox per effect row"); } + /// Double-clicking an effect row of a layer that isn't active selects that layer first, so + /// the dialog holds its effects (and OK edits it), not the active layer's. + #[test] + fn effect_row_of_another_layer_opens_that_layers_style() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let with_fx = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.layerStyle.replace", json!({"layer": with_fx.0, "effects": [{"kind": "dropShadow", "params": defaults("dropShadow")}]})).unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + let plain = app.session.active().unwrap().active_layer.unwrap(); + assert_ne!(plain, with_fx); + assert!(app.session.active().unwrap().doc.layer(plain).unwrap().effects.items.is_empty()); + + let id = open_for_layer(&mut app, with_fx, Some("dropShadow")).unwrap(); + assert_eq!(app.session.active().unwrap().active_layer, Some(with_fx), "the row's layer becomes active"); + let d = app.ui.dialogs.iter().find(|d| d.id == id).unwrap(); + assert_eq!(d.fields["layer"], json!(with_fx.0)); + let fx = effects_of(&d.fields); + assert_eq!(fx.len(), 1); + assert_eq!(fx[0]["kind"], json!("dropShadow")); + assert!(fx[0]["fx"].is_object(), "the existing drop shadow, not a new instance"); + } + #[test] fn every_kind_has_spec_and_defaults() { for &(kind, _) in KINDS { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6193717537..bb4d2f1e90 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3932,7 +3932,7 @@ fn effect_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: usi ); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name.clone())); if resp.double_clicked() { - crate::layer_style::open(app, kind); + crate::layer_style::open_for_layer(app, l.id, kind); } // #2846: right-click opens the layer style menu (Create Layer, Copy Layer Style…), as in Photoshop. resp.context_menu(|ui| { From 9fd20de408d0cca4baf96ac4761da3404b44604c Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:57:34 +1100 Subject: [PATCH 201/523] Keep Liquify undo and redo working while a numeric field is focused. (#4134) Ctrl+Z was ignored whenever a slider's text edit had focus, so a stroke could not be undone from the keyboard. Other Liquify keys still stay out of that field. The bracket keys also step the brush by at least one pixel, so a 2 px brush can reach 1 px. Fixes #4027 --- crates/ui-egui/src/liquify_ui.rs | 13 ++++++++++--- crates/ui-egui/src/shortcuts.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 3e3a33e8aa..2f429e6bcd 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -605,7 +605,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { } } -pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { +/// Ctrl+Z / Ctrl+Shift+Z inside Liquify, including while a slider's text field is focused. +pub fn history_keys(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(d) = app.distort.liquify.as_mut() else { return }; let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { @@ -613,6 +614,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { d.undo(); } +} + +pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { + history_keys(app, ctx); + let Some(d) = app.distort.liquify.as_mut() else { return }; // Ctrl+H (Hide Extras) toggles the red freeze-mask overlay; the mask itself stays active. if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::H)) { d.opts.show_mask = !d.opts.show_mask; @@ -625,10 +631,11 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) { d.global(LiquifyTool::ThawAll, None); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) { - d.opts.size = (d.opts.size * 0.9).max(1.0); + // At least one pixel, or 2 px * 0.9 rounds back to 2 and `[` never reaches 1. + d.opts.size = (d.opts.size * 0.9).round().min(d.opts.size - 1.0).clamp(1.0, 15000.0); } if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) { - d.opts.size = (d.opts.size * 1.1).min(15000.0); + d.opts.size = (d.opts.size * 1.1).round().max(d.opts.size + 1.0).min(15000.0); } let tools = [ (egui::Key::W, LiquifyTool::ForwardWarp), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a156a6ce3b..5b549d6679 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -335,8 +335,14 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Liquify is a full-window custom dialog with focusable sliders. egui can therefore claim // keyboard input before the distortion-mode handler below runs. Give Liquify's local // shortcuts first refusal (Undo, brush size, tool keys), but never steal keys from text edits. - if app.distort.liquify.is_some() && !ctx.text_edit_focused() { - crate::liquify_ui::keys(app, ctx); + if app.distort.liquify.is_some() { + // Undo and redo still apply while a numeric field (Size, Pressure, …) has focus. + // Other Liquify keys must not steal characters from that field (#4027). + if ctx.text_edit_focused() { + crate::liquify_ui::history_keys(app, ctx); + } else { + crate::liquify_ui::keys(app, ctx); + } } use crate::shortcut_dispatch::{Focus, dispatch_pressed}; let focus = Focus::of(ctx); From 4709af046b9ae0b29736dc30a51ccb4362acff2b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:57:49 +1100 Subject: [PATCH 202/523] Draw the Free Transform rotate arrow instead of hiding the pointer. (#4139) The rotate zone requested CursorIcon::None, which removes the system cursor. Crop already draws a turn arrow for that case. Free Transform now uses the same arrow. Fixes #4050 --- crates/ui-egui/src/canvas.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a77d34d48..96643b2024 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3435,10 +3435,17 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: }); if let Some((vertical, _)) = guide_hover { ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }); - } else if let Some(c) = - response.hover_pos().and_then(|p| crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command))) + } else if let Some(p) = response.hover_pos() + && let Some(c) = crate::transform_tool::cursor(app, xf.to_doc(p), ui.input(|i| i.modifiers.alt || i.modifiers.command)) { ui.ctx().set_cursor_icon(c); + // CursorIcon::None hides the pointer; the curved arrow has to be drawn or the + // rotate zone looks like a plain cursor (#4050). + let d = xf.to_doc(p); + if let Some(k) = crate::transform_tool::rotate_cursor_dir(app, d) { + let toward = xf.to_screen((d[0] + k[0] * 16.0) as f32, (d[1] + k[1] * 16.0) as f32) - p; + crate::crop_ui::draw_turn_cursor(&ctx, p, toward); + } } else if let Some(c) = response.hover_pos().and_then(|p| crate::cam_transform::cursor(app, &ctx, &xf, p)) { ui.ctx().set_cursor_icon(c); } else if let Some(c) = response.hover_pos().and_then(|p| transform_controls_rect(app, &xf).and_then(|r| transform_controls_cursor(r, p))) { From bfa63f77b476a097ae502794eb261dd204a0efaf Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 10:58:03 +1100 Subject: [PATCH 203/523] Snap 1 px pencil clicks to the pixel the aliased tip paints. (#4140) A shift-line between two clicks inside the same pixels changed with the sub-pixel position. Pencil and pencil-mode Eraser clicks now use the same grid centre as the dab. Fixes #4071 --- crates/ui-egui/src/canvas.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96643b2024..ebbfc7a40a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4843,10 +4843,16 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) // Clear mode (`~` held, or chosen in the options bar) erases with the tool's own brush. let clear = crate::paint_mouse::clears(app, tool); let erase = std::mem::take(&mut app.secondary_erase) || tool == Tool::Eraser || clear; + // A 1 px pencil line depends on the pixels, not where inside the pixel the click + // landed (#4071). Snap both ends of the stroke to the aliased tip's grid. + let (x, y) = snap_aliased_tip(app, tool, x, y); // ⇧-click connects to the last endpoint, including its pen pressure (#2827), so // the renderer interpolates size and opacity across the segment. let active = app.session.active().map(|st| st.doc.id); - let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p); + let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| { + let (sx, sy) = snap_aliased_tip(app, tool, p[0], p[1]); + [sx, sy, p[2]] + }); let mut points = vec![[x, y, pressure as f64]]; if let Some(p) = from { points.insert(0, p); @@ -5132,8 +5138,22 @@ fn commit_brush_stroke(app: &mut PhotocraftApp, d: &Drag) { } } +/// Pencil (and a Pencil-mode Eraser) dabs sit on the pixel grid. Clicks anywhere inside one +/// pixel must produce the same point, or a ⇧-click line changes with the sub-pixel position. +fn snap_aliased_tip(app: &PhotocraftApp, tool: Tool, x: f64, y: f64) -> (f64, f64) { + let pencil = tool == Tool::Pencil || crate::eraser_ui::pencil_mode(app, tool); + if !pencil { + return (x, y); + } + let (cx, cy) = photocraft_engine::paint::grid_center(x, y, app.session.tools.brush.size); + (f64::from(cx), f64::from(cy)) +} + pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { - let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let mut end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); + let (sx, sy) = snap_aliased_tip(app, d.tool, end[0], end[1]); + end[0] = sx; + end[1] = sy; if let Some(floating) = d.sel_move { finish_selection_drag(app, floating, d.start, [end[0], end[1]]); return; From 5ce608e5f510050592c0c6f7846188c96f00d71e Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:16 +0200 Subject: [PATCH 204/523] Image Size and Canvas Size: no silent resize of large documents in physical units (#4162) (#4163) The Width/Height fields capped non-pixel units at 30000. egui's DragValue clamps the shown value to its range when drawn, and number_edit reports that as an edit, so a 40000 px document at 72 ppi shown in points opened as 30000 pt and OK resized (Image Size) or cropped (Canvas Size) it with no edit. The bound is now the engine's 300000 px limit converted into the current unit, never below 30000. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/sizing.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/sizing.rs b/crates/ui-egui/src/sizing.rs index f4a9f54f06..6cce7e0a5c 100644 --- a/crates/ui-egui/src/sizing.rs +++ b/crates/ui-egui/src/sizing.rs @@ -166,7 +166,11 @@ fn dim_field(ui: &mut egui::Ui, f: &mut Map, key: &str, orig_key: let orig = num(f, orig_key); let px = num(f, key); let mut v = to_unit(px, &unit, orig, res) as f32; - let range = if unit == "px" { -300000.0..=300000.0 } else { -30000.0..=30000.0 }; + // The engine's 300000 px limit in this unit (never below 30000), so a large document's size + // isn't clamped when the field is drawn: egui clamps the shown value to the range, and the + // field reports that as an edit (#4162). + let lim = if unit == "px" { 300000.0 } else { to_unit(300000.0, &unit, orig, res).max(30000.0) as f32 }; + let range = -lim..=lim; // Each unit keeps its own precision: whole pixels, 3 places for inches, 2 for cm/mm/picas (#2434). // A typed unit (`2in` while in pixels) converts at the dialog's resolution. let units = crate::widgets::FieldUnits { unit: crate::widgets::unit_of_key(&unit), dpi: res, ppi: 72.0 }; @@ -596,6 +600,32 @@ mod tests { assert!((doc.resolution_dpi - 50.0).abs() < 1e-3, "{}", doc.resolution_dpi); } + /// #4162: a document wider than 30000 units in a physical unit (40000 px at 72 ppi is 40000 pt) + /// kept its size in the fields and on OK; the fields clamped it to 30000 on open, so OK with + /// no edit resized (Image Size) or cropped (Canvas Size) it. + #[test] + fn large_documents_in_physical_units_open_unchanged() { + use egui_kittest::Harness; + let mut wrong = Vec::new(); + for command in ["image.imageSize", "image.canvasSize"] { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 40000, "height": 4, "resolution": 72})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + let id = open(h.state_mut(), command).unwrap(); + h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("__unit".into(), json!("pt")); + h.run_steps(3); + let f = fields(&h); + let shown = (num(&f, "width"), num(&f, "height")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + let size = h.state().session.active().unwrap().doc.size; + if shown != (40000.0, 4.0) || size != photocraft_doc::Size::new(40000, 4) { + wrong.push(format!("{command}: fields {shown:?}, document {size:?}")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); + } + /// #2329: Image Size and Canvas Size open on Width with its value selected, as in Photoshop: /// typing replaces it and Enter applies it. #[test] From f60202879d7386c2c61c3185dda58de525eba3fd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:30 +0200 Subject: [PATCH 205/523] Exposure: the slider keeps fractional stops (#4164) (#4165) The shared adjustment slider rows rounded to 0.01 for ranges of 10 or less and to whole numbers otherwise, so Exposure (-20..20) snapped to whole stops: 0.5 became 1. Each row can now carry its own decimals: Exposure keeps 2 and Offset 4, as in Photoshop, and their fields show that many places. All other rows (Gamma, Posterize, Threshold, Brightness/Contrast, Vibrance, Hue/Saturation ranges, Photo Filter density) keep the old rule. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/adjust_editors.rs | 46 ++++++++---- crates/ui-egui/src/adjust_editors_tests.rs | 86 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 18 ++++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/adjust_editors.rs b/crates/ui-egui/src/adjust_editors.rs index 84fab70b4b..cc47bb33d1 100644 --- a/crates/ui-egui/src/adjust_editors.rs +++ b/crates/ui-egui/src/adjust_editors.rs @@ -115,18 +115,18 @@ pub fn editor(ui: &mut egui::Ui, kind: &str, v: &mut Value, cx: &EditorCx) -> Ed ui, v, &[ - ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, ""), - ("offset", tl!("Offset"), -0.5, 0.5, 0.0, ""), - ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, ""), + ("exposure", tl!("Exposure"), -20.0, 20.0, 0.0, "", Some(2)), + ("offset", tl!("Offset"), -0.5, 0.5, 0.0, "", Some(4)), + ("gamma", tl!("Gamma Correction"), 0.01, 9.99, 1.0, "", None), ], ), - "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%"), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%")]), + "vibrance" => sliders(ui, v, &[("vibrance", "Vibrance", -100.0, 100.0, 0.0, "%", None), ("saturation", "Saturation", -100.0, 100.0, 0.0, "%", None)]), "hueSaturation" => hue_saturation(ui, v, cx), "colorBalance" => color_balance(ui, v, cx), "blackWhite" => black_white(ui, v, cx), "photoFilter" => photo_filter(ui, v, cx), "channelMixer" => channel_mixer(ui, v, cx), - "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "")]), + "posterize" => sliders(ui, v, &[("levels", "Levels", 2.0, 255.0, 4.0, "", None)]), "threshold" => threshold(ui, v, cx), "gradientMap" => gradient_map(ui, v, cx), _ => Edit::default(), @@ -190,15 +190,31 @@ fn label(ui: &mut egui::Ui, text: &str) { } /// Labelled slider rows over top-level keys: (key, label, min, max, default, unit). -fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[(&str, &str, f32, f32, f32, &str)]) -> Edit { +/// A row of [`sliders`]: params key, label, range, default, unit, and its decimal places (None: +/// two for a range of 10 or less, else whole numbers). +type SliderRow<'a> = (&'a str, &'a str, f32, f32, f32, &'a str, Option); + +/// The value a [`sliders`] row stores for `x`: rounded to `decimals` places, or by its range. +pub(crate) fn row_value(x: f32, min: f32, max: f32, decimals: Option) -> f32 { + match decimals { + Some(d) => { + let scale = 10f32.powi(d.min(6) as i32); + (x * scale).round() / scale + } + None if max - min <= 10.0 => round(x, 0.01), + None => x.round(), + } +} + +fn sliders(ui: &mut egui::Ui, v: &mut Value, rows: &[SliderRow]) -> Edit { let mut e = Edit::default(); - for &(key, text, min, max, default, unit) in rows { + for &(key, text, min, max, default, unit, decimals) in rows { let mut x = num(v, key, default).clamp(min, max); // A double-click on the slider puts it back to its default (Photoshop). let gestures = widgets::RowGestures { reset: Some(default), wheel_step: None }; - let r = widgets::slider_row_with(ui, text, &mut x, min..=max, unit, None, gestures); + let r = widgets::slider_row_prec(ui, text, &mut x, min..=max, unit, None, gestures, decimals); if r.changed() { - v[key] = json!(if max - min <= 10.0 { round(x, 0.01) } else { x.round() }); + v[key] = json!(row_value(x, min, max, decimals)); } e.add(Edit::of(&r)); } @@ -222,7 +238,7 @@ fn track_stops<'a>(cx: &EditorCx, stops: &'a [Color32]) -> Option<&'a [Color32]> fn brightness_contrast(ui: &mut egui::Ui, v: &mut Value) -> Edit { let legacy = flag(v, "legacy", false); let lo = if legacy { -100.0 } else { -50.0 }; - let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, ""), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "")]); + let mut e = sliders(ui, v, &[("brightness", tl!("Brightness"), -150.0, 150.0, 0.0, "", None), ("contrast", tl!("Contrast"), lo, 100.0, 0.0, "", None)]); let mut l = legacy; if widgets::checkbox(ui, &mut l, tl!("Use Legacy")).changed() { v["legacy"] = json!(l); @@ -798,7 +814,7 @@ fn threshold(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { p.line_segment([pos2(x, hr.top()), pos2(x, hr.bottom())], Stroke::new(1.0, t.accent)); p.rect_stroke(hr, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); ui.add_space(4.0); - sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "")]) + sliders(ui, v, &[("level", tl!("Threshold Level"), 1.0, 255.0, 128.0, "", None)]) } // --------------------------------------------------------------------------------------------- @@ -912,7 +928,11 @@ fn hue_saturation(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { o["hue"] = json!(h.round()); } sub.add(Edit::of(&r)); - sub.add(sliders(ui, &mut o, &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%"), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%")])); + sub.add(sliders( + ui, + &mut o, + &[("saturation", tl!("Saturation"), -100.0, 100.0, 0.0, "%", None), ("lightness", tl!("Lightness"), -100.0, 100.0, 0.0, "%", None)], + )); if sub.changed { v[key] = o; } @@ -1143,7 +1163,7 @@ fn photo_filter(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { e.add(r); }); ui.data_mut(|d| d.insert_temp(mode_id, use_color)); - e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%")])); + e.add(sliders(ui, v, &[("density", tl!("Density"), 0.0, 100.0, 25.0, "%", None)])); let mut pl = flag(v, "preserveLuminosity", true); if widgets::checkbox(ui, &mut pl, tl!("Preserve Luminosity")).changed() { v["preserveLuminosity"] = json!(pl); diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 478b2de68d..805bbbdd90 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -664,3 +664,89 @@ fn properties_white_point_eyedropper_edits_the_layer(kind: &str) { h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(layer(&h).1, before); } + +/// A bare `kind` editor over `params`, as the Properties panel and the dialog host it. +fn editor_harness(kind: &'static str, params: serde_json::Value) -> Harness<'static, serde_json::Value> { + use crate::adjust_editors::{EditorCx, editor}; + let mut h = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state( + move |ui, values: &mut serde_json::Value| { + let cx = EditorCx { + mem: egui::Id::new("precision"), + hist: None, + gray: false, + swatches: [[0.0; 3], [1.0; 3]], + gradients: Vec::new(), + dialog: true, + dynamic_color_sliders: true, + }; + editor(ui, kind, values, &cx); + }, + params, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h +} + +/// Types `text` into the `index`th number field and commits it with Enter. +fn type_into_field(h: &mut Harness<'static, serde_json::Value>, index: usize, text: &str) { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +/// The text the `index`th number field shows. +fn field_text(h: &Harness<'static, serde_json::Value>, index: usize) -> String { + h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(index).unwrap().value().unwrap_or_default() +} + +/// #4164: Exposure keeps fractional stops (Photoshop: two decimals) and Offset four decimals, +/// instead of rounding Exposure to whole stops because its range is wider than 10. +#[test] +fn exposure_keeps_fractional_stops_and_offset_four_decimals() { + let mut h = editor_harness("exposure", json!({})); + type_into_field(&mut h, 0, "0.5"); + assert_eq!(h.state()["exposure"].as_f64(), Some(0.5), "{}", h.state()); + type_into_field(&mut h, 0, "-1.37"); + assert!((h.state()["exposure"].as_f64().unwrap() + 1.37).abs() < 1e-4, "{}", h.state()); + type_into_field(&mut h, 1, "0.0123"); + assert!((h.state()["offset"].as_f64().unwrap() - 0.0123).abs() < 1e-6, "{}", h.state()); + type_into_field(&mut h, 2, "1.234"); + assert!((h.state()["gamma"].as_f64().unwrap() - 1.23).abs() < 1e-4, "Gamma keeps two decimals: {}", h.state()); + h.run_steps(2); + assert_eq!(field_text(&h, 0), "-1.37"); + assert_eq!(field_text(&h, 1), "0.0123"); + assert_eq!(field_text(&h, 2), "1.23"); +} + +/// #4164: the integer rows that share Exposure's slider code stay whole numbers. +#[test] +fn integer_adjustment_rows_stay_whole_numbers() { + for (kind, key, typed, want) in [ + ("posterize", "levels", "4.6", 5.0), + ("threshold", "level", "100.4", 100.0), + ("brightnessContrast", "brightness", "12.7", 13.0), + ("vibrance", "vibrance", "-20.4", -20.0), + ] { + let mut h = editor_harness(kind, json!({})); + type_into_field(&mut h, 0, typed); + assert_eq!(h.state()[key].as_f64(), Some(want), "{kind}: {}", h.state()); + } +} + +/// #4164: a row's own decimals win over the range rule; rows without them keep it. +#[test] +fn row_values_round_to_their_own_decimals() { + use crate::adjust_editors::row_value; + assert_eq!(row_value(0.5, -20.0, 20.0, Some(2)), 0.5); + assert_eq!(row_value(-1.374, -20.0, 20.0, Some(2)), -1.37); + assert!((row_value(0.012_34, -0.5, 0.5, Some(4)) - 0.0123).abs() < 1e-7); + assert_eq!(row_value(4.6, 2.0, 255.0, None), 5.0, "wide ranges stay whole numbers"); + assert!((row_value(1.234, 0.01, 9.99, None) - 1.23).abs() < 1e-6, "small ranges keep two decimals"); + assert!(row_value(f32::NAN, -20.0, 20.0, Some(u32::MAX)).is_nan(), "no panic on odd input"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5a3def4f2d..8d57c51e75 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -973,6 +973,22 @@ pub fn slider_row_with( suffix: &str, gradient: Option<&[Color32]>, g: RowGestures, +) -> Response { + slider_row_prec(ui, label, value, range, suffix, gradient, g, None) +} + +/// [`slider_row_with`] whose field shows `decimals` places instead of the one (or two, for a +/// small range) it picks itself: Exposure's ±20 stops show two, its Offset four (#4164). +#[allow(clippy::too_many_arguments)] +pub fn slider_row_prec( + ui: &mut Ui, + label: &str, + value: &mut f32, + range: std::ops::RangeInclusive, + suffix: &str, + gradient: Option<&[Color32]>, + g: RowGestures, + decimals: Option, ) -> Response { let t = Tokens::get(ui.ctx()); let mut changed_resp = None; @@ -981,7 +997,7 @@ pub fn slider_row_with( let l = ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); scrub_label_within(ui, &l, f32::INFINITY); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - changed_resp = Some(value_field(ui, value, range.clone(), suffix, 74.0)); + changed_resp = Some(value_field_in(ui, value, range.clone(), suffix, 74.0, 0.0, FieldOpts { decimals, units: None }).0); }); }); let s = slider_with(ui, value, range, gradient, g); From 4566f5f55fe6728fee6571b22aa18d9eda451c9f Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:43 +0200 Subject: [PATCH 206/523] Shape tools: the click-to-open-dialog tolerance is the same on HiDPI displays (#4205) (#4207) finish_gesture passed the device-pixel zoom (current_zoom) to shape_dialog::is_click, whose 3-point slop is in egui points, so at pixels_per_point 2 the tolerance halved and a slightly jittery click drew a tiny shape instead of opening Create Rectangle/Ellipse/... Pass point_zoom(), as the marquee already does. Regression test: a 2 pt jitter at ppp 2 and 100 % opens the dialog and adds no layer. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/shape_dialog_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ebbfc7a40a..911e428a69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5176,7 +5176,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())), // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) - && crate::shape_dialog::is_click(d.start, &d.points, app.current_zoom()) + && crate::shape_dialog::is_click(d.start, &d.points, app.point_zoom()) && crate::shape_dialog::open(app, t, d.start).is_some() => {} t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::finish_shape(app, t, d.start, [end[0], end[1]], d.live), Tool::PathSelection => crate::vector_ui::path_selection_finish(app, d.start, [end[0], end[1]]), diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index fde2cf78c7..a0b5c61596 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -141,6 +141,20 @@ fn click_slop_scales_with_zoom() { assert!(is_click([10.0, 10.0], &[], 0.0)); } +/// #4205: the click slop is in screen points, so on a 2x (HiDPI) display at 100 % a 2 pt jitter +/// (4 device px = 4 document px) is still a click that opens Create Rectangle, not a tiny shape. +#[test] +fn a_click_with_jitter_opens_the_dialog_on_hidpi() { + let mut app = app(); + app.ppp = 2.0; + let i = app.session.active_index().unwrap(); + app.ui.views[i].zoom = 1.0; + let before = layers(&app); + press(&mut app, Tool::Rectangle, [20.0, 30.0], [24.0, 30.0]); + only_dialog(&app); + assert_eq!(layers(&app), before, "a jittery click draws nothing"); +} + #[test] fn cancel_draws_nothing_and_ok_remembers_the_size() { let mut app = app(); From f52549571ff0d04d2c660b4c8f012969fc40aabd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:58:57 +0200 Subject: [PATCH 207/523] Rotate View: the image follows the pointer in a flipped view (#4226) (#4227) ViewXform::to_screen mirrors x after rotating, so with View > Flip Horizontal on a larger rotation turns the image counter-clockwise on screen. The Rotate View drag and the trackpad rotate added their screen delta unchanged, so the image spun against the pointer. A small helper, rotate_view::screen_delta, negates the delta in a flipped view and serves both paths; Shift still snaps the final angle. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/rotate_view.rs | 56 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 911e428a69..af3a11f04d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2900,7 +2900,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: } let rotate_trackpad = app.session.prefs().enhanced_controls.rotate_view_with_trackpad; if let Some(delta) = crate::rotate_view::trackpad_delta(&ctx, rotate_trackpad) { - let next = view.rotation + delta; + let next = view.rotation + crate::rotate_view::screen_delta(delta, xf.flip); if next.is_finite() { view.rotation = crate::rotate_view::wrap_deg(next); } diff --git a/crates/ui-egui/src/rotate_view.rs b/crates/ui-egui/src/rotate_view.rs index 0204ba5b3b..01841b48f7 100644 --- a/crates/ui-egui/src/rotate_view.rs +++ b/crates/ui-egui/src/rotate_view.rs @@ -1,6 +1,7 @@ //! Rotate View tool: turn the canvas camera around its centre without rewriting pixels. //! -//! Drag around the view centre (clockwise increases the angle). Shift snaps to 15°. Reset View, +//! Drag around the view centre: the image follows the pointer (clockwise increases the angle, or +//! decreases it in a flipped view). Shift snaps to 15°. Reset View, //! Escape, a double-click, or the on-canvas compass put the angle back to 0. History is not //! recorded. Trackpad rotate is gated by Preferences › Enhanced Controls › Rotate View with //! Trackpad. @@ -59,6 +60,14 @@ pub fn snap_deg(a: f32, step: f32) -> f32 { wrap_deg((a / step).round() * step) } +/// Turn a pointer or trackpad angle change (screen degrees, clockwise positive) into a change of +/// `View::rotation`. [`ViewXform::to_screen`] mirrors x after rotating, so in a flipped view a +/// larger angle turns the image the other way on screen; negating keeps it under the pointer. +pub fn screen_delta(delta: f32, flip: bool) -> f32 { + let d = wrap_deg(delta); + if flip { wrap_deg(-d) } else { d } +} + /// Pointer angle in degrees around `pivot` (Y-down, so clockwise is positive). pub fn pointer_deg(p: Pos2, pivot: Pos2) -> f32 { let d = p - pivot; @@ -180,7 +189,7 @@ pub fn drag(ctx: &egui::Context, view: &mut View, xf: &ViewXform, b: &Buttons, p return b.started || b.dragged || b.stopped; }; if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) { - let delta = wrap_deg(pointer_deg(p, z.pivot) - z.start_pointer); + let delta = screen_delta(pointer_deg(p, z.pivot) - z.start_pointer, xf.flip); let mut angle = z.start_rotation + delta; if !angle.is_finite() { angle = z.start_rotation; @@ -358,6 +367,49 @@ mod tests { assert!((s.x - c.x).abs() < 1e-3 && (s.y - (c.y + 1.0)).abs() < 1e-3, "flip then rotate {s:?}"); } + /// Screen angle, about the canvas centre, of the document point that was under `p0` at the press. + fn followed_angle(h: &Harness<'static, PhotocraftApp>, p0: Pos2, start_rotation: f32) -> f32 { + let st = h.state(); + let (rect, v, flip) = (st.last_canvas_rect, &st.ui.views[0], st.ui.view.flip_horizontal); + let before = ViewXform { rect, zoom: 1.0, center: v.center, flip, rotation: start_rotation, aspect: 1.0 }; + let d = before.to_doc(p0); + let after = ViewXform { rotation: v.rotation, ..before }; + let s = after.to_screen(d[0] as f32, d[1] as f32) - rect.center(); + wrap_deg(s.y.atan2(s.x).to_degrees() - pointer_deg(p0, rect.center())) + } + + #[test] + fn drag_follows_the_pointer_in_a_flipped_view() { + for flip in [false, true] { + let mut h = harness(); + h.state_mut().ui.view.flip_horizontal = flip; + h.run_steps(2); + let c = h.state().last_canvas_rect.center(); + let p0 = c + vec2(120.0, 0.0); + h.event(Event::PointerMoved(p0)); + h.step(); + press(&mut h, p0, true, false); + // 30° clockwise about the pivot: the image turns 30° clockwise on screen. + let p1 = c + vec2(120.0 * 30f32.to_radians().cos(), 120.0 * 30f32.to_radians().sin()); + h.event(Event::PointerMoved(p1)); + h.step(); + let got = followed_angle(&h, p0, 0.0); + assert!((got - 30.0).abs() < 0.75, "flip={flip}: the image turned {got}°, the pointer 30°"); + press(&mut h, p1, false, false); + } + } + + #[test] + fn screen_delta_mirrors_only_in_a_flipped_view() { + assert_eq!(screen_delta(12.5, false), 12.5); + assert_eq!(screen_delta(12.5, true), -12.5); + assert_eq!(screen_delta(-180.0, true), 180.0); + assert_eq!(screen_delta(f32::NAN, true), 0.0); + assert_eq!(screen_delta(f32::INFINITY, false), 0.0); + // A flipped view still lands on the snapped angle with Shift. + assert_eq!(snap_deg(0.0 + screen_delta(-40.0, true), SNAP_DEG), 45.0); + } + fn app_docs(n: usize) -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); for i in 0..n { From 9970cf72142796192a6e274bfaa8356e7582f60c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 01:59:11 +0200 Subject: [PATCH 208/523] Brush resize HUD: horizontal is size and vertical is hardness in a rotated view (#4228) (#4229) The Control+Alt / Alt+right-drag HUD measured the drag in document axes and only corrected for View > Flip Horizontal, so at 90 degrees a horizontal drag changed the hardness and at 180 degrees dragging right shrank the brush. Map the anchor and pointer to screen with the active ViewXform and divide by its zoom: identical numbers at rotation 0 (flipped or not), screen-relative when rotated, as in Photoshop. Without a view the old behaviour remains. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/ui-egui/src/brush_resize.rs | 47 ++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/brush_resize.rs b/crates/ui-egui/src/brush_resize.rs index c4239a3586..dfba8e5166 100644 --- a/crates/ui-egui/src/brush_resize.rs +++ b/crates/ui-egui/src/brush_resize.rs @@ -111,9 +111,17 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) { } fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) { - // Right is bigger on screen, also in a flipped view (View › Flip Horizontal). - let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }; - let (size, v) = resized(r.start, dx, y - r.anchor[1], app.point_zoom()); + // Screen-relative, as in Photoshop (#4228): right is bigger and down harder on screen, however + // the view is rotated or flipped. The screen delta over the zoom is in document pixels at 0°. + let (dx, dy, zoom) = match ViewXform::active(app) { + Some(xf) if xf.zoom.is_finite() && xf.zoom > 0.0 => { + let d = xf.to_screen(x as f32, y as f32) - xf.to_screen(r.anchor[0] as f32, r.anchor[1] as f32); + (f64::from(d.x / xf.zoom), f64::from(d.y / xf.zoom), xf.zoom) + } + // No view: document axes, mirrored by View › Flip Horizontal. + _ => ((x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 }, y - r.anchor[1], app.point_zoom()), + }; + let (size, v) = resized(r.start, dx, dy, zoom); // "Vary Round Brush Hardness on HUD" (Preferences ▸ Tools, on by default): off, the vertical // movement takes the opacity instead of the hardness, as in Photoshop. let vary = app.session.prefs().tools.vary_round_brush_hardness_on_hud; @@ -484,6 +492,39 @@ mod tests { } } + /// One Control+Alt drag by `(dx, dy)` document pixels from (100, 100) in a view turned by + /// `rotation` degrees (flipped with `flip`): the resulting (size, hardness). + fn drag_in_view(rotation: f32, flip: bool, dx: f64, dy: f64) -> (f32, f32) { + let mut app = app(Tool::Brush); + app.sync_views(); + app.ui.views[0].rotation = rotation; + app.ui.view.flip_horizontal = flip; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, CTRL_ALT); + tool_event(&mut app, ToolEvent::Up { x: 100.0 + dx, y: 100.0 + dy }, CTRL_ALT); + let b = &app.session.tools.brush; + (b.size, b.hardness) + } + + /// #4228: the HUD is screen-relative, as in Photoshop: horizontal on screen is the size and + /// vertical the hardness however the view is rotated or flipped. + #[test] + fn a_rotated_view_keeps_horizontal_for_size_and_vertical_for_hardness() { + // Unrotated, with and without View › Flip Horizontal: unchanged. + assert_eq!(drag_in_view(0.0, false, 30.0, 0.0), (100.0, 0.2), "right grows"); + assert_eq!(drag_in_view(0.0, false, 0.0, 40.0), (40.0, 0.4), "down hardens"); + assert_eq!(drag_in_view(0.0, true, -30.0, 0.0), (100.0, 0.2), "flipped: screen right grows"); + assert_eq!(drag_in_view(0.0, true, 0.0, 40.0), (40.0, 0.4), "flipped: down hardens"); + // 90° clockwise: document +y points screen-left and document +x screen-down. + assert_eq!(drag_in_view(90.0, false, 0.0, 10.0), (20.0, 0.2), "screen-left shrinks, the hardness stays"); + assert_eq!(drag_in_view(90.0, false, 0.0, -30.0), (100.0, 0.2), "screen-right grows"); + assert_eq!(drag_in_view(90.0, false, 40.0, 0.0), (40.0, 0.4), "screen-down hardens, the size stays"); + // 180°: document -x is screen right. + assert_eq!(drag_in_view(180.0, false, -30.0, 0.0), (100.0, 0.2), "screen right still grows"); + assert_eq!(drag_in_view(180.0, false, 0.0, -40.0), (40.0, 0.4), "screen down still hardens"); + // 90° and flipped: document +y points screen-right. + assert_eq!(drag_in_view(90.0, true, 0.0, 30.0), (100.0, 0.2), "rotated and flipped: screen right grows"); + } + /// Preferences ▸ Cursors ▸ Brush Preview colour tints the HUD's tip preview (red by default). #[test] fn the_tip_preview_uses_the_brush_preview_colour() { From 5981a02e64d536b627e301e79805cdd9572b5dbb Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:59:36 +0700 Subject: [PATCH 209/523] feat(codecs): support full X11 rgb.txt color names in XPM (#4263) * feat(codecs): import XPM3 images with transparency (#3692) * Correct XPM test expectation for green palette name * feat(codecs): resolve full X11 rgb.txt color names in XPM * XPM: fix Format::ALL length after DPX, reject '+' in hex colours, fix non-ASCII byte-string test, fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + apps/photocraft/src/services.rs | 4 +- crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/codecs/xpm.rs | 192 ++++++ crates/codecs/src/codecs/xpm/x11_colors.rs | 666 +++++++++++++++++++++ crates/codecs/src/format.rs | 13 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/xpm.rs | 93 +++ 8 files changed, 970 insertions(+), 4 deletions(-) create mode 100644 crates/codecs/src/codecs/xpm.rs create mode 100644 crates/codecs/src/codecs/xpm/x11_colors.rs create mode 100644 crates/codecs/tests/xpm.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c2b9f87854..8c838b64d9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ next to them. | Path | Title | Author | Source | License | |---|---|---|---|---| +| `crates/codecs/src/codecs/xpm/x11_colors.rs` | X11 `rgb.txt` color-name lookup table (normalized X.Org RGB values) | X.Org Foundation and X11 contributors | | MIT (X.Org app/rgb) | | `crates/ui-egui/src/i18n/el.tsv` | Greek 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/de.tsv` | German 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/it.tsv` | Italian 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) | diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index faf0b6a11f..b24c2a025a 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", - "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 2a1407d1c4..16a0d0e159 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -14,3 +14,4 @@ pub(crate) mod tiff_ifd; pub(crate) mod via_image; pub(crate) mod vp8; pub(crate) mod webp; +pub(crate) mod xpm; diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs new file mode 100644 index 0000000000..36062f04db --- /dev/null +++ b/crates/codecs/src/codecs/xpm.rs @@ -0,0 +1,192 @@ +//! XPM3 reader for C-source icon arrays. Supports transparency (None), RGB hex +//! at 4/8/16 bits per channel, and common X11 colour names. Does not run C code. + +use std::collections::HashMap; + +mod x11_colors; + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +const MAX_SOURCE: usize = 256 << 20; +const MAX_COLOURS: usize = 65_536; +const MAX_CPP: usize = 8; + +fn bad(msg: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Xpm, msg) +} + +/// Extract C string literals without treating quoted text or // in a string as a comment. +/// XPM uses one literal for the header, each palette entry and each row. +fn c_strings(bytes: &[u8]) -> Result>, CodecError> { + let mut lines = Vec::new(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i..].starts_with(b"/*") { + let tail = &bytes[i + 2..]; + let Some(end) = tail.windows(2).position(|p| p == b"*/") else { return Err(bad("unterminated C comment")) }; + i += end + 4; + } else if bytes[i..].starts_with(b"//") { + i += bytes[i..].iter().position(|&c| c == b'\n').unwrap_or(bytes.len() - i); + } else if bytes[i] == b'"' { + i += 1; + let mut s = Vec::new(); + let mut closed = false; + while i < bytes.len() { + let c = bytes[i]; + i += 1; + match c { + b'"' => { + closed = true; + break; + } + b'\\' => { + let next = *bytes.get(i).ok_or_else(|| bad("truncated C escape"))?; + i += 1; + s.push(match next { + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'0' => 0, + _ => next, // quoted backslash or quote + }); + } + _ => s.push(c), + } + } + if !closed { + return Err(bad("unterminated XPM string")); + } + lines.push(s); + } else { + i += 1; + } + } + Ok(lines) +} + +fn channel(hex: &str) -> Option { + let v = u32::from_str_radix(hex, 16).ok()?; + let max = (1u32 << (hex.len() * 4)) - 1; + Some(((v * 255 + max / 2) / max) as u8) +} + +fn named(name: &str) -> Option<[u8; 3]> { + let s = name.to_ascii_lowercase().replace([' ', '-', '_'], ""); + // XPM imports X11 names, whose RGB values differ from CSS for names + // such as gray, maroon and purple. Keep these exact rather than guessing. + if let Ok(index) = x11_colors::X11_COLORS.binary_search_by_key(&s.as_str(), |(name, _)| *name) { + return Some(x11_colors::X11_COLORS[index].1); + } + // Retain useful web color aliases not present in the X11 database. + Some(match s.as_str() { + "silver" => [192, 192, 192], + "olive" => [128, 128, 0], + "teal" => [0, 128, 128], + "fuchsia" => [255, 0, 255], + "navyblue" => [0, 0, 128], + _ => return None, + }) +} + +fn color(name: &str) -> Option<[u8; 4]> { + if name.eq_ignore_ascii_case("none") { + return Some([0, 0, 0, 0]); + } + let rgb = if let Some(hex) = name.strip_prefix('#') { + // UTF-8 byte lengths can match a supported hex width while slicing would panic. + // `from_str_radix` would also take a leading `+`: accept hex digits only. + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let digits = match hex.len() { + 3 => 1, + 6 => 2, + 12 => 4, + _ => return None, + }; + let rgb = (0..3).map(|i| channel(&hex[i * digits..(i + 1) * digits])).collect::>>()?; + [rgb[0], rgb[1], rgb[2]] + } else { + named(name)? + }; + Some([rgb[0], rgb[1], rgb[2], 255]) +} + +pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { + if !bytes.starts_with(b"/* XPM */") { + return Err(bad("not an XPM3 image")); + } + if bytes.len() > MAX_SOURCE { + return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into())); + } + let lines = c_strings(bytes)?; + let header = lines.first().ok_or_else(|| bad("missing image header"))?; + let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?; + let mut parts = parts.split_ascii_whitespace(); + let number = |key: &str, s: Option<&str>| -> Result { + s.ok_or_else(|| bad(format!("missing {key}")))?.parse::().map_err(|_| bad(format!("invalid {key}"))) + }; + let width = number("width", parts.next())?; + let height = number("height", parts.next())?; + let colours = number("palette size", parts.next())?; + let cpp = number("chars per pixel", parts.next())?; + if width == 0 || height == 0 || colours == 0 || colours > MAX_COLOURS || !(1..=MAX_CPP).contains(&cpp) { + return Err(bad("invalid XPM dimensions, palette size or chars per pixel")); + } + let (w, h) = (u32::try_from(width).map_err(|_| bad("width overflow"))?, u32::try_from(height).map_err(|_| bad("height overflow"))?); + limits.check(w, h, ChannelLayout::Rgba, SampleType::U8)?; + let required = 1usize.checked_add(colours).and_then(|n| n.checked_add(height)).ok_or_else(|| bad("row count overflow"))?; + if lines.len() < required { + return Err(bad("truncated palette or image rows")); + } + let mut palette = HashMap::, [u8; 4]>::with_capacity(colours); + for line in &lines[1..1 + colours] { + if line.len() < cpp { + return Err(bad("truncated colour key")); + } + let fields = std::str::from_utf8(&line[cpp..]).map_err(|_| bad("invalid colour name"))?; + // XPM priority is colour, gray, four-level gray, then monochrome, + // regardless of the order of keys in the palette entry. + let terms: Vec<&str> = fields.split_ascii_whitespace().collect(); + let mut picked: Option<(u8, String)> = None; + let mut i = 0; + while i < terms.len() { + let priority = match terms[i] { + "c" => 4, + "g" => 3, + "g4" => 2, + "m" => 1, + _ => 0, // Symbolic names ('s') are not colour values. + }; + let start = i + 1; + i = start; + while i < terms.len() && !matches!(terms[i], "c" | "g" | "g4" | "m" | "s") { + i += 1; + } + if priority > 0 && i > start && picked.as_ref().is_none_or(|(current, _)| priority > *current) { + picked = Some((priority, terms[start..i].join(" "))); + } + } + let value = picked.as_ref().and_then(|(_, name)| color(name)).ok_or_else(|| bad("unsupported or missing XPM colour"))?; + if palette.insert(line[..cpp].to_vec(), value).is_some() { + return Err(bad("duplicate XPM palette key")); + } + } + let row_len = width.checked_mul(cpp).ok_or_else(|| bad("row byte count overflow"))?; + let mut pixels = vec![0u8; width.checked_mul(height).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("pixel size overflow"))?]; + for (y, line) in lines[1 + colours..required].iter().enumerate() { + if line.len() != row_len { + return Err(bad("XPM row has the wrong width")); + } + for x in 0..width { + let key = &line[x * cpp..(x + 1) * cpp]; + let rgba = palette.get(key).ok_or_else(|| bad("unknown palette key"))?; + pixels[(y * width + x) * 4..(y * width + x + 1) * 4].copy_from_slice(rgba); + } + } + Image::from_u8(w, h, ChannelLayout::Rgba, pixels) +} diff --git a/crates/codecs/src/codecs/xpm/x11_colors.rs b/crates/codecs/src/codecs/xpm/x11_colors.rs new file mode 100644 index 0000000000..1d47b37061 --- /dev/null +++ b/crates/codecs/src/codecs/xpm/x11_colors.rs @@ -0,0 +1,666 @@ +//! X11 color-name database (X.Org rgb.txt) normalized to lowercase without +//! spaces, underscores, or hyphens. RGB values are *X11* values, not CSS values. +//! Source: https://cgit.freedesktop.org/xorg/app/rgb/tree/rgb.txt +//! License: MIT (X.Org Foundation), see the upstream app/rgb COPYING file. +//! Aliases differing only in case or spaces have been collapsed. +//! Keep sorted: decoder uses binary search without allocation per candidate. + +pub(super) const X11_COLORS: &[(&str, [u8; 3])] = &[ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("antiquewhite1", [255, 239, 219]), + ("antiquewhite2", [238, 223, 204]), + ("antiquewhite3", [205, 192, 176]), + ("antiquewhite4", [139, 131, 120]), + ("aquamarine", [127, 255, 212]), + ("aquamarine1", [127, 255, 212]), + ("aquamarine2", [118, 238, 198]), + ("aquamarine3", [102, 205, 170]), + ("aquamarine4", [69, 139, 116]), + ("azure", [240, 255, 255]), + ("azure1", [240, 255, 255]), + ("azure2", [224, 238, 238]), + ("azure3", [193, 205, 205]), + ("azure4", [131, 139, 139]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("bisque1", [255, 228, 196]), + ("bisque2", [238, 213, 183]), + ("bisque3", [205, 183, 158]), + ("bisque4", [139, 125, 107]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blue1", [0, 0, 255]), + ("blue2", [0, 0, 238]), + ("blue3", [0, 0, 205]), + ("blue4", [0, 0, 139]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("brown1", [255, 64, 64]), + ("brown2", [238, 59, 59]), + ("brown3", [205, 51, 51]), + ("brown4", [139, 35, 35]), + ("burlywood", [222, 184, 135]), + ("burlywood1", [255, 211, 155]), + ("burlywood2", [238, 197, 145]), + ("burlywood3", [205, 170, 125]), + ("burlywood4", [139, 115, 85]), + ("cadetblue", [95, 158, 160]), + ("cadetblue1", [152, 245, 255]), + ("cadetblue2", [142, 229, 238]), + ("cadetblue3", [122, 197, 205]), + ("cadetblue4", [83, 134, 139]), + ("chartreuse", [127, 255, 0]), + ("chartreuse1", [127, 255, 0]), + ("chartreuse2", [118, 238, 0]), + ("chartreuse3", [102, 205, 0]), + ("chartreuse4", [69, 139, 0]), + ("chocolate", [210, 105, 30]), + ("chocolate1", [255, 127, 36]), + ("chocolate2", [238, 118, 33]), + ("chocolate3", [205, 102, 29]), + ("chocolate4", [139, 69, 19]), + ("coral", [255, 127, 80]), + ("coral1", [255, 114, 86]), + ("coral2", [238, 106, 80]), + ("coral3", [205, 91, 69]), + ("coral4", [139, 62, 47]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("cornsilk1", [255, 248, 220]), + ("cornsilk2", [238, 232, 205]), + ("cornsilk3", [205, 200, 177]), + ("cornsilk4", [139, 136, 120]), + ("cyan", [0, 255, 255]), + ("cyan1", [0, 255, 255]), + ("cyan2", [0, 238, 238]), + ("cyan3", [0, 205, 205]), + ("cyan4", [0, 139, 139]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgoldenrod1", [255, 185, 15]), + ("darkgoldenrod2", [238, 173, 14]), + ("darkgoldenrod3", [205, 149, 12]), + ("darkgoldenrod4", [139, 101, 8]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkolivegreen1", [202, 255, 112]), + ("darkolivegreen2", [188, 238, 104]), + ("darkolivegreen3", [162, 205, 90]), + ("darkolivegreen4", [110, 139, 61]), + ("darkorange", [255, 140, 0]), + ("darkorange1", [255, 127, 0]), + ("darkorange2", [238, 118, 0]), + ("darkorange3", [205, 102, 0]), + ("darkorange4", [139, 69, 0]), + ("darkorchid", [153, 50, 204]), + ("darkorchid1", [191, 62, 255]), + ("darkorchid2", [178, 58, 238]), + ("darkorchid3", [154, 50, 205]), + ("darkorchid4", [104, 34, 139]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkseagreen1", [193, 255, 193]), + ("darkseagreen2", [180, 238, 180]), + ("darkseagreen3", [155, 205, 155]), + ("darkseagreen4", [105, 139, 105]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategray1", [151, 255, 255]), + ("darkslategray2", [141, 238, 238]), + ("darkslategray3", [121, 205, 205]), + ("darkslategray4", [82, 139, 139]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deeppink1", [255, 20, 147]), + ("deeppink2", [238, 18, 137]), + ("deeppink3", [205, 16, 118]), + ("deeppink4", [139, 10, 80]), + ("deepskyblue", [0, 191, 255]), + ("deepskyblue1", [0, 191, 255]), + ("deepskyblue2", [0, 178, 238]), + ("deepskyblue3", [0, 154, 205]), + ("deepskyblue4", [0, 104, 139]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("dodgerblue1", [30, 144, 255]), + ("dodgerblue2", [28, 134, 238]), + ("dodgerblue3", [24, 116, 205]), + ("dodgerblue4", [16, 78, 139]), + ("firebrick", [178, 34, 34]), + ("firebrick1", [255, 48, 48]), + ("firebrick2", [238, 44, 44]), + ("firebrick3", [205, 38, 38]), + ("firebrick4", [139, 26, 26]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("gold1", [255, 215, 0]), + ("gold2", [238, 201, 0]), + ("gold3", [205, 173, 0]), + ("gold4", [139, 117, 0]), + ("goldenrod", [218, 165, 32]), + ("goldenrod1", [255, 193, 37]), + ("goldenrod2", [238, 180, 34]), + ("goldenrod3", [205, 155, 29]), + ("goldenrod4", [139, 105, 20]), + ("gray", [190, 190, 190]), + ("gray0", [0, 0, 0]), + ("gray1", [3, 3, 3]), + ("gray10", [26, 26, 26]), + ("gray100", [255, 255, 255]), + ("gray11", [28, 28, 28]), + ("gray12", [31, 31, 31]), + ("gray13", [33, 33, 33]), + ("gray14", [36, 36, 36]), + ("gray15", [38, 38, 38]), + ("gray16", [41, 41, 41]), + ("gray17", [43, 43, 43]), + ("gray18", [46, 46, 46]), + ("gray19", [48, 48, 48]), + ("gray2", [5, 5, 5]), + ("gray20", [51, 51, 51]), + ("gray21", [54, 54, 54]), + ("gray22", [56, 56, 56]), + ("gray23", [59, 59, 59]), + ("gray24", [61, 61, 61]), + ("gray25", [64, 64, 64]), + ("gray26", [66, 66, 66]), + ("gray27", [69, 69, 69]), + ("gray28", [71, 71, 71]), + ("gray29", [74, 74, 74]), + ("gray3", [8, 8, 8]), + ("gray30", [77, 77, 77]), + ("gray31", [79, 79, 79]), + ("gray32", [82, 82, 82]), + ("gray33", [84, 84, 84]), + ("gray34", [87, 87, 87]), + ("gray35", [89, 89, 89]), + ("gray36", [92, 92, 92]), + ("gray37", [94, 94, 94]), + ("gray38", [97, 97, 97]), + ("gray39", [99, 99, 99]), + ("gray4", [10, 10, 10]), + ("gray40", [102, 102, 102]), + ("gray41", [105, 105, 105]), + ("gray42", [107, 107, 107]), + ("gray43", [110, 110, 110]), + ("gray44", [112, 112, 112]), + ("gray45", [115, 115, 115]), + ("gray46", [117, 117, 117]), + ("gray47", [120, 120, 120]), + ("gray48", [122, 122, 122]), + ("gray49", [125, 125, 125]), + ("gray5", [13, 13, 13]), + ("gray50", [127, 127, 127]), + ("gray51", [130, 130, 130]), + ("gray52", [133, 133, 133]), + ("gray53", [135, 135, 135]), + ("gray54", [138, 138, 138]), + ("gray55", [140, 140, 140]), + ("gray56", [143, 143, 143]), + ("gray57", [145, 145, 145]), + ("gray58", [148, 148, 148]), + ("gray59", [150, 150, 150]), + ("gray6", [15, 15, 15]), + ("gray60", [153, 153, 153]), + ("gray61", [156, 156, 156]), + ("gray62", [158, 158, 158]), + ("gray63", [161, 161, 161]), + ("gray64", [163, 163, 163]), + ("gray65", [166, 166, 166]), + ("gray66", [168, 168, 168]), + ("gray67", [171, 171, 171]), + ("gray68", [173, 173, 173]), + ("gray69", [176, 176, 176]), + ("gray7", [18, 18, 18]), + ("gray70", [179, 179, 179]), + ("gray71", [181, 181, 181]), + ("gray72", [184, 184, 184]), + ("gray73", [186, 186, 186]), + ("gray74", [189, 189, 189]), + ("gray75", [191, 191, 191]), + ("gray76", [194, 194, 194]), + ("gray77", [196, 196, 196]), + ("gray78", [199, 199, 199]), + ("gray79", [201, 201, 201]), + ("gray8", [20, 20, 20]), + ("gray80", [204, 204, 204]), + ("gray81", [207, 207, 207]), + ("gray82", [209, 209, 209]), + ("gray83", [212, 212, 212]), + ("gray84", [214, 214, 214]), + ("gray85", [217, 217, 217]), + ("gray86", [219, 219, 219]), + ("gray87", [222, 222, 222]), + ("gray88", [224, 224, 224]), + ("gray89", [227, 227, 227]), + ("gray9", [23, 23, 23]), + ("gray90", [229, 229, 229]), + ("gray91", [232, 232, 232]), + ("gray92", [235, 235, 235]), + ("gray93", [237, 237, 237]), + ("gray94", [240, 240, 240]), + ("gray95", [242, 242, 242]), + ("gray96", [245, 245, 245]), + ("gray97", [247, 247, 247]), + ("gray98", [250, 250, 250]), + ("gray99", [252, 252, 252]), + ("green", [0, 255, 0]), + ("green1", [0, 255, 0]), + ("green2", [0, 238, 0]), + ("green3", [0, 205, 0]), + ("green4", [0, 139, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [190, 190, 190]), + ("grey0", [0, 0, 0]), + ("grey1", [3, 3, 3]), + ("grey10", [26, 26, 26]), + ("grey100", [255, 255, 255]), + ("grey11", [28, 28, 28]), + ("grey12", [31, 31, 31]), + ("grey13", [33, 33, 33]), + ("grey14", [36, 36, 36]), + ("grey15", [38, 38, 38]), + ("grey16", [41, 41, 41]), + ("grey17", [43, 43, 43]), + ("grey18", [46, 46, 46]), + ("grey19", [48, 48, 48]), + ("grey2", [5, 5, 5]), + ("grey20", [51, 51, 51]), + ("grey21", [54, 54, 54]), + ("grey22", [56, 56, 56]), + ("grey23", [59, 59, 59]), + ("grey24", [61, 61, 61]), + ("grey25", [64, 64, 64]), + ("grey26", [66, 66, 66]), + ("grey27", [69, 69, 69]), + ("grey28", [71, 71, 71]), + ("grey29", [74, 74, 74]), + ("grey3", [8, 8, 8]), + ("grey30", [77, 77, 77]), + ("grey31", [79, 79, 79]), + ("grey32", [82, 82, 82]), + ("grey33", [84, 84, 84]), + ("grey34", [87, 87, 87]), + ("grey35", [89, 89, 89]), + ("grey36", [92, 92, 92]), + ("grey37", [94, 94, 94]), + ("grey38", [97, 97, 97]), + ("grey39", [99, 99, 99]), + ("grey4", [10, 10, 10]), + ("grey40", [102, 102, 102]), + ("grey41", [105, 105, 105]), + ("grey42", [107, 107, 107]), + ("grey43", [110, 110, 110]), + ("grey44", [112, 112, 112]), + ("grey45", [115, 115, 115]), + ("grey46", [117, 117, 117]), + ("grey47", [120, 120, 120]), + ("grey48", [122, 122, 122]), + ("grey49", [125, 125, 125]), + ("grey5", [13, 13, 13]), + ("grey50", [127, 127, 127]), + ("grey51", [130, 130, 130]), + ("grey52", [133, 133, 133]), + ("grey53", [135, 135, 135]), + ("grey54", [138, 138, 138]), + ("grey55", [140, 140, 140]), + ("grey56", [143, 143, 143]), + ("grey57", [145, 145, 145]), + ("grey58", [148, 148, 148]), + ("grey59", [150, 150, 150]), + ("grey6", [15, 15, 15]), + ("grey60", [153, 153, 153]), + ("grey61", [156, 156, 156]), + ("grey62", [158, 158, 158]), + ("grey63", [161, 161, 161]), + ("grey64", [163, 163, 163]), + ("grey65", [166, 166, 166]), + ("grey66", [168, 168, 168]), + ("grey67", [171, 171, 171]), + ("grey68", [173, 173, 173]), + ("grey69", [176, 176, 176]), + ("grey7", [18, 18, 18]), + ("grey70", [179, 179, 179]), + ("grey71", [181, 181, 181]), + ("grey72", [184, 184, 184]), + ("grey73", [186, 186, 186]), + ("grey74", [189, 189, 189]), + ("grey75", [191, 191, 191]), + ("grey76", [194, 194, 194]), + ("grey77", [196, 196, 196]), + ("grey78", [199, 199, 199]), + ("grey79", [201, 201, 201]), + ("grey8", [20, 20, 20]), + ("grey80", [204, 204, 204]), + ("grey81", [207, 207, 207]), + ("grey82", [209, 209, 209]), + ("grey83", [212, 212, 212]), + ("grey84", [214, 214, 214]), + ("grey85", [217, 217, 217]), + ("grey86", [219, 219, 219]), + ("grey87", [222, 222, 222]), + ("grey88", [224, 224, 224]), + ("grey89", [227, 227, 227]), + ("grey9", [23, 23, 23]), + ("grey90", [229, 229, 229]), + ("grey91", [232, 232, 232]), + ("grey92", [235, 235, 235]), + ("grey93", [237, 237, 237]), + ("grey94", [240, 240, 240]), + ("grey95", [242, 242, 242]), + ("grey96", [245, 245, 245]), + ("grey97", [247, 247, 247]), + ("grey98", [250, 250, 250]), + ("grey99", [252, 252, 252]), + ("honeydew", [240, 255, 240]), + ("honeydew1", [240, 255, 240]), + ("honeydew2", [224, 238, 224]), + ("honeydew3", [193, 205, 193]), + ("honeydew4", [131, 139, 131]), + ("hotpink", [255, 105, 180]), + ("hotpink1", [255, 110, 180]), + ("hotpink2", [238, 106, 167]), + ("hotpink3", [205, 96, 144]), + ("hotpink4", [139, 58, 98]), + ("indianred", [205, 92, 92]), + ("indianred1", [255, 106, 106]), + ("indianred2", [238, 99, 99]), + ("indianred3", [205, 85, 85]), + ("indianred4", [139, 58, 58]), + ("ivory", [255, 255, 240]), + ("ivory1", [255, 255, 240]), + ("ivory2", [238, 238, 224]), + ("ivory3", [205, 205, 193]), + ("ivory4", [139, 139, 131]), + ("khaki", [240, 230, 140]), + ("khaki1", [255, 246, 143]), + ("khaki2", [238, 230, 133]), + ("khaki3", [205, 198, 115]), + ("khaki4", [139, 134, 78]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lavenderblush1", [255, 240, 245]), + ("lavenderblush2", [238, 224, 229]), + ("lavenderblush3", [205, 193, 197]), + ("lavenderblush4", [139, 131, 134]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lemonchiffon1", [255, 250, 205]), + ("lemonchiffon2", [238, 233, 191]), + ("lemonchiffon3", [205, 201, 165]), + ("lemonchiffon4", [139, 137, 112]), + ("lightblue", [173, 216, 230]), + ("lightblue1", [191, 239, 255]), + ("lightblue2", [178, 223, 238]), + ("lightblue3", [154, 192, 205]), + ("lightblue4", [104, 131, 139]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightcyan1", [224, 255, 255]), + ("lightcyan2", [209, 238, 238]), + ("lightcyan3", [180, 205, 205]), + ("lightcyan4", [122, 139, 139]), + ("lightgoldenrod", [238, 221, 130]), + ("lightgoldenrod1", [255, 236, 139]), + ("lightgoldenrod2", [238, 220, 130]), + ("lightgoldenrod3", [205, 190, 112]), + ("lightgoldenrod4", [139, 129, 76]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightpink1", [255, 174, 185]), + ("lightpink2", [238, 162, 173]), + ("lightpink3", [205, 140, 149]), + ("lightpink4", [139, 95, 101]), + ("lightsalmon", [255, 160, 122]), + ("lightsalmon1", [255, 160, 122]), + ("lightsalmon2", [238, 149, 114]), + ("lightsalmon3", [205, 129, 98]), + ("lightsalmon4", [139, 87, 66]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightskyblue1", [176, 226, 255]), + ("lightskyblue2", [164, 211, 238]), + ("lightskyblue3", [141, 182, 205]), + ("lightskyblue4", [96, 123, 139]), + ("lightslateblue", [132, 112, 255]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightsteelblue1", [202, 225, 255]), + ("lightsteelblue2", [188, 210, 238]), + ("lightsteelblue3", [162, 181, 205]), + ("lightsteelblue4", [110, 123, 139]), + ("lightyellow", [255, 255, 224]), + ("lightyellow1", [255, 255, 224]), + ("lightyellow2", [238, 238, 209]), + ("lightyellow3", [205, 205, 180]), + ("lightyellow4", [139, 139, 122]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("magenta1", [255, 0, 255]), + ("magenta2", [238, 0, 238]), + ("magenta3", [205, 0, 205]), + ("magenta4", [139, 0, 139]), + ("maroon", [176, 48, 96]), + ("maroon1", [255, 52, 179]), + ("maroon2", [238, 48, 167]), + ("maroon3", [205, 41, 144]), + ("maroon4", [139, 28, 98]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumorchid1", [224, 102, 255]), + ("mediumorchid2", [209, 95, 238]), + ("mediumorchid3", [180, 82, 205]), + ("mediumorchid4", [122, 55, 139]), + ("mediumpurple", [147, 112, 219]), + ("mediumpurple1", [171, 130, 255]), + ("mediumpurple2", [159, 121, 238]), + ("mediumpurple3", [137, 104, 205]), + ("mediumpurple4", [93, 71, 139]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("mistyrose1", [255, 228, 225]), + ("mistyrose2", [238, 213, 210]), + ("mistyrose3", [205, 183, 181]), + ("mistyrose4", [139, 125, 123]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navajowhite1", [255, 222, 173]), + ("navajowhite2", [238, 207, 161]), + ("navajowhite3", [205, 179, 139]), + ("navajowhite4", [139, 121, 94]), + ("navy", [0, 0, 128]), + ("navyblue", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olivedrab", [107, 142, 35]), + ("olivedrab1", [192, 255, 62]), + ("olivedrab2", [179, 238, 58]), + ("olivedrab3", [154, 205, 50]), + ("olivedrab4", [105, 139, 34]), + ("orange", [255, 165, 0]), + ("orange1", [255, 165, 0]), + ("orange2", [238, 154, 0]), + ("orange3", [205, 133, 0]), + ("orange4", [139, 90, 0]), + ("orangered", [255, 69, 0]), + ("orangered1", [255, 69, 0]), + ("orangered2", [238, 64, 0]), + ("orangered3", [205, 55, 0]), + ("orangered4", [139, 37, 0]), + ("orchid", [218, 112, 214]), + ("orchid1", [255, 131, 250]), + ("orchid2", [238, 122, 233]), + ("orchid3", [205, 105, 201]), + ("orchid4", [139, 71, 137]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("palegreen1", [154, 255, 154]), + ("palegreen2", [144, 238, 144]), + ("palegreen3", [124, 205, 124]), + ("palegreen4", [84, 139, 84]), + ("paleturquoise", [175, 238, 238]), + ("paleturquoise1", [187, 255, 255]), + ("paleturquoise2", [174, 238, 238]), + ("paleturquoise3", [150, 205, 205]), + ("paleturquoise4", [102, 139, 139]), + ("palevioletred", [219, 112, 147]), + ("palevioletred1", [255, 130, 171]), + ("palevioletred2", [238, 121, 159]), + ("palevioletred3", [205, 104, 137]), + ("palevioletred4", [139, 71, 93]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peachpuff1", [255, 218, 185]), + ("peachpuff2", [238, 203, 173]), + ("peachpuff3", [205, 175, 149]), + ("peachpuff4", [139, 119, 101]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("pink1", [255, 181, 197]), + ("pink2", [238, 169, 184]), + ("pink3", [205, 145, 158]), + ("pink4", [139, 99, 108]), + ("plum", [221, 160, 221]), + ("plum1", [255, 187, 255]), + ("plum2", [238, 174, 238]), + ("plum3", [205, 150, 205]), + ("plum4", [139, 102, 139]), + ("powderblue", [176, 224, 230]), + ("purple", [160, 32, 240]), + ("purple1", [155, 48, 255]), + ("purple2", [145, 44, 238]), + ("purple3", [125, 38, 205]), + ("purple4", [85, 26, 139]), + ("red", [255, 0, 0]), + ("red1", [255, 0, 0]), + ("red2", [238, 0, 0]), + ("red3", [205, 0, 0]), + ("red4", [139, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("rosybrown1", [255, 193, 193]), + ("rosybrown2", [238, 180, 180]), + ("rosybrown3", [205, 155, 155]), + ("rosybrown4", [139, 105, 105]), + ("royalblue", [65, 105, 225]), + ("royalblue1", [72, 118, 255]), + ("royalblue2", [67, 110, 238]), + ("royalblue3", [58, 95, 205]), + ("royalblue4", [39, 64, 139]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("salmon1", [255, 140, 105]), + ("salmon2", [238, 130, 98]), + ("salmon3", [205, 112, 84]), + ("salmon4", [139, 76, 57]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seagreen1", [84, 255, 159]), + ("seagreen2", [78, 238, 148]), + ("seagreen3", [67, 205, 128]), + ("seagreen4", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("seashell1", [255, 245, 238]), + ("seashell2", [238, 229, 222]), + ("seashell3", [205, 197, 191]), + ("seashell4", [139, 134, 130]), + ("sienna", [160, 82, 45]), + ("sienna1", [255, 130, 71]), + ("sienna2", [238, 121, 66]), + ("sienna3", [205, 104, 57]), + ("sienna4", [139, 71, 38]), + ("skyblue", [135, 206, 235]), + ("skyblue1", [135, 206, 255]), + ("skyblue2", [126, 192, 238]), + ("skyblue3", [108, 166, 205]), + ("skyblue4", [74, 112, 139]), + ("slateblue", [106, 90, 205]), + ("slateblue1", [131, 111, 255]), + ("slateblue2", [122, 103, 238]), + ("slateblue3", [105, 89, 205]), + ("slateblue4", [71, 60, 139]), + ("slategray", [112, 128, 144]), + ("slategray1", [198, 226, 255]), + ("slategray2", [185, 211, 238]), + ("slategray3", [159, 182, 205]), + ("slategray4", [108, 123, 139]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("snow1", [255, 250, 250]), + ("snow2", [238, 233, 233]), + ("snow3", [205, 201, 201]), + ("snow4", [139, 137, 137]), + ("springgreen", [0, 255, 127]), + ("springgreen1", [0, 255, 127]), + ("springgreen2", [0, 238, 118]), + ("springgreen3", [0, 205, 102]), + ("springgreen4", [0, 139, 69]), + ("steelblue", [70, 130, 180]), + ("steelblue1", [99, 184, 255]), + ("steelblue2", [92, 172, 238]), + ("steelblue3", [79, 148, 205]), + ("steelblue4", [54, 100, 139]), + ("tan", [210, 180, 140]), + ("tan1", [255, 165, 79]), + ("tan2", [238, 154, 73]), + ("tan3", [205, 133, 63]), + ("tan4", [139, 90, 43]), + ("thistle", [216, 191, 216]), + ("thistle1", [255, 225, 255]), + ("thistle2", [238, 210, 238]), + ("thistle3", [205, 181, 205]), + ("thistle4", [139, 123, 139]), + ("tomato", [255, 99, 71]), + ("tomato1", [255, 99, 71]), + ("tomato2", [238, 92, 66]), + ("tomato3", [205, 79, 57]), + ("tomato4", [139, 54, 38]), + ("turquoise", [64, 224, 208]), + ("turquoise1", [0, 245, 255]), + ("turquoise2", [0, 229, 238]), + ("turquoise3", [0, 197, 205]), + ("turquoise4", [0, 134, 139]), + ("violet", [238, 130, 238]), + ("violetred", [208, 32, 144]), + ("violetred1", [255, 62, 150]), + ("violetred2", [238, 58, 140]), + ("violetred3", [205, 50, 120]), + ("violetred4", [139, 34, 82]), + ("wheat", [245, 222, 179]), + ("wheat1", [255, 231, 186]), + ("wheat2", [238, 216, 174]), + ("wheat3", [205, 186, 150]), + ("wheat4", [139, 126, 102]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellow1", [255, 255, 0]), + ("yellow2", [238, 238, 0]), + ("yellow3", [205, 205, 0]), + ("yellow4", [139, 139, 0]), + ("yellowgreen", [154, 205, 50]), +]; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index 4a76c14458..ac5fb39487 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -17,6 +17,8 @@ pub enum Format { Pnm, /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. Pcx, + /// X PixMap icons (XPM3 C-string representation). + Xpm, Qoi, OpenExr, /// Radiance RGBE. @@ -60,6 +62,7 @@ pub struct FormatCaps { /// Formats that are enabled but intentionally not symmetric, with the reason. /// The test-suite asserts that every other enabled format is read+write. pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ + (Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."), ( Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", @@ -86,7 +89,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 17] = [ + pub const ALL: [Format; 18] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -97,6 +100,7 @@ impl Format { Format::Ico, Format::Pnm, Format::Pcx, + Format::Xpm, Format::Qoi, Format::OpenExr, Format::Hdr, @@ -128,6 +132,7 @@ impl Format { Format::Ico => "ICO", Format::Pnm => "Netpbm", Format::Pcx => "PCX", + Format::Xpm => "XPM", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", @@ -152,6 +157,7 @@ impl Format { Format::Ico => &["ico"], Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], Format::Pcx => &["pcx"], + Format::Xpm => &["xpm"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], @@ -174,6 +180,7 @@ impl Format { Format::Ico => "image/x-icon", Format::Pnm => "image/x-portable-anymap", Format::Pcx => "image/x-pcx", + Format::Xpm => "image/x-xpixmap", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", @@ -224,6 +231,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base }, Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, + Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -288,6 +296,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } + if b.starts_with(b"/* XPM */") { + return Some(Format::Xpm); + } if crate::codecs::pcx::looks_like_pcx(b) { return Some(Format::Pcx); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index e90b5235c4..3974929ee0 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp}; +use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -75,6 +75,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::WebP => webp::decode(bytes, l), Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), + Format::Xpm => xpm::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), @@ -225,6 +226,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result webp::encode(image, plan, opts), Format::Pnm => pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), + Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/xpm.rs new file mode 100644 index 0000000000..8727e5dbc3 --- /dev/null +++ b/crates/codecs/tests/xpm.rs @@ -0,0 +1,93 @@ +use photocraft_codecs::{ChannelLayout, CodecError, Format, Image, caps, decode, decode_as, detect, encode, from_extension}; + +const SAMPLE: &[u8] = br#"/* XPM */ +static char *icon[] = { +"2 2 3 1", +". c #ff0000", +" c None", +"+ c #00ff00", +".+", +" +" +}; +"#; + +#[test] +fn xpm3_decodes_palette_transparency_and_spaces_in_keys() { + assert_eq!(detect(SAMPLE), Some(Format::Xpm)); + assert_eq!(from_extension("icon.XPM"), Some(Format::Xpm)); + let img = decode(SAMPLE).unwrap(); + assert_eq!(img.layout(), ChannelLayout::Rgba); + assert_eq!(img.dimensions(), (2, 2)); + assert_eq!(img.data(), &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn rgb_precisions_and_x11_name() { + let xpm = br#"/* XPM */ +static char *i[] = { +"3 1 3 1", +"a c #f00", +"b c #ffff00000000", +"c c gray70", +"abc" +};"#; + let img = decode(xpm).unwrap(); + assert_eq!(img.data(), &[255, 0, 0, 255, 255, 0, 0, 255, 179, 179, 179, 255]); +} + +#[test] +fn rejects_truncation_unsupported_colours_and_unknown_keys() { + for bytes in [ + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c None\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c NotAColor\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #a\xC3\xA9\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c #+f0000\", \"a\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"z\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 1 1\", \"a c black\", \"aa\" };"[..], + &b"/* XPM */\nstatic char *i[] = { \"1 1 2 1\", \"a c black\", \"a c white\", \"a\" };"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{:?}", bytes); + } + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"4294967295 4294967295 1 1\"").is_err()); + assert!(decode_as(Format::Xpm, b"/* XPM */\n\"2 1 1 1\" \"a c black\" \"aa\" /* bad").is_err()); +} + +#[test] +fn x11_names_and_field_priority() { + let xpm = br#"/* XPM */ +static char *i[] = { +"4 1 4 1", +"a c LightGray", +"b c DarkBlue", +"c m black g white c light blue", +"d m black g4 red g green", +"abcd" +};"#; + let image = decode(xpm).unwrap(); + assert_eq!(image.data(), &[211, 211, 211, 255, 0, 0, 139, 255, 173, 216, 230, 255, 0, 255, 0, 255]); +} + +#[test] +fn named_x11_colors_use_x11_not_css_values() { + let bytes = br#"/* XPM */ +static char *icon[] = { +"6 1 6 1", +"a c gray", +"b c maroon", +"c c purple", +"d c LightGoldenrodYellow", +"e c DarkSlateGray4", +"f c Ghost White", +"abcdef" +};"#; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]); +} + +#[test] +fn xpm_is_import_only() { + assert!(caps(Format::Xpm).read); + assert!(!caps(Format::Xpm).write); + let image = Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&image, Format::Xpm, &Default::default()), Err(CodecError::Unsupported { .. }))); +} From 4a53fad835bb2e1b1afb9daccf6ef9aecb39368b Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:01:35 +0700 Subject: [PATCH 210/523] feat(codecs): add XPM2 plain-text image import support (#4264) * feat(codecs): import XPM3 images with transparency (#3692) * Correct XPM test expectation for green palette name * feat(codecs): decode XPM2 plain-text image files --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/xpm.rs | 41 ++++++++++++++++++++++++++++----- crates/codecs/src/format.rs | 4 ++-- crates/codecs/tests/xpm.rs | 30 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/codecs/src/codecs/xpm.rs b/crates/codecs/src/codecs/xpm.rs index 36062f04db..82543d8867 100644 --- a/crates/codecs/src/codecs/xpm.rs +++ b/crates/codecs/src/codecs/xpm.rs @@ -1,5 +1,6 @@ -//! XPM3 reader for C-source icon arrays. Supports transparency (None), RGB hex -//! at 4/8/16 bits per channel, and common X11 colour names. Does not run C code. +//! X PixMap reader for XPM2 plain-text icons and XPM3 C-source icon arrays. +//! Supports transparency (None), RGB hex at 4/8/16 bits per channel and +//! common X11 colour names. Does not execute C code. use std::collections::HashMap; @@ -68,6 +69,27 @@ fn c_strings(bytes: &[u8]) -> Result>, CodecError> { Ok(lines) } +/// Extract XPM2's plain-text lines without trimming pixel rows: leading and +/// trailing spaces can be palette keys, and physical CRLF is not image data. +fn xpm2_lines(bytes: &[u8]) -> Result>, CodecError> { + let mut input = bytes.split(|&b| b == b'\n'); + let header = input.next().ok_or_else(|| bad("missing XPM2 marker"))?; + if header.strip_suffix(b"\r").unwrap_or(header) != b"! XPM2" { + return Err(bad("not an XPM2 image")); + } + let mut lines = Vec::new(); + for raw in input { + let line = raw.strip_suffix(b"\r").unwrap_or(raw); + // The header may be preceded by comments and blank lines. After the + // header, a leading '!' is allowed as a palette key or pixel value. + if lines.is_empty() && (line.is_empty() || line.starts_with(b"!")) { + continue; + } + lines.push(line.to_vec()); + } + Ok(lines) +} + fn channel(hex: &str) -> Option { let v = u32::from_str_radix(hex, 16).ok()?; let max = (1u32 << (hex.len() * 4)) - 1; @@ -117,13 +139,20 @@ fn color(name: &str) -> Option<[u8; 4]> { } pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result { - if !bytes.starts_with(b"/* XPM */") { - return Err(bad("not an XPM3 image")); - } if bytes.len() > MAX_SOURCE { return Err(CodecError::LimitExceeded("XPM source exceeds 256 MiB".into())); } - let lines = c_strings(bytes)?; + let lines = if bytes.starts_with(b"/* XPM */") { + c_strings(bytes)? + } else if bytes.starts_with(b"! XPM2\n") || bytes.starts_with(b"! XPM2\r\n") { + xpm2_lines(bytes)? + } else { + return Err(bad("not an XPM2/XPM3 image")); + }; + decode_lines(&lines, limits) +} + +fn decode_lines(lines: &[Vec], limits: &Limits) -> Result { let header = lines.first().ok_or_else(|| bad("missing image header"))?; let parts = std::str::from_utf8(header).map_err(|_| bad("non-UTF-8 XPM header"))?; let mut parts = parts.split_ascii_whitespace(); diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index ac5fb39487..e1e0927fad 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -17,7 +17,7 @@ pub enum Format { Pnm, /// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB. Pcx, - /// X PixMap icons (XPM3 C-string representation). + /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, Qoi, OpenExr, @@ -296,7 +296,7 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"SIMPLE =") { return Some(Format::Fits); } - if b.starts_with(b"/* XPM */") { + if b.starts_with(b"/* XPM */") || b.starts_with(b"! XPM2\n") || b.starts_with(b"! XPM2\r\n") { return Some(Format::Xpm); } if crate::codecs::pcx::looks_like_pcx(b) { diff --git a/crates/codecs/tests/xpm.rs b/crates/codecs/tests/xpm.rs index 8727e5dbc3..aa0b3bf0b0 100644 --- a/crates/codecs/tests/xpm.rs +++ b/crates/codecs/tests/xpm.rs @@ -84,6 +84,36 @@ static char *icon[] = { assert_eq!(image.data(), &[190, 190, 190, 255, 176, 48, 96, 255, 160, 32, 240, 255, 250, 250, 210, 255, 82, 139, 139, 255, 248, 248, 255, 255,]); } +#[test] +fn xpm2_decodes_crlf_comments_spaces_and_multichar_keys() { + let bytes = b"! XPM2\r\n! exported by an icon tool\r\n2 2 3 2\r\n.a c #f00\r\n!_ c None\r\nzz c DarkBlue\r\n.a!_\r\nzz.a\r\n"; + assert_eq!(detect(bytes), Some(Format::Xpm)); + let image = decode(bytes).unwrap(); + assert_eq!(image.dimensions(), (2, 2)); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 139, 255, 255, 0, 0, 255]); +} + +#[test] +fn xpm2_allows_exclamation_as_a_pixel_key_and_preserves_space_keys() { + let bytes = b"! XPM2\n2 1 2 1\n! c red\n c None\n! \n"; + let image = decode(bytes).unwrap(); + assert_eq!(image.data(), &[255, 0, 0, 255, 0, 0, 0, 0]); +} + +#[test] +fn xpm2_malformed_inputs_return_errors() { + for bytes in [ + &b"! XPM2\n1 1 1 1\na c black\n"[..], + &b"! XPM2\n1 1 1 1\na c black\nb\n"[..], + &b"! XPM2\n1 1 1 1\na c #a\xc3\xa9\na\n"[..], + &b"! XPM2 invalid\n1 1 1 1\na c black\na\n"[..], + &b"! XPM2\n100000 100000 1 1\na c red\na\n"[..], + ] { + assert!(decode_as(Format::Xpm, bytes).is_err(), "{bytes:?}"); + } + assert_ne!(detect(b"! XPM23\n1 1 1 1\na c black\na\n"), Some(Format::Xpm)); +} + #[test] fn xpm_is_import_only() { assert!(caps(Format::Xpm).read); From af9316b97e554eaf51e10ed85d23440e0eb184e3 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 02:03:29 +0200 Subject: [PATCH 211/523] Docs: refresh gaps.md against main (#4412) (#4413) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- docs/gaps.md | 46 ++++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index d505e8f447..870613ae30 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; stroke-quality and performance gaps unchanged; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -67,8 +67,9 @@ what an agent can't finish alone. ### G7. Workspaces shown as parameter dialogs - **Missing:** Select and Mask workspace layout (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area - (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the - transform box (TOOL-214-7); Content-Aware Move's Transform On Drop (TOOL-213-4). + (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Move's live preview + of the result (TOOL-213-4; Transform On Drop and Content-Aware Scale through the transform box, + TOOL-214-7, are done). - **Evidence:** scorecard rows above, all partial or missing. - **Impact:** these are the tools retouchers and compositors use daily; a slider dialog is not a substitute. @@ -108,7 +109,7 @@ what an agent can't finish alone. - **Missing:** CR3 (detected, unsupported), compressed RAF, lossy DNG (FILE-215-11), .xmp sidecars, masking and calibration in the Camera Raw dialog, per-camera colour for non-DNG cameras, lens profiles. -- **Evidence:** `crates/raw/src/lib.rs:219` (CR3), scorecard FILE-215-11/12, #50. +- **Evidence:** `crates/raw/src/lib.rs:228` (CR3), scorecard FILE-215-11/12, #50. - **Progress (2026-10-11):** on the embedded-JPEG rendering work in #2135, Camera Raw tuning retains the fitted look and applies user gains afterwards. Three D5100 NEFs establish a guarded 4928 × 3264 image area. This is an estimated rendering, not a @@ -153,23 +154,24 @@ what an agent can't finish alone. - **Estimate:** 100–180 h. **Human:** checking feel on real tablets. ### G5. Transform, handles and modifiers -- **Missing:** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the - options bar (#2442); H/V skew fields and the full interpolation list; transform keeps layer - order and blend mode in its preview (#2439); Hand tool interrupting a transform (#2302); - blend mode/opacity edits during a transform (#1384); locked layers refused before the drag - (#2585); ⌘-drag temporary Move without box or snapping (#2681), Auto-Select box follows the - previous layer (#2666); ⌘-arrow layer nudge (#2474); path-point nudge; polygonal lasso Shift - angles (#2352); autoscroll at the edge while selecting (#2231); hold-a-key spring-loaded tools - (#2672); double-click Zoom tool to 100% (#2508); wheel and pinch behaviour (#2485, #2463, - #1989); toolbar press-and-hold flyouts (#2301); tool-key memory in a group (#2608). +- **Missing:** H/V skew fields and the full interpolation list; blend mode/opacity edits during + a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); + hold-a-key spring-loaded tools (#2672); wheel and pinch behaviour (#2485, #2463, #1989); + toolbar press-and-hold flyouts (#2301). +- **Done since the re-measure (2026-10-10 to 12):** right-click menu during Free Transform + (TOOL-214-5); commit/cancel buttons in the options bar (#2442); transform keeps the layer's + place in the stack (#2439, #2480); Hand tool no longer interrupts a transform (#2302, #2327); + locked layers refused before the drag (#2585, #2609); ⌘-drag temporary Move with box and + snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow + layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool + to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace -- **Missing:** 46 of 149 preferences do nothing (scorecard prefs audit); panel groups can't +- **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and - slider modes (UI-217-17, #2569); UI scale presets (UI-217-9, #2492); more themes (UI-217-11, - #2661); Stamp Visible (UI-217-22); layer clipboard + slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard (UI-217-23, #2670); tool-specific context menus beyond brushes, selections and Pen ([context-menu-parity.md](context-menu-parity.md)); Libraries, Comments, Contextual Task Bar panels. @@ -186,8 +188,7 @@ what an agent can't finish alone. loaded machine): layer move with styles 321 ms p50 against 25 ms, opacity change 276 ms against 20 ms, paste 12 MP 1.2 s against 100 ms, layer move on 15000×10000 16-bit 2.7 s, Content-Aware Scale 24 MP 954 s against 3 s, Select Subject 559 ms against 100 ms; the - 150-layer nudge crashes the GPU compositor (P12); a 50 MP PSB with feathered masks redraws in - ~10 s (#2377); menus slow with thousands of layers (#2460); old machines lag (#2687). + 150-layer nudge crashes the GPU compositor (P12); menus slow with thousands of layers (#2460); old machines lag (#2687). - **Estimate:** 200–350 h. Serial in places (compositor architecture). ## File-format gaps @@ -197,7 +198,7 @@ what an agent can't finish alone. (FILE-215-10, #2541), AVIF read (#463) and default-build AVIF write, JPEG XL, JPEG 2000 (#1047), Cineon (DPX landed, #1049), DICOM, EPS/DCS, PCX, Pixar, IFF, WBMP, MPO/JPS, PICT, Scitex CT, Photoshop Raw; layered TIFF extra alpha channels (FILE-215-7); spot plates (FILE-215-17); .atn, .asl, - .shc, .csh presets (FILE-215-15); TGA/PNG alpha channels on open (#2225); C2PA (#1050). + .shc, .csh presets (FILE-215-15); C2PA (#1050). - **Detail:** [`file-format-parity.md`](file-format-parity.md). - **Estimate:** 120–220 h. @@ -216,7 +217,7 @@ what an agent can't finish alone. ### G13. Hindi, Arabic, Vietnamese, RTL and engine messages - **Missing:** catalogs for Hindi, Arabic and Vietnamese (3 of the 12 key languages); right-to- - left UI layout; Devanagari/Thai/Khmer fallback fonts; engine error and status messages in + left UI layout; Devanagari fallback fonts (Thai #1909 and Khmer, Lao and Myanmar #3259 landed); engine error and status messages in English in every language; browser-locale detection and CJK fonts on the web; Japanese IME on Windows (#590); installed Japanese fonts missing from the font list (#2330); Arabic layer names as tofu (#1408, #1693). @@ -228,8 +229,8 @@ what an agent can't finish alone. ## Platform gaps ### G17. Platforms and distribution -- **Missing:** Windows code signing (material not obtained), Windows ARM64 (DIST-220-2), in-app - update check (DIST-220-1, #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), +- **Missing:** Windows code signing (material not obtained), in-app update check (DIST-220-1, + #2650, #2375), in-app bug report (DIST-220-3), Flathub (#173), starting without a GPU adapter (#2519, #2412), Windows 7 (#2310, out of scope), Intel Macs with OpenCore (#1859). - **Estimate:** 30–60 h. **Human:** signing certificates. @@ -241,6 +242,7 @@ what an agent can't finish alone. | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | +| 2026-10-12 | minor | Refreshed against main: G5 items fixed by #2480, #2327, #2609, #2684, #2675, #2556, #2396, #2527, #2625 and TOOL-214-5/#2442; G7 TOOL-214-7 and Transform On Drop done; G2 #2377 fixed (#2417); G9 UI scale (UI-217-9) and Stamp Visible (UI-217-22) done, prefs 27 of 156; G10 #2225 done (#2482); G13 Khmer, Lao, Myanmar fallback (#3259); G14 CR3 line; G17 Windows ARM64 done (DIST-220-2) | | 2026-10-11 | minor | D5100 image area and Camera Raw tuning validation; G14 remains open | | 2026-10-11 | minor | G13: Czech dynamic UI text localized; engine messages and native review remain open | | 2026-10-11 | minor | G8: triple/quadruple/five-click type selection done (TYPE-218-8, #3493); Type evidence recounted | From 13657f35f275e181586151aad040a8a1df4789bc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 02:04:19 +0200 Subject: [PATCH 212/523] Docs: refresh target-app-parity and file-format-parity against main (#4417) (#4418) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- docs/file-format-parity.md | 6 ++++-- docs/target-app-parity.md | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0aa2984e23..12c6dc9d1d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -132,7 +132,8 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | ICC profiles | R | R | Own CMS | | Actions `.atn` | R/W | R/W | `psd/src/atn.rs` (version 16 sets, exact round trip), `engine/src/atn_cmds.rs` (108 Photoshop events ↔ commands; other steps kept verbatim). Panel menu, CLI `batch --actions set.atn`, `actions.load`/`actions.save`. Photoshop 5-era version 12 files unsupported (AUTO-219-2) | | Styles `.asl` | R/W | no | #1693 | -| Contours `.shc`, custom shapes `.csh`, tool presets `.tpl` | R/W | no | FILE-215-15 | +| Contours `.shc` | R/W | R/W | `psd/src/shc.rs`; Contour Editor Load…/Save… (FILE-215-15). No user contour library; not yet checked against a Photoshop-written file | +| Custom shapes `.csh`, tool presets `.tpl` | R/W | no | | | Curves `.acv`, Levels `.alv`, other adjustment presets | R/W | not audited | | | C2PA Content Credentials | R/W | no | #1050 | @@ -142,6 +143,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. |---|---|---| | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | +| 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | | 2026-10-12 | minor | Palette BMPs open in Indexed Color mode (#3802) | | 2026-10-12 | minor | Save writes a JPEG at its own estimated quality and a WebP lossy or lossless as opened (#2399) | | 2026-10-11 | minor | D5100 image area and fitted-look preservation in Camera Raw tuning | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5e8dd8a04..1322980b8b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -49,7 +49,7 @@ architecture and the PSD writer are the serial parts. `Locales/` language packs, `Default Keyboard Shortcuts.kys` (67 toolbox tools, 107 menu commands with default shortcuts), `Required/` plug-ins (85 filter entries, 12 format plug-ins), presets, and strings in the main binary (81 "… Tool" titles; HDR/EDR, Metal, Touch Bar, pen features). -- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items) was +- PhotoCraft's own Photoshop menu tree (`crates/ui-egui/src/menu_catalog.rs`, 628 items on 2026-10-10, 631 on 2026-10-12) was written from Photoshop's menus; it is the denominator of the menu number. Photoshop's binary `Default Menus.mnu` was not parsed, so items added in 27.x that the catalog lacks would not show (Generative and cloud items are the likely ones). @@ -60,7 +60,7 @@ architecture and the PSD writer are the serial parts. | Component | Weight | Ours / Photoshop | % | Kind | |---|---:|---|---:|---| -| Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | +| Menu commands | 30% | 631 / 631 catalog items dispatch a live command (628 / 628 on 2026-10-10) | 100% | measured (`cargo xtask parity`) | | Toolbox tools | 15% | 59 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 87% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | | Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | @@ -76,13 +76,13 @@ Weights reflect what a working Photoshop user depends on (photographers, retouch | Dimension | Weight | % ready | Remaining (agent-h) | Evidence | Detail | |---|---:|---:|---:|---|---| | Features in depth (tools, layers, selections, type, filters, transforms) | 25% | 55% | 600–1,000 | Scorecard checklists: Tools 20 done / 15 partial / 6 missing, Type 0 / 3 / 6, Automation 3 / 2 / 6. 14 tools missing; Select and Mask, Vanishing Point and Content-Aware Fill are parameter dialogs, not workspaces; type on a path, manual kerning, RTL paragraphs missing | [gaps.md](gaps.md) | -| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 5 / 7 / 11; 46 of 149 preferences do nothing; no floating panel groups, thin Color panel, no transform context menu, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | +| UI / UX fidelity and feel | 15% | 40% | 350–600 | ~150 open UI issues; Workspace checklist 13 / 5 / 5 (2026-10-12; 5 / 7 / 11 on 2026-10-10); 27 of 156 preferences do nothing (was 46 of 149); no floating panel groups, thin Color panel, brush lag and pressure jumps reported | [ui-parity.md](ui-parity.md), [brush-parity.md](brush-parity.md) | | File formats and fidelity | 15% | 60% | 300–500 | PSD oracle on Photoshop-authored files 133/256 (52%); io corpus 146/170 (86%); psd-tools 236/309 (76%); round trips ~100% of corpus; but users report Photoshop refusing our files (#1281, #2469; a text-layer case, #2374, was fixed 2026-10-10); no Photoshop PDF, EPS, JPEG 2000, JPEG XL, AVIF read, CR3 | [file-format-parity.md](file-format-parity.md) | | Performance | 10% | 25% | 200–350 | 3 of 25 budgeted scenarios meet budget (baseline 2026-10-05); 150-layer nudge crashes the GPU path (P12); layer move on 15000×10000 16-bit 2.7 s p50; users report brush and cursor lag (#2619, #2566, #1994) | [scorecard.md](scorecard.md#performance) | | Stability | 10% | 45% | 150–250 | Never-crash lints on 28/28 crates, `panic_hunt` in the gate, atomic saves; but field reports of freezes (#2518 Type tool 60 s on macOS), GPU panics (#2020, #1215), no-GPU start failures (#2519, #2412) | [gaps.md](gaps.md) | | Hardware | 5% | 50% | 80–150 | GPU compositor (wgpu), pen pressure on all desktops but tilt/rotation not on Windows, Wayland pen broken (#2622), no HDR/EDR output, monitor profile auto-detected on macOS only | [hardware-parity.md](hardware-parity.md) | -| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi, Arabic, Vietnamese absent; no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | -| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; no Windows ARM64, no in-app updates | [scorecard.md](scorecard.md#distribution) | +| Localization | 5% | 55% | 80–140 | 7 of the 12 key languages at 100% of measured UI keys; Hindi and Arabic absent, Vietnamese catalog added 2026-10-12 (#2636, coverage not re-measured); no RTL UI; engine messages English | [localization-parity.md](localization-parity.md) | +| Platforms | 5% | 80% | 30–60 | macOS (notarized), Windows (unsigned), Linux (AppImage/deb/rpm/Flatpak), web, FreeBSD partial; Windows ARM64 is packaged and install-checked by `.github/workflows/windows-arm64.yml` (DIST-220-2); no in-app updates | [scorecard.md](scorecard.md#distribution) | | Ecosystem (plug-ins, scripting, presets, libraries) | 5% | 15% | 250–450 | No .8BF hosting (#2647), no UXP/ExtendScript; .atn load/save (2026-10-12, 108 events, the rest kept verbatim), .abr/.grd/.pat/.aco/.ase/.cube supported | [gaps.md](gaps.md) | | AI features | 5% | 10% | 300–600 | No ML runtime at all (scorecard AUTO-219-7); classical substitutes only. Needs an owner decision and openly licensed models (#41) | [gaps.md](gaps.md) | | **Weighted** | 100% | **~46%** | **2,340–4,100** | | | @@ -114,7 +114,7 @@ Discounts for what still stops real work (estimated, each with evidence): | Discount | Factor | Evidence | |---|---:|---| -| Interaction fidelity | ×0.92 | ~150 open UI issues; transform context menu, spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | +| Interaction fidelity | ×0.92 | ~150 open UI issues; spring-loaded tools, panel tear-off missing ([ui-parity.md](ui-parity.md)); brush lag and pressure reports ([brush-parity.md](brush-parity.md)) | | Stability and speed on real machines | ×0.88 | 3 of 25 performance budgets met; 150-layer GPU crash (P12); Type tool freeze (#2518); GPU start failures (#2519, #2412, #2020); 41 open performance issues | | Exchanging files with Photoshop users | ×0.92 | Photoshop-authored oracle 133/256; exports refused by Photoshop (#1281, #2469); smart-filter re-render 5/30 | @@ -144,7 +144,7 @@ workflows, PSD exchange edge cases, and everything excluded above. | Layers (add, reorder, hide, opacity) | 12% | 80% | | | New / open a photo | 10% | 90% | | | Select (marquee, lasso, magic wand, object) | 10% | 75% | | -| Move and Free Transform | 10% | 70% | no commit/cancel buttons (#2442) | +| Move and Free Transform | 10% | 70% | commit and cancel buttons in the options bar (#2442, closed) | | Brush and eraser | 10% | 70% | lag with big brushes (#2619) | | Undo, History | 8% | 90% | | | Basic adjustments (brightness, levels, curves, hue/saturation) | 8% | 80% | | @@ -167,14 +167,14 @@ parity for the area and are already contained in the dimension rows above. | Area | % | Hours | Notes | |---|---:|---:|---| -| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: multi-layer Smart Object conversion (#2604, #2648), layer clipboard between documents (UI-217-23), Stamp Visible, contour editor, smart-object perspective (FILE-215-6), mask targeting by command (#2458) | -| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a generic dialog (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | +| Layers, groups, masks, blend modes, smart objects, layer styles | 65% | 150–250 | Broad and GPU-accelerated. Open: layer clipboard between documents (UI-217-23: `layer.copyToDocument` exists, no copy/paste), smart-object perspective (FILE-215-6), a user contour library (FILE-215-15). Multi-layer Smart Object conversion (#2052, #2651), Stamp Visible (UI-217-22) and mask targeting by command (#2458) are done | +| Selections, Select and Mask, channels | 55% | 120–200 | All selection tools, single row/column marquee included (#2837). Select and Mask is a dialog with Photoshop's view modes, a Refine Edge Brush and six outputs, not a workspace (TOOL-214-1), no Refine Hair (#2671); Select Subject/Sky classical, 559 ms p50 on 24 MP vs a 100 ms budget | | Painting and brush engine | 55% | 150–250 | Every Brush Settings section exists; feel is the gap: lag on big brushes, pressure jumps, smoothing and edge quality reports ([brush-parity.md](brush-parity.md)) | | Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 | Remove tool non-generative; Content-Aware Fill has no workspace (TOOL-214-15); live stroke preview only for Brush/Eraser (TOOL-213-7) | -| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | +| Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5); no skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); missing on-canvas corner radius (TOOL-214-12), path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (108 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -564,6 +564,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| | 2026-10-12 | minor | Photoshop Actions `.atn` load and save (90 events mapped both ways); percentages unchanged | +| 2026-10-12 | minor | Rows checked against `main` and the regenerated scorecard: menu count 631, Workspace checklist and preference count, transform context menu, Puppet Warp mesh, Select and Mask, anchor-point tools, Smart Object conversion, Stamp Visible, mask targeting, Free Transform commit/cancel, Vietnamese catalog, Windows ARM64; percentages, weights and hours not re-measured | | 2026-10-10 | minor | Added measured Tilt-Shift computation/filtered-proxy gains and inconclusive cases; parity percentages and budgets unchanged | | 2026-10-10 | minor | Readiness-by-audience table: percent and hours for full, mainstream and essentials; full number confirmed as the additive weighted sum over dimensions (46%, reported ~45%) | | 2026-10-10 | minor | Added mainstream-practitioner (~43%) and essentials-user (~61%) numbers with written weights and discounts; full ready-for-real-work rechecked against its written weights (46%, reported ~45%), unchanged | From 776b9dddd089333a8c10f2f8e6be6b9fbaf23cd2 Mon Sep 17 00:00:00 2001 From: xsvm <2479667226@qq.com> Date: Tue, 13 Oct 2026 08:05:09 +0800 Subject: [PATCH 213/523] Clone Stamp and History Brush: releasing a stroke commits the live pixels (#4019) Releasing a Clone Stamp or History Brush stroke rendered the whole stroke again, although the live stroke had already drawn exactly those pixels. The canvas now offers the live stroke to its commit as the Brush does (#2131), through the same LiveCommit: a commit with the same command, document revision, params and points takes the live surface and reports fromLive; anything else renders as before. LiveRetouch renders only the position and pressure of its points, which is all the commands parse, and the offer is refused when the brush, layer or clone source changed during the stroke (the revision doesn't cover them). The Healing Brush, which blends on release, is left out. --- crates/engine/src/brush_cmds.rs | 28 ++--- crates/engine/src/lib.rs | 9 ++ crates/engine/src/retouch_cmds.rs | 71 ++++++++++++- crates/engine/src/retouch_cmds/tests.rs | 122 ++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 30 +++++- crates/ui-egui/src/stroke_timing_tests.rs | 46 ++++++++ 6 files changed, 287 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 321973b8d2..e66e18b94e 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -437,17 +437,19 @@ pub struct LiveStroke { base: (photocraft_doc::DocId, u64), } -/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`]): when the -/// committing `paint.stroke` / `paint.pencil` matches it (same command, document revision, params, -/// seed and points), the command takes these pixels instead of rendering the stroke again. +/// A finished live stroke offered to its own commit ([`Session::offer_live_stroke`], +/// [`Session::offer_live_retouch`]): when the committing command matches it (same command, +/// document revision, params, seed and points), it takes these pixels instead of rendering the +/// stroke again. pub struct LiveCommit { - cmd: String, - base: (photocraft_doc::DocId, u64), - params: Value, - seed: u64, - points: Vec, - surface: Surface, - bounds: Rect, + pub(crate) cmd: String, + pub(crate) base: (photocraft_doc::DocId, u64), + pub(crate) params: Value, + /// The Brush's and Pencil's seed; the retouching tools have none. + pub(crate) seed: Option, + pub(crate) points: Vec, + pub(crate) surface: Surface, + pub(crate) bounds: Rect, } impl LiveCommit { @@ -461,11 +463,11 @@ impl LiveCommit { p } - fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { + pub(crate) fn matches(&self, s: &Session, cmd: &str, p: &Value, pts: &[StrokePoint]) -> bool { let base = s.active().map(|st| (st.doc.id, st.revision)); self.cmd == cmd && base == Some(self.base) - && p.get("seed").and_then(Value::as_u64) == Some(self.seed) + && p.get("seed").and_then(Value::as_u64) == self.seed && self.points == pts && Self::settings(p) == Self::settings(&self.params) } @@ -531,7 +533,7 @@ impl LiveStroke { pub fn into_commit(mut self) -> Result { let bounds = self.bounds(); let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.layer, &self.params)?; - Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: self.seed, points: self.points, surface: surf.clone(), bounds }) + Ok(LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: Some(self.seed), points: self.points, surface: surf.clone(), bounds }) } /// Everything the stroke has touched so far. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27a2f941a8..45cf3418fe 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -567,6 +567,15 @@ impl Session { Ok(()) } + /// [`Self::offer_live_stroke`] for a live Clone Stamp or History Brush stroke, taken by the + /// `paint.cloneStamp` / `paint.historyBrush` it was drawn for. Refused for the Healing Brush + /// (it blends on release) and when the brush, layer or clone source changed during the stroke. + pub fn offer_live_retouch(&mut self, live: retouch_cmds::LiveRetouch) -> Result<()> { + let commit = live.into_commit(self)?; + self.live_commit = Some(commit); + Ok(()) + } + /// Withdraw an offer the command didn't take. pub fn clear_live_stroke(&mut self) { self.live_commit = None; diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 833e2db62e..cba7a22976 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -297,15 +297,27 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let which = sample_layers(p, CMD)?; let mode = blend_param(p, CMD)?; let aligned = flag(p, "aligned", true); + // The live stroke the canvas already drew for exactly this commit: take its pixels instead of + // rendering the stroke again (the hitch on release). + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let (bounds, cov) = stroke_coverage(&stroke); // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?; Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(clone_result(dmg, off, aligned, p, &stroke)) + let mut r = clone_result(dmg, off, aligned, p, &stroke); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } /// What a live retouching stroke paints through its coverage. @@ -337,6 +349,11 @@ pub struct LiveRetouch { /// Where the doc shows what finishing the stroke now would add (a lone first dab, the /// smoothing catch-up), redrawn on every step as the Brush's live stroke does. tail: Rect, + /// The document and revision the stroke started on. + base: (photocraft_doc::DocId, u64), + brush: Box, + /// Every point rendered so far, in order. + points: Vec, } impl LiveRetouch { @@ -358,7 +375,8 @@ impl LiveRetouch { "paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?), other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")), }; - let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); + let st = s.active().ok_or(EngineError::NoDocument)?; + let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision)); let mut doc = (*pre).clone(); let sel = doc.selection.clone(); let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?; @@ -366,6 +384,7 @@ impl LiveRetouch { // As `stroke_coverage`, which the commit uses. let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); let opacity = stroke.brush.opacity; + let brush = Box::new(stroke.brush.clone()); let paint = match paint { LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())), other => other, @@ -384,6 +403,9 @@ impl LiveRetouch { sel, lock, tail: Rect::EMPTY, + base, + brush, + points: Vec::new(), }; live.push(&stroke.points)?; Ok(live) @@ -394,15 +416,46 @@ impl LiveRetouch { self.renderer.bounds().union(&self.tail) } + /// Every point rendered so far, in order. + pub fn points(&self) -> &[StrokePoint] { + &self.points + } + + /// The finished stroke as its commit would take it over ([`Session::offer_live_retouch`]). + /// Only the Clone Stamp and History Brush draw what they commit (the Healing Brush blends on + /// release), and only while the brush, layer and clone source are still the ones the stroke + /// started with: the document revision doesn't cover those. + pub(crate) fn into_commit(mut self, s: &Session) -> Result { + let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?; + let source_unchanged = match &self.paint { + LivePaint::Clone { map, .. } if self.cmd == "paint.cloneStamp" => { + let f = stroke.points.first().ok_or_else(|| bad(&self.cmd, "`points` is empty"))?; + crate::presets::clone_source::resolve(s, &self.params, (f.x, f.y), &self.cmd)?.0 == *map + } + LivePaint::History(_) if self.cmd == "paint.historyBrush" => true, + _ => return Err(bad(&self.cmd, "only Clone Stamp and History Brush strokes commit their live pixels")), + }; + if !source_unchanged || *self.brush != stroke.brush || id != self.id { + return Err(bad(&self.cmd, "the brush, layer or clone source changed during the stroke")); + } + let bounds = self.bounds(); + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + let surface = surf.clone(); + Ok(crate::brush_cmds::LiveCommit { cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds }) + } + /// Render more points; returns the rectangle that changed. Each changed area is redrawn from /// the pre-stroke pixels with the stroke's coverage so far, so overlapping dabs build up as in /// the commit, and pixels painted earlier in the stroke are never re-cloned. What finishing /// the stroke now would add is drawn too (a lone first dab shows on the press), and redrawn on /// every step. /// Invalid coordinates reject the whole batch without changing the preview. + /// Only the position and pressure of `pts` are used: the commands take no tilt or times. pub fn push(&mut self, pts: &[StrokePoint]) -> Result { crate::brush_cmds::check_coords(pts, &self.cmd)?; - self.renderer.push(pts); + 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); let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; if !old.is_empty() { @@ -519,15 +572,25 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { const CMD: &str = "paint.historyBrush"; let (stroke, id) = parse_brush(s, p, CMD)?; let mode = blend_param(p, CMD)?; + let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points)); + let from_live = live.is_some(); let src_surface = history_source(s, id, p, CMD)?; let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); let paint = Region::read(&src, bounds); Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) })?; - Ok(json!({ "damage": damage_json(dmg) })) + let mut r = json!({ "damage": damage_json(dmg) }); + if from_live { + r["fromLive"] = json!(true); + } + Ok(r) } // --------------------------------------------------------------------------------------------- diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index a0fac91b2a..b566196738 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -1640,3 +1640,125 @@ fn remove_to_a_new_layer_leaves_the_target_untouched() { assert!(near(d.doc.layer(nid).unwrap().surface().unwrap().rgba(46, 30), bg, 0.03)); assert!(near(d.doc.layer(target).unwrap().surface().unwrap().rgba(46, 30), square, 0.001)); } + +/// A Clone Stamp or History Brush stroke drawn live and offered to its commit: the commit takes +/// the live pixels, and they are what running the command afresh paints, bit for bit, at every +/// depth, with and without a feathered selection. +#[test] +fn a_live_clone_or_history_stroke_commits_its_own_pixels() { + let all = [[30.0, 30.0], [50.0, 34.0], [70.0, 28.0], [90.0, 40.0], [60.0, 50.0], [35.0, 44.0], [36.0, 45.0]]; + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + for depth in DEPTHS { + for (cmd, extra) in [("paint.cloneStamp", json!({"source": [20, 15], "mode": "multiply"})), ("paint.historyBrush", json!({"state": 1}))] { + for selected in [false, true] { + let setup = || { + let mut s = session(120, 70, depth, "rgb"); + paint_layer(&mut s, texture); + s.execute("paint.stroke", json!({"points": [[0, 40], [120, 40]], "size": 20, "hardness": 1.0, "color": "#c03030"})).unwrap(); + if selected { + s.execute("select.rect", json!({"x": 40, "y": 20, "width": 50, "height": 40, "ellipse": true, "feather": 3})).unwrap(); + } + s + }; + let params = |v: &[[f64; 2]]| { + let mut p = json!({"points": v, "size": 14, "hardness": 40, "opacity": 70}); + for (k, x) in extra.as_object().unwrap() { + p[k] = x.clone(); + } + p + }; + let mut s = setup(); + let mut live = LiveRetouch::begin(&s, cmd, ¶ms(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..4])).unwrap(); + live.push(&pts(&all[4..])).unwrap(); + s.offer_live_retouch(live).unwrap(); + let r = s.execute(cmd, params(&all)).unwrap(); + s.clear_live_stroke(); + assert_eq!(r["fromLive"], true, "{cmd} {depth}-bit selected={selected}"); + let mut fresh = setup(); + let r = fresh.execute(cmd, params(&all)).unwrap(); + assert!(r.get("fromLive").is_none()); + let (a, b) = (s.active().unwrap(), fresh.active().unwrap()); + let bounds = a.doc.bounds(); + let got = a.doc.layer(a.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let want = b.doc.layer(b.active_layer.unwrap()).unwrap().surface().unwrap().read_region(bounds); + let diff = got.iter().zip(&want).position(|(x, y)| x.to_bits() != y.to_bits()); + assert!(diff.is_none(), "{cmd} {depth}-bit selected={selected}: first difference at value {diff:?}"); + assert_eq!(a.history.past_len(), b.history.past_len()); + } + } + } +} + +/// An offered live stroke is taken only by the commit it was drawn for, and only once. +#[test] +fn a_live_retouch_offer_is_taken_only_by_its_own_commit() { + let mut s = session(96, 64, 8, "rgb"); + paint_layer(&mut s, texture); + let all = [[50.0, 30.0], [60.0, 34.0], [70.0, 30.0]]; + let params = |v: &[[f64; 2]], opacity: u32| json!({"points": v, "source": [20, 20], "size": 12, "hardness": 50, "opacity": opacity}); + let offer = |s: &mut Session| { + let live = LiveRetouch::begin(s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.offer_live_retouch(live).unwrap(); + }; + let from_live = |r: Value| r.get("fromLive").is_some(); + // Other points, other params, another command: rendered afresh. + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all[..2], 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 60)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + let mut history = params(&all, 70); + history.as_object_mut().unwrap().remove("source"); + assert!(!from_live(s.execute("paint.historyBrush", history).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + // The document changed after the stroke started. + offer(&mut s); + s.execute("paint.stroke", json!({"points": [[5, 5], [9, 9]], "size": 4})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // A withdrawn offer, and an offer used once. + offer(&mut s); + s.clear_live_stroke(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + offer(&mut s); + assert!(from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!from_live(s.execute("paint.cloneStamp", params(&all, 70)).unwrap())); + // The Healing Brush blends on release: it has no live pixels to offer. + let healing = LiveRetouch::begin(&s, "paint.healingBrush", ¶ms(&all, 70)).unwrap(); + assert!(s.offer_live_retouch(healing).is_err()); + // The brush changed during the stroke (the document revision doesn't show it). + let live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms(&all, 70)).unwrap(); + s.execute("tools.setBrush", json!({"brush": {"spacing": 0.5}})).unwrap(); + assert!(s.offer_live_retouch(live).is_err()); +} + +/// The live stroke renders only the position and pressure of its points, as its commit parses +/// them: a pen's tilt and the times don't change what it shows. +#[test] +fn a_live_retouch_stroke_ignores_tilt_and_times() { + let mut s = session(64, 48, 8, "rgb"); + paint_layer(&mut s, texture); + let p = json!({"points": [[20, 20]], "source": [8, 8], "size": 10, "hardness": 50}); + let mut plain = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let mut pen = LiveRetouch::begin(&s, "paint.cloneStamp", &p).unwrap(); + let pts = [[30.0, 24.0], [44.0, 30.0]]; + plain.push(&pts.map(|q| StrokePoint::new(q[0], q[1], 0.8))).unwrap(); + pen.push(&pts.map(|q| { + let mut sp = StrokePoint::new(q[0], q[1], 0.8); + (sp.tilt_x, sp.tilt_y, sp.rotation, sp.time) = (40.0, -20.0, 90.0, 120.0); + sp + })) + .unwrap(); + assert_eq!(plain.points(), pen.points()); + let id = s.active().unwrap().active_layer.unwrap(); + let b = s.active().unwrap().doc.bounds(); + let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b); + assert_eq!(read(&plain), read(&pen)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index af3a11f04d..3c067c7565 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -680,6 +680,28 @@ fn feed_live_stroke(app: &mut PhotocraftApp) { } } +/// The points the live retouching stroke has rendered. +#[cfg(test)] +pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option> { + match &app.live_stroke.as_ref()?.stroke { + EngineStroke::Retouch(l) => Some(l.points().to_vec()), + _ => None, + } +} + +/// Offer the Clone Stamp's or History Brush's live stroke, with the drag points it hasn't rendered +/// yet, to the command `retouch_ui::finish_stroke` commits, which then takes its pixels. +fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) { + if !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) { + return; + } + let Some(LiveStroke { stroke: EngineStroke::Retouch(mut live), fed, .. }) = app.live_stroke.take() else { return }; + let rest: Vec<_> = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect(); + if rest.is_empty() || live.push(&rest).is_ok() { + let _ = app.session.offer_live_retouch(live); + } +} + /// A pen tap (a drag still at its one press point when released) paints its dab at the highest /// pressure the pen reached during the contact, not just the first sample's (#1798). A mouse is /// unchanged at full pressure. @@ -5164,11 +5186,15 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { { app.last_stroke_end = Some((st.doc.id, end)); } - // A live Clone Stamp preview ends here; the commit below replaces it. + // A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and + // History Brush's with the pixels it already drew). + offer_live_retouch(app, &d); if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { app.live_stroke = None; } - if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { + let finished = crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers); + app.session.clear_live_stroke(); + if finished { return; } match d.tool { diff --git a/crates/ui-egui/src/stroke_timing_tests.rs b/crates/ui-egui/src/stroke_timing_tests.rs index 5c369370ff..fc6d0c7e78 100644 --- a/crates/ui-egui/src/stroke_timing_tests.rs +++ b/crates/ui-egui/src/stroke_timing_tests.rs @@ -337,3 +337,49 @@ fn a_committed_stroke_refreshes_only_what_each_cache_had_not_shown() { assert_eq!(c.since(0), Some(r(0).union(&r(20)).union(&r(40))), "never showed the stroke: all of it"); assert_eq!(c.since(99), None, "not a key of this stroke"); } + +/// A Clone Stamp stroke through the canvas renders the points its commit gets (position and +/// pressure, no tilt or times), so the commit can take the live pixels; they are what replaying +/// the journaled command paints. +#[test] +fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() { + let setup = |run: &mut dyn FnMut(&str, Value)| { + run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 0.4}})); + run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"})); + run("cloneSource.set", json!({"source": [40, 30]})); + }; + let mut h = harness(); + setup(&mut |id, p| { + h.state_mut().run(id, p).unwrap(); + }); + h.state_mut().ui.tool = Tool::CloneStamp; + h.run_steps(1); + move_to(&mut h, 60.0, 50.0); + button(&mut h, 60.0, 50.0, true); + for (x, y) in [(80.0, 40.0), (110.0, 60.0), (140.0, 45.0), (170.0, 65.0)] { + move_to(&mut h, x, y); + } + let shown = crate::canvas::live_retouch_points(h.state()).expect("a live Clone Stamp stroke"); + button(&mut h, 170.0, 65.0, false); + h.run_steps(2); + let app = h.state(); + let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == "paint.cloneStamp").cloned().expect("a committed Clone Stamp stroke"); + let committed: Vec<_> = p["points"] + .as_array() + .unwrap() + .iter() + .map(|q| photocraft_engine::paint::StrokePoint::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap(), q[2].as_f64().unwrap() as f32)) + .collect(); + assert!(shown.len() > 1 && committed.starts_with(&shown), "live {shown:?} vs committed {committed:?}"); + let mut fresh = photocraft_engine::Session::new(); + fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap(); + setup(&mut |id, p| { + fresh.execute(id, p).unwrap(); + }); + fresh.execute("paint.cloneStamp", p).unwrap(); + let st = app.session.active().unwrap(); + let a = photocraft_compose::render(&st.doc, st.doc.bounds()); + let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds()); + assert_eq!(a.px.len(), b.px.len()); + assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ"); +} From dbadd21a3e9d74bb040d0175053d03753e9f3492 Mon Sep 17 00:00:00 2001 From: Cyrill Vitkovskiy <4040177+Furash@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:06:11 +0100 Subject: [PATCH 214/523] Stylus: a pen held still is not a press-and-hold right click (#4106) * Stylus: a pen held still is not a press-and-hold right click On Windows a pen reaches egui as touch events, and egui takes any touch contact held still past max_click_duration (0.8 s) for a touch screen's press-and-hold: a secondary click (context menu) that also drops the drag. Holding the pen still for a second opened a context menu. PenContacts tells a pen contact from a finger in raw_input_hook (only a pen hovers, and a pen reports pressure) and turns the press-and-hold timeout off while one is down, through the frame it lifts in, so a long still press releases as a tap. Fingers keep press-and-hold. Co-Authored-By: Claude Opus 5.5 * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/stylus.rs | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 73cc9b61d0..05c49b60d1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1673,6 +1673,9 @@ impl eframe::App for PhotocraftApp { wheel_nav::fold_legacy_pinch(ctx, raw_input); } raw_input.events.extend(self.take_synthetic_step()); + // A pen held still is no touch screen's press-and-hold right click (stylus.rs). + let pen_down = self.stylus.pen_contacts.observe(&raw_input.events); + stylus::pen_hold_is_no_right_click(ctx, pen_down); if self.custom_titlebar { titlebar::release_after_os_resize(ctx, raw_input); } diff --git a/crates/ui-egui/src/stylus.rs b/crates/ui-egui/src/stylus.rs index 110f401f63..8460c31ea2 100644 --- a/crates/ui-egui/src/stylus.rs +++ b/crates/ui-egui/src/stylus.rs @@ -137,6 +137,8 @@ pub struct Stylus { current: Option, /// The highest pen pressure (before the curve) since the drag began, for a tap's one dab. peak: f32, + /// The pen's touch contacts in the raw input, so a held pen is no press-and-hold right click. + pub(crate) pen_contacts: PenContacts, /// The egui frame [`Self::update_for_frame`] last ran in. updated_frame: Option, /// Tilt X, tilt Y, rotation of each point of the current drag (parallel to its points). @@ -166,6 +168,7 @@ impl Default for Stylus { prev: None, current: None, peak: 0.0, + pen_contacts: PenContacts::default(), updated_frame: None, stroke: Vec::new(), times: Vec::new(), @@ -416,10 +419,143 @@ impl Stylus { } } +/// The touch contacts a pen makes, read from the raw input. egui takes every touch contact held +/// still past `max_click_duration` (0.8 s) for a touch screen's press-and-hold: a secondary click +/// (a context menu) that also drops the drag. On Windows a pen arrives as touch events, so a pen +/// held still opened a context menu. A pen held still is just a pen, as in Photoshop. +#[derive(Clone, Debug, Default)] +pub(crate) struct PenContacts { + /// Touch ids that moved without touching: only a pen hovers. + hovering: Vec, + /// The contacts down, and whether each is a pen. + down: Vec<(egui::TouchId, bool)>, +} + +/// Most touch ids [`PenContacts`] remembers (the oldest go first). +const CONTACTS: usize = 16; + +impl PenContacts { + /// Follow a frame's touch events. True when a pen contact was down at any point of the frame. + pub(crate) fn observe(&mut self, events: &[egui::Event]) -> bool { + let pressed = |f: &Option| f.is_some_and(|f| f > 0.0); + let mut pen = self.down.iter().any(|(_, p)| *p); + for e in events { + let egui::Event::Touch { id, phase, force, .. } = e else { continue }; + match phase { + egui::TouchPhase::Start => { + let is_pen = self.hovering.contains(id) || pressed(force); + self.down.retain(|(c, _)| c != id); + if self.down.len() >= CONTACTS { + self.down.remove(0); + } + self.down.push((*id, is_pen)); + pen |= is_pen; + } + egui::TouchPhase::Move => match self.down.iter_mut().find(|(c, _)| c == id) { + Some(c) if pressed(force) => { + c.1 = true; + pen = true; + } + Some(_) => {} + None if !self.hovering.contains(id) => { + if self.hovering.len() >= CONTACTS { + self.hovering.remove(0); + } + self.hovering.push(*id); + } + None => {} + }, + egui::TouchPhase::End | egui::TouchPhase::Cancel => self.down.retain(|(c, _)| c != id), + } + } + pen + } +} + +/// Turn egui's touch press-and-hold off while a pen contact is down (and through the frame it +/// lifts in, so the release is the click a quick tap would be), back on otherwise for fingers. +pub(crate) fn pen_hold_is_no_right_click(ctx: &egui::Context, pen_down: bool) { + let want = if pen_down { f64::INFINITY } else { egui::InputOptions::default().max_click_duration }; + if ctx.options(|o| o.input_options.max_click_duration) != want { + ctx.options_mut(|o| o.input_options.max_click_duration = want); + } +} + #[cfg(test)] mod tests { use super::*; + /// Hold a touch contact still for 1.2 s over a click-sensing area, with the press-and-hold + /// handling the app does in `raw_input_hook`. Did the area see a secondary click? + fn held_contact_right_clicks(hover_first: bool, force: Option) -> bool { + let ctx = egui::Context::default(); + let mut contacts = PenContacts::default(); + let mut right_clicked = false; + let ev = |phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(3), phase, pos: egui::pos2(50.0, 50.0), force }; + // A first frame lays the area out, so the press hits it. + let mut frames = vec![(0.0, vec![])]; + if hover_first { + frames.push((0.05, vec![ev(egui::TouchPhase::Move, None), egui::Event::PointerMoved(egui::pos2(50.0, 50.0))])); + } + frames.push(( + 0.1, + vec![ + ev(egui::TouchPhase::Start, force), + egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), + egui::Event::PointerButton { + pos: egui::pos2(50.0, 50.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + )); + for t in [0.4, 0.7, 1.0, 1.3] { + frames.push((t, vec![])); + } + for (time, mut events) in frames { + // egui-winit emits the touch and its emulated pointer events together. + let mut raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0))), + ..Default::default() + }; + raw.events.append(&mut events); + let pen = contacts.observe(&raw.events); + pen_hold_is_no_right_click(&ctx, pen); + let mut out = ctx.run_ui(raw, |ui| { + let r = ui.allocate_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0)), egui::Sense::click_and_drag()); + right_clicked |= r.secondary_clicked(); + }); + out.textures_delta.clear(); + } + right_clicked + } + + #[test] + fn a_held_pen_is_not_a_right_click_but_a_held_finger_still_is() { + assert!(held_contact_right_clicks(false, None), "a finger's press-and-hold is egui's context menu"); + assert!(!held_contact_right_clicks(true, None), "a pen that hovered first"); + assert!(!held_contact_right_clicks(false, Some(0.4)), "a contact that reports pressure"); + } + + #[test] + fn pen_contacts_follow_hover_force_and_lift() { + use egui::TouchPhase::{Cancel, End, Move, Start}; + let t = |id, phase, force| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(id), phase, pos: egui::pos2(1.0, 1.0), force }; + let mut c = PenContacts::default(); + assert!(!c.observe(&[t(1, Move, None)]), "hovering is no contact"); + assert!(c.observe(&[t(1, Start, None)])); + assert!(c.observe(&[]), "still down"); + assert!(c.observe(&[t(1, End, None)]), "the lift frame still counts"); + assert!(!c.observe(&[])); + // A finger, until it reports a force. + assert!(!c.observe(&[t(9, Start, None), t(9, Move, None)])); + assert!(c.observe(&[t(9, Move, Some(0.5))])); + assert!(c.observe(&[t(9, Cancel, None)])); + assert!(!c.observe(&[])); + } + fn touch(phase: egui::TouchPhase, force: Option) -> egui::Event { egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(0), phase, pos: egui::pos2(1.0, 1.0), force } } From ef937adf9891e10848f575c4352f5f7cd34ae714 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 11:06:24 +1100 Subject: [PATCH 215/523] Refresh Liquify's zoomed preview when a stroke is undone. (#4135) * Refresh Liquify's zoomed preview when a stroke is undone. Above 100% zoom the dialog shows a source-resolution crop. Undo rebuilt the proxy and left that crop untouched, so the stroke stayed on screen until the next zoom. Refs #4100 * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/liquify_ui.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 2f429e6bcd..0bc4cbd782 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -286,6 +286,9 @@ impl LiquifyDialog { fn rebuild(&mut self) { self.field = LiquifyField::from_strokes(self.canvas, self.cell, &self.strokes); self.dirty = Some([0, 0, self.proxy.w, self.proxy.h]); + // Undo rebuilds the whole field. The zoomed viewport is a separate buffer; leaving it + // clean kept the pre-undo pixels on screen until the next zoom (#4100). + self.view_dirty = self.view_bounds; self.mask_dirty = true; self.render_dirty(); } @@ -1210,6 +1213,27 @@ mod tests { assert_eq!(d.out, expected, "dirty upload source must match the rebuilt field"); } + #[test] + fn undo_dirties_the_zoomed_preview() { + let ctx = egui::Context::default(); + let mut app = app_with_layer(); + open(&mut app, &ctx).unwrap(); + control(&mut app, &json!({"tool": "forwardWarp", "size": 40})).unwrap(); + for ev in [ToolEvent::Down { x: 35.0, y: 40.0, pressure: 1.0 }, ToolEvent::Move { x: 55.0, y: 40.0, pressure: 1.0 }, ToolEvent::Up { x: 55.0, y: 40.0 }] + { + pointer(&mut app, ev, egui::Modifiers::NONE); + } + { + let d = app.distort.liquify.as_mut().unwrap(); + d.zoom = 2.0; + d.view_bounds = Some(Rect::new(0, 0, 20, 20)); + d.view_dirty = None; + } + control(&mut app, &json!({"undo": true})).unwrap(); + let d = app.distort.liquify.as_ref().unwrap(); + assert_eq!(d.view_dirty, d.view_bounds); + } + #[test] fn liquify_redo_restores_undone_stroke_and_a_new_stroke_clears_redo() { let ctx = egui::Context::default(); From 799f28167fe502cef6be5a37ba8c08d26848189a Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:10:27 +0700 Subject: [PATCH 216/523] fix(selection): avoid panics when sampling incomplete region masks Use checked mask indexing so incomplete or extreme regions return zero coverage instead of panicking or wrapping. Validated with the full algo suite, independent native and Wasm regressions, and green PR checks. --- crates/algo/src/selection.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index ebe8a42701..268826f395 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -245,7 +245,9 @@ impl Region { if !self.bbox.contains(x, y) { return 0.0; } - self.mask[(y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize] as f32 / 255.0 + let row = (y.abs_diff(self.bbox.y0) as usize).checked_mul(self.bbox.width() as usize); + let value = row.and_then(|i| i.checked_add(x.abs_diff(self.bbox.x0) as usize)).and_then(|i| self.mask.get(i)).copied().unwrap_or(0); + f32::from(value) / 255.0 } } @@ -1400,6 +1402,20 @@ mod tests { .collect() } + #[test] + fn region_coverage_outside_a_short_mask_is_zero() { + let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] }; + assert_eq!(region.at(-2, -3), 1.0); + assert!((region.at(-1, -3) - 128.0 / 255.0).abs() < 1e-6); + assert_eq!(region.at(0, -3), 0.0); + assert_eq!(region.at(-2, -2), 0.0); + assert_eq!(region.at(-3, -3), 0.0); + + let extreme = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![] }; + assert_eq!(extreme.at(i32::MAX - 1, i32::MAX - 1), 0.0); + assert_eq!(extreme.at(i32::MIN, i32::MIN), 0.0); + } + #[test] fn wand_region_matches_reference_magic_wand() { let (w, h) = (37, 29); From 2338492b1289181c661bf895311411caeb8b3a98 Mon Sep 17 00:00:00 2001 From: Hevar Hemin <66733462+Hevarh1@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:11:42 +0300 Subject: [PATCH 217/523] Type: Space inserts a space while editing type (#4224) * Type: Space inserts a space while editing type Space was grabbing the Hand tool during canvas type edit even though Photoshop keeps Space typing a space. Skip temporary-tool holds when ui.text_edit is active. * Credit @Hevarh1 with sync_github_name in About * Format Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 3 +++ crates/ui-egui/src/hold_keys.rs | 7 ++++--- crates/ui-egui/src/hold_keys/tests.rs | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 9377474c8e..fadd139846 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -133,3 +133,6 @@ sync_github_name = true [people.lucAmbr0] sync_github_name = true + +[people.Hevarh1] +sync_github_name = true diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index c182247bfd..3f2845cae8 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -147,9 +147,10 @@ fn held(i: &InputState, sc: &KeyboardShortcut) -> bool { } /// The temporary tool whose key is held now, the most specific binding first (⌘Space before -/// Space). Never while a text field has the keyboard. +/// Space). Never while a text field has the keyboard, or while editing type on the canvas +/// (Space must insert a space, not grab the Hand tool). pub fn held_tool(app: &PhotocraftApp, ctx: &egui::Context) -> Option { - if ctx.text_edit_focused() { + if ctx.text_edit_focused() || app.ui.text_edit.is_some() { return None; } let count = |sc: &KeyboardShortcut| { @@ -194,7 +195,7 @@ pub fn erase_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { /// Is the reposition key (the Hand key, any modifiers) down? pub fn reposition_held(app: &PhotocraftApp, ctx: &egui::Context) -> bool { - !ctx.text_edit_focused() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) + !ctx.text_edit_focused() && app.ui.text_edit.is_none() && binding(app, Temporary::Hand).is_some_and(|sc| ctx.input(|i| i.key_down(sc.logical_key))) } /// Tools whose in-progress drag the reposition key moves. diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index 45e806c64d..d558a5a7b7 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -174,6 +174,28 @@ fn space_is_a_temporary_hand_and_gives_the_tool_back() { assert!(!st.session.active().unwrap().history.can_undo(), "nothing was painted"); } +#[test] +fn space_while_editing_type_is_not_the_hand_tool() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"text": "ئا", "size": 20, "x": 100, "y": 100})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 2, + anchor: 2, + session: "t".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + key(&mut h, Key::Space, true, Modifiers::NONE); + assert!(h.state().ui.text_edit.is_some(), "type session stays open"); + assert_eq!(held_tool(h.state(), &h.ctx), None, "Space types a space while editing"); + assert!(!reposition_held(h.state(), &h.ctx)); + key(&mut h, Key::Space, false, Modifiers::NONE); +} + #[test] fn temporary_zoom_in_and_out_restore_the_tool() { let mut h = harness(Tool::RectMarquee); From 0761234c2f4dc9c094f5f74363c757d0692d7d93 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:33 +0700 Subject: [PATCH 218/523] fix(algo): reject oversized Magic Eraser masks without overflow (#4043) * fix(algo): reject oversized Magic Eraser masks before multiplying dimensions * Format the oversized Magic Eraser mask test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/erase.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/erase.rs b/crates/algo/src/erase.rs index 6f644d1191..75b2a4246c 100644 --- a/crates/algo/src/erase.rs +++ b/crates/algo/src/erase.rs @@ -18,7 +18,8 @@ pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 }; let bbox = region.bbox; let bw = bbox.width() as usize; - if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize { + let Some(pixels) = bw.checked_mul(bbox.height() as usize) else { return Rect::EMPTY }; + if bbox.is_empty() || amount <= 0.0 || region.mask.len() != pixels { return Rect::EMPTY; } let fmt = target.format(); @@ -147,6 +148,16 @@ mod tests { assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]); } + #[test] + fn oversized_region_mask_is_rejected_without_overflow() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 1, 1), &[1.0, 0.0, 0.0, 1.0]); + let before = s.pixel(0, 0); + let oversized = Region { bbox: Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX), mask: vec![255] }; + assert_eq!(magic_erase(&mut s, &oversized, 1.0, None, None), Rect::EMPTY); + assert_eq!(s.pixel(0, 0), before); + } + #[test] fn degenerate_inputs_do_nothing() { let mut s = Surface::new(PixelFormat::RGBA8); From 9712f5e7d59027bee58823fbbe8f6b877a9b1837 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:41 +0700 Subject: [PATCH 219/523] fix(selection): reject incomplete magic wand pixel buffers (#4046) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/selection.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs index 268826f395..d6662e75f1 100644 --- a/crates/algo/src/selection.rs +++ b/crates/algo/src/selection.rs @@ -166,7 +166,8 @@ fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool { /// around the seed. `px` covers `area`. pub fn magic_wand(px: &[[f32; 4]], area: Rect, seed: (i32, i32), tolerance: f32, contiguous: bool, anti_alias: bool) -> Vec { let (w, h) = (area.width() as usize, area.height() as usize); - let mut m = vec![0.0f32; w * h]; + let Some(n) = w.checked_mul(h).filter(|&n| n <= px.len()) else { return Vec::new() }; + let mut m = vec![0.0f32; n]; if !area.contains(seed.0, seed.1) { return m; } @@ -259,6 +260,9 @@ pub fn wand_region(img: &[[u8; 4]], area: Rect, seed: (i32, i32), tolerance: f32 return None; } let (w, h) = (area.width() as usize, area.height() as usize); + if w.checked_mul(h).is_none_or(|n| n > img.len()) { + return None; + } let target = img[(seed.1 - area.y0) as usize * w + (seed.0 - area.x0) as usize]; let tol = (tolerance + 1e-3).floor().max(0.0) as i32; // Copying a selection can leave RGB under zero alpha. Those invisible colours must not @@ -1402,6 +1406,23 @@ mod tests { .collect() } + #[test] + fn magic_wand_rejects_incomplete_and_overflowing_pixel_inputs() { + let area = Rect::new(0, 0, 4, 4); + let short = vec![[255u8, 0, 0, 255]; 4]; + let floats: Vec<[f32; 4]> = short.iter().map(|p| p.map(|v| f32::from(v) / 255.0)).collect(); + assert_eq!(magic_wand(&floats, area, (1, 1), 0.0, true, false), Vec::::new()); + assert_eq!(wand_region(&short, area, (1, 1), 0.0, true, false), None); + + let huge = Rect::new(i32::MIN, i32::MIN, i32::MAX, i32::MAX); + assert!(magic_wand(&floats, huge, (0, 0), 0.0, false, false).is_empty()); + assert_eq!(wand_region(&short, huge, (0, 0), 0.0, false, false), None); + + let valid = Rect::new(0, 0, 2, 2); + assert_eq!(magic_wand(&floats, valid, (0, 0), 0.0, false, false), vec![1.0; 4]); + assert!(wand_region(&short, valid, (0, 0), 0.0, false, false).is_some()); + } + #[test] fn region_coverage_outside_a_short_mask_is_zero() { let region = Region { bbox: Rect::new(-2, -3, 1, -1), mask: vec![255, 128] }; From d3d735c6c77362b872a3b48deb290e072318a3c1 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 01:16:49 +0100 Subject: [PATCH 220/523] feat(ui): the shaped brush cursor turns and flips with the view (#4233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3467 traces a shaped tip's outline but scales it by the screen radius alone, so on a rotated or flipped canvas (R, View › Flip Horizontal) the cursor kept the document's orientation while the stroke did not. The outline now goes on screen through the view's own axes: ViewXform::map_vec maps a document vector (pixel aspect, rotation, flip, zoom), and brush_cursor::tip_axes gives one tip radius along the document's x and y. A plain view gives the same points as before. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/brush_cursor.rs | 78 ++++++++++++++++++++++++++++-- crates/ui-egui/src/canvas.rs | 15 +++++- docs/brush-parity.md | 5 +- 3 files changed, 90 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index 2cb30806aa..c53fc5de6b 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -42,11 +42,25 @@ impl Key { } } -/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom); -/// `normal` is Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). -pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, normal: bool, centre: bool) -> CursorIcon { +/// The screen vectors of one tip radius (`size / 2` document pixels) along the document's x and +/// y axes: the view's zoom, rotation, flip and pixel aspect, which the outline follows as the +/// stroke does. +pub(crate) fn tip_axes(xf: &crate::canvas::ViewXform, size: f32) -> [Vec2; 2] { + let r = if size.is_finite() { size.max(0.0) / 2.0 } else { 0.0 }; + [xf.map_vec(Vec2::new(r, 0.0)), xf.map_vec(Vec2::new(0.0, r))] +} + +/// Rings in radius units (document orientation) on screen, through [`tip_axes`]. +fn on_screen(rings: &[Vec<[f32; 2]>], axes: [Vec2; 2]) -> Vec> { + rings.iter().map(|r| r.iter().map(|p| axes[0] * p[0] + axes[1] * p[1]).collect()).collect() +} + +/// The brush cursor at `at`. `radius` is the tip's screen radius (size / 2 at the current zoom), +/// for the plain circle; `axes` comes from [`tip_axes`], for a traced outline. `normal` is +/// Preferences › Cursors "Normal Brush Tip" (the 50 % contour, not the full tip). +pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: f32, axes: [Vec2; 2], normal: bool, centre: bool) -> CursorIcon { if let Some(rings) = rings(painter.ctx(), brush, !normal) { - let pts: Vec> = rings.iter().map(|r| r.iter().map(|p| Vec2::new(p[0], p[1]) * radius).collect()).collect(); + let pts = on_screen(&rings, axes); let extent = pts.iter().flatten().fold(0.0f32, |m, p| m.max(p.length())); if extent < MIN_RADIUS { return crate::tool_cursor::crosshair(painter, at, 4.0, 0.0); @@ -73,3 +87,59 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option ViewXform { + ViewXform { rect: Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 300.0)), zoom: 2.0, center: [50.0, 40.0], flip, rotation, aspect } + } + + fn close(a: Vec2, b: Vec2) -> bool { + (a - b).length() < 1e-4 + } + + /// The axes are the screen offsets of the document points one radius right of and below the + /// pointer, whatever the view does. + #[test] + fn tip_axes_follow_the_view_like_to_screen() { + for (rotation, flip, aspect) in [(0.0, false, 1.0), (30.0, false, 1.0), (-90.0, true, 1.0), (45.0, true, 2.0), (200.0, false, 0.5)] { + let xf = view(rotation, flip, aspect); + let [ex, ey] = tip_axes(&xf, 20.0); + let o = xf.to_screen(60.0, 30.0); + assert!(close(ex, xf.to_screen(70.0, 30.0) - o), "x axis at {rotation}°, flip {flip}, aspect {aspect}"); + assert!(close(ey, xf.to_screen(60.0, 40.0) - o), "y axis at {rotation}°, flip {flip}, aspect {aspect}"); + } + } + + /// An unrotated, unflipped view gives what upstream's radius scaling gave. + #[test] + fn a_plain_view_scales_the_outline_by_the_screen_radius() { + let rings = vec![vec![[1.0, 0.0], [0.0, 0.5], [-1.0, 0.0]]]; + let pts = on_screen(&rings, tip_axes(&view(0.0, false, 1.0), 20.0)); + let want = [Vec2::new(20.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(-20.0, 0.0)]; + assert!(pts[0].iter().zip(want).all(|(a, b)| close(*a, b)), "{pts:?}"); + } + + /// A tip pointing along the document's x axis points down the screen in a view turned 90°, + /// and left in a flipped one. + #[test] + fn the_outline_turns_and_flips_with_the_view() { + let rings = vec![vec![[1.0, 0.0]]]; + let turned = on_screen(&rings, tip_axes(&view(90.0, false, 1.0), 20.0)); + assert!(close(turned[0][0], Vec2::new(0.0, 20.0)), "{turned:?}"); + let flipped = on_screen(&rings, tip_axes(&view(0.0, true, 1.0), 20.0)); + assert!(close(flipped[0][0], Vec2::new(-20.0, 0.0)), "{flipped:?}"); + } + + #[test] + fn hostile_sizes_give_finite_axes() { + for size in [f32::NAN, f32::INFINITY, -5.0, 0.0] { + let [ex, ey] = tip_axes(&view(30.0, true, 1.0), size); + assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3c067c7565..b053af1a41 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -864,6 +864,15 @@ impl ViewXform { vec2((d.x * c + d.y * s) / self.sx(), -d.x * s + d.y * c) } + /// A document-space vector on screen: stretched, rotated, flipped and zoomed as the view + /// draws it (the inverse of [`Self::unmap_vec`], with the zoom). + pub fn map_vec(self, d: Vec2) -> Vec2 { + let (c, s) = self.sincos(); + let dx = d.x * self.sx(); + let v = vec2(dx * c - d.y * s, dx * s + d.y * c) * self.zoom; + if self.flip { vec2(-v.x, v.y) } else { v } + } + pub fn to_screen(&self, x: f32, y: f32) -> Pos2 { let (c, s) = self.sincos(); let dx = (x - self.center[0]) * self.sx(); @@ -3575,10 +3584,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: egui::CursorIcon::None } // A shaped tip (sampled bitmap, or a rotated/flattened round tip) - // traces its outline; a plain round tip keeps the circle. + // traces its outline, turned and flipped with the view; a plain round + // tip keeps the circle. _ => { let centre = brush_tip_centre(tool, alt || app.ui.shell.sticky_alt, cur.show_crosshair_in_brush_tip); - crate::brush_cursor::cursor(&painter, p, brush, full, cur.painting == PaintingCursor::NormalTip, centre) + let axes = crate::brush_cursor::tip_axes(&xf, brush.size); + crate::brush_cursor::cursor(&painter, p, brush, full, axes, cur.painting == PaintingCursor::NormalTip, centre) } } } diff --git a/docs/brush-parity.md b/docs/brush-parity.md index ad1ea5c83c..4022f7a657 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (checkbox-only Brush Settings rows and non-destructive Smoothing switch; shaped-tip cursor follows the view) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -55,7 +55,7 @@ tablets against Photoshop side by side. | Interpolation and smoothing match Photoshop | partial | #2138, #2057 | | Soft-edge falloff matches Photoshop's curve | partial | #2149, #1619 | | Brush preview and cursor crisp, not pixelated | partial | #1433 | -| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364 | +| Cursor outline equals the painted footprint at every zoom | partial | #1761, #2364; shaped tips trace their outline (#3467), turned and flipped with a rotated or flipped view | | Latency: dab to screen within a frame | partial | budgets P13/P16/P33 over (scorecard); big-brush lag #2619, #1994; cursor lag #2566, #1343 | | No hitch at mouse-up on a long stroke | not measurable | P32 needs a frame harness | | Live stroke for Brush and Eraser | done | | @@ -116,3 +116,4 @@ tablets against Photoshop side by side. | 2026-10-10 | major | First version, from a source audit and the open issues | | 2026-10-11 | minor | `~` held with the Brush or Pencil erases with that tool's brush, and Clear joins their Mode menus (#3439) | | 2026-10-12 | minor | Five Brush Settings switch rows toggle without selecting a properties pane; Smoothing has a default-on switch and retains its settings while disabled (#4283). No brush-feel estimate changed. | +| 2026-10-12 | minor | A shaped tip's cursor outline turns and flips with a rotated or flipped view, and follows pixel aspect correction | From 003f9b4a1867af1abdf60b11dbd2a513f2f7e48d Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 12 Oct 2026 17:16:56 -0700 Subject: [PATCH 221/523] Affinity import: pie shapes with huge angles no longer hang the reader (#4054) * Affinity import: pie shapes with huge angles no longer hang the reader The reader raised the end angle of a pie shape (`ShPi`) by `TAU` in a loop until the end angle reached the start angle. Both angles come from the file. The loop ran about `(AngS - AngE) / TAU` times, and it never ended once adding `TAU` no longer changed the end angle: with `AngS` = 1e308 and `AngE` = -1e308, reading the document did not return. The reader now computes the pie's angles in constant time. The start angle is `AngS` modulo `TAU`. The sweep is `AngE - AngS` capped at one turn, or its remainder modulo `TAU` when `AngE` is below `AngS`. When `AngE` is below `AngS` and that difference overflows to negative infinity, the sweep comes from the remainders of the two angles. The change and its two tests come from Riccardo Balloch's commit 79c5d673 in storytold/vectorcraft, which fixed the same loop in VectorCraft's copy of this reader. Here the tests read the document on a thread of their own and fail when reading takes more than 20 seconds. Co-authored-by: Riccardo Balloch * Contributors: add dotCooCoo (Robert Lee) --------- Co-authored-by: Riccardo Balloch Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- contributors/people.toml | 4 +++ crates/affinity/src/shapes.rs | 20 +++++++---- crates/affinity/src/tests.rs | 65 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index fadd139846..9a8bf4b4ce 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -136,3 +136,7 @@ sync_github_name = true [people.Hevarh1] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index 5e825fa850..f3bff62a33 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -115,13 +115,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> }))? } b"ShPi" => { - let start = num(r, shape, b"AngS").unwrap_or(0.0); - let mut end = num(r, shape, b"AngE").unwrap_or(TAU); - while end < start { - end += TAU; - } + let raw_start = num(r, shape, b"AngS").unwrap_or(0.0); + let raw_end = num(r, shape, b"AngE").unwrap_or(TAU); + // Normalize in constant time. Repeatedly adding TAU can hang on finite values + // large enough that floating-point addition no longer changes the endpoint. + let start = raw_start.rem_euclid(TAU); + let delta = raw_end - raw_start; + let sweep = if raw_end >= raw_start { + delta.min(TAU) + } else if delta.is_finite() { + delta.rem_euclid(TAU) + } else { + (raw_end.rem_euclid(TAU) - start).rem_euclid(TAU) + }; let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0); - pie(cx, cy, rx, ry, start, end.min(start + TAU), inner) + pie(cx, cy, rx, ry, start, start + sweep, inner) } b"ShpT" => { let p = num(r, shape, b"Pos ").unwrap_or(0.5); diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index f70648b42d..7f1472b1ad 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -349,3 +349,68 @@ mod archive { } } } + +mod shapes { + use crate::container::Limits; + use crate::model::{Document, Kind, Path}; + use crate::stream::Tag; + use crate::synth::{self, F, Method, tag}; + + fn shape(id: u32, class: &[u8; 4], parameters: Vec<(Tag, F)>) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(class)], parameters))], + ) + } + + /// Reads a document whose one page holds the object `top` from `definitions`, on a thread of + /// its own, and fails the test when reading takes more than 20 s. + fn read(definitions: Vec, top: u32) -> Document { + let data = synth::stream(&[ + (tag(b"Defs"), F::Shared(definitions)), + ( + tag(b"DocR"), + F::Def( + 1, + vec![tag(b"DocN")], + vec![( + tag(b"Chld"), + F::Shared(vec![F::Def( + 2, + vec![tag(b"Sprd")], + vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![F::Ref(top)]))], + )]), + )], + ), + ), + ]); + let bytes = synth::container(&[("doc.dat", &data, Method::Zlib)], None); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(crate::model::read(&bytes, Limits::default())).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading the document did not finish")).unwrap() + } + + fn path(document: &Document) -> &Path { + match &document.spreads[0].nodes[0].kind { + Kind::Shape { path, .. } => path, + other => panic!("expected shape, got {other:?}"), + } + } + + #[test] + fn pie_angles_with_huge_finite_values_do_not_loop_or_emit_nonfinite_points() { + let document = read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(1.0e308)), (tag(b"AngE"), F::F64(-1.0e308))])], 100); + for subpath in &path(&document).subpaths { + assert!(subpath.start.x.is_finite() && subpath.start.y.is_finite()); + assert!(subpath.segments.len() <= 20); + assert!(subpath.segments.iter().flatten().all(|point| point.x.is_finite() && point.y.is_finite())); + } + } + + #[test] + fn wrapped_pie_angles_keep_the_same_geometry_as_the_positive_span() { + let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100); + assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2))); + } +} From 7716d609e3b5e1dec95e1595672e18659e189014 Mon Sep 17 00:00:00 2001 From: B?! <12576795+callmeBalloch@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:20:44 +0200 Subject: [PATCH 222/523] Affinity: rectangles keep per-corner radii and straight corners (#1606) (#4055) Unlocked rectangle corners now each use their own radius and type (top left, top right, bottom right, bottom left) instead of all following the first one, and straight corners are cut across instead of rounded. Same fix as VectorCraft's copy of the reader (storytold/vectorcraft#1362). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/affinity/README.md | 4 +- crates/affinity/src/shapes.rs | 95 ++++++++++++++++++++++------------- crates/affinity/src/tests.rs | 47 ++++++++++++++++- 3 files changed, 108 insertions(+), 38 deletions(-) diff --git a/crates/affinity/README.md b/crates/affinity/README.md index a88b85bd4d..4f0f9afe52 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -16,7 +16,7 @@ Affinity files. | Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) | -| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | +| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers | | Compound shapes (`Comp`, add and subtract) | one even-odd shape | | A shape or curve with children clips them | group with the outline as vector mask, the shape at its bottom | | Vector masks (`AdCh` curves and shapes) | vector masks | @@ -34,7 +34,7 @@ Affinity files. Everything else is reported in the import warnings, one line per kind with a count, never dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, transparency gradients, fill opacity, bitmap fills, master pages, unrecognized -parametric shapes (imported as their bounding ellipses), corner types other than round, stars with +parametric shapes (imported as their bounding ellipses), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (one of each is used), strokes behind the fill, outlined or scaled text, text fields such as page numbers, frames with columns or a curved outline, grey/Lab/32-bit pixels, CMYK and Lab colour (converted to RGB without the document's diff --git a/crates/affinity/src/shapes.rs b/crates/affinity/src/shapes.rs index f3bff62a33..c7751da885 100644 --- a/crates/affinity/src/shapes.rs +++ b/crates/affinity/src/shapes.rs @@ -51,28 +51,44 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) -> let class = r.s.class(shape)?; let sub = match &class.0.to_be_bytes() { b"ShNR" | b"ShRR" => { - let rounded = matches!(last(r, shape, b"CTyp"), Some(Value::Array(a)) if !a.is_empty()); - let radius = match last(r, shape, b"ShCR") { - Some(Value::Floats(v)) => v.first().copied().filter(|f| f.is_finite() && *f > 0.0), - _ => None, + let types = match last(r, shape, b"CTyp") { + Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(), + _ => Vec::new(), }; - if let (true, Some(f)) = (rounded, radius) { - if let Some(Value::Array(types)) = last(r, shape, b"CTyp") - && types.iter().any(|t| !matches!(t, Value::Enum { id: 0 | 4, .. })) - { - r.warn("rectangles with straight, concave or other corner types (imported with round corners)"); - } - let (crx, cry) = if r.s.bool(shape, b"AbSz") == Some(true) { - // An absolute radius is in document pixels, whatever the node's scale. - let [a, b2, c, d, ..] = world.0; - let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); - if sx > 0.0 && sy > 0.0 { (f / sx, f / sy) } else { (0.0, 0.0) } - } else { - (f * w.min(h), f * w.min(h)) - }; - rounded_rect(b, crx.min(w / 2.0), cry.min(h / 2.0)) - } else { + let radii = match last(r, shape, b"ShCR") { + Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(), + _ => Vec::new(), + }; + // Corners follow the first one (top left) unless they were unlocked; then each has + // its own radius and type, clockwise from the top left. + let locked = !matches!(last(r, shape, b"Lock"), Some(Value::Bool(false))); + let corners: [(f64, Option); 4] = std::array::from_fn(|i| { + let i = if locked { 0 } else { i }; + (radii.get(i).copied().unwrap_or(0.0), types.get(i).copied().flatten()) + }); + if types.is_empty() || corners.iter().all(|(f, _)| *f == 0.0) { polygon([pt(x0, y0), pt(x1, y0), pt(x1, y1), pt(x0, y1)])? + } else { + if corners.iter().any(|(f, t)| *f > 0.0 && !matches!(t, Some(0 | 1 | 4))) { + r.warn("rectangles with concave or other corner types (imported with round corners)"); + } + let absolute = r.s.bool(shape, b"AbSz") == Some(true); + let [a, b2, c, d, ..] = world.0; + let (sx, sy) = ((a * a + b2 * b2).sqrt(), (c * c + d * d).sqrt()); + cornered_rect( + b, + corners.map(|(f, t)| { + let (crx, cry) = if !absolute { + (f * w.min(h), f * w.min(h)) + } else if sx > 0.0 && sy > 0.0 { + // An absolute radius is in document pixels, whatever the node's scale. + (f / sx, f / sy) + } else { + (0.0, 0.0) + }; + (crx, cry, t == Some(1)) + }), + ) } } b"ShpE" => ellipse(cx, cy, rx, ry), @@ -354,22 +370,31 @@ fn ellipse(cx: f64, cy: f64, rx: f64, ry: f64) -> SubPath { } } -fn rounded_rect([x0, y0, x1, y1]: [f64; 4], rx: f64, ry: f64) -> SubPath { - let (kx, ky) = ((1.0 - KAPPA) * rx, (1.0 - KAPPA) * ry); - SubPath { - start: pt(x0 + rx, y0), - segments: vec![ - [pt(x1 - rx, y0), pt(x1 - rx, y0), pt(x1 - rx, y0)], - [pt(x1 - kx, y0), pt(x1, y0 + ky), pt(x1, y0 + ry)], - [pt(x1, y1 - ry), pt(x1, y1 - ry), pt(x1, y1 - ry)], - [pt(x1, y1 - ky), pt(x1 - kx, y1), pt(x1 - rx, y1)], - [pt(x0 + rx, y1), pt(x0 + rx, y1), pt(x0 + rx, y1)], - [pt(x0 + kx, y1), pt(x0, y1 - ky), pt(x0, y1 - ry)], - [pt(x0, y0 + ry), pt(x0, y0 + ry), pt(x0, y0 + ry)], - [pt(x0, y0 + ky), pt(x0 + kx, y0), pt(x0 + rx, y0)], - ], - closed: true, +/// A rectangle whose corners (top left, top right, bottom right, bottom left) are each rounded, +/// or cut straight across, by their (horizontal, vertical) radii. Two radii that don't fit along +/// one side shrink in proportion until they do. +fn cornered_rect([x0, y0, x1, y1]: [f64; 4], corners: [(f64, f64, bool); 4]) -> SubPath { + let fit = |r: f64, pair: f64, side: f64| if pair > side { side * (r / pair) } else { r }; + let [tl, tr, br, bl] = corners; + let (w, h) = (x1 - x0, y1 - y0); + let rx = [fit(tl.0, tl.0 + tr.0, w), fit(tr.0, tl.0 + tr.0, w), fit(br.0, bl.0 + br.0, w), fit(bl.0, bl.0 + br.0, w)]; + let ry = [fit(tl.1, tl.1 + bl.1, h), fit(tr.1, tr.1 + br.1, h), fit(br.1, tr.1 + br.1, h), fit(bl.1, tl.1 + bl.1, h)]; + let (kx, ky) = (rx.map(|r| (1.0 - KAPPA) * r), ry.map(|r| (1.0 - KAPPA) * r)); + // (where the corner leaves the previous side, its two handles, where it joins the next side) + let arcs = [ + [pt(x1 - rx[1], y0), pt(x1 - kx[1], y0), pt(x1, y0 + ky[1]), pt(x1, y0 + ry[1])], + [pt(x1, y1 - ry[2]), pt(x1, y1 - ky[2]), pt(x1 - kx[2], y1), pt(x1 - rx[2], y1)], + [pt(x0 + rx[3], y1), pt(x0 + kx[3], y1), pt(x0, y1 - ky[3]), pt(x0, y1 - ry[3])], + [pt(x0, y0 + ry[0]), pt(x0, y0 + ky[0]), pt(x0 + kx[0], y0), pt(x0 + rx[0], y0)], + ]; + let mut segments = Vec::with_capacity(8); + for ([s, c1, c2, e], straight) in arcs.into_iter().zip([tr.2, br.2, bl.2, tl.2]) { + segments.push([s, s, s]); + if s != e { + segments.push(if straight { [e, e, e] } else { [c1, c2, e] }); + } } + SubPath { start: pt(x0 + rx[0], y0), segments, closed: true } } /// Elliptical arc from angle `a0` to `a1` (radians, measured y-up as Affinity's pie angles are), diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 7f1472b1ad..b28e30f3d6 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -352,7 +352,7 @@ mod archive { mod shapes { use crate::container::Limits; - use crate::model::{Document, Kind, Path}; + use crate::model::{Document, Kind, Path, Point, SubPath}; use crate::stream::Tag; use crate::synth::{self, F, Method, tag}; @@ -413,4 +413,49 @@ mod shapes { let make = |end| read(vec![shape(100, b"ShPi", vec![(tag(b"AngS"), F::F64(0.0)), (tag(b"AngE"), F::F64(end))])], 100); assert_eq!(path(&make(-std::f64::consts::FRAC_PI_2)), path(&make(3.0 * std::f64::consts::FRAC_PI_2))); } + + /// A 10×20 rectangle (`ShNR`) with the given parameters, read back as its outline. + fn rectangle(parameters: Vec<(Tag, F)>) -> (SubPath, Vec) { + let node = + F::Def(3, vec![tag(b"ShpN")], vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 10.0, 20.0])), (tag(b"Shpe"), F::Def(4, vec![tag(b"ShNR")], parameters))]); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(vec![node]))]); + let data = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + let doc = crate::read(&synth::container(&[("doc.dat", &data, Method::Zlib)], None), Limits::default()).unwrap(); + let Kind::Shape { path, .. } = &doc.spreads[0].nodes[0].kind else { panic!("expected a shape") }; + (path.subpaths[0].clone(), doc.warnings) + } + + /// `CTyp`: an array of corner-type enums (top left, top right, bottom right, bottom left). + fn corner_types(ids: [u16; 4]) -> F { + let mut raw = vec![0xaa]; + raw.extend(tag(b"CTyp").0.to_le_bytes()); + raw.extend(4u32.to_le_bytes()); + raw.extend(0u16.to_le_bytes()); + raw.extend(ids.iter().flat_map(|id| id.to_le_bytes())); + F::Raw(raw) + } + + #[test] + fn unlocked_rectangle_corners_keep_their_own_radius_and_type() { + let p = |x: f64, y: f64| Point { x, y }; + // Relative radii scale by 10. Round 5, round 1, straight 3, none. + let corners = |lock: Option| { + let mut fields = vec![(tag(b"ShCR"), F::F64s(vec![0.5, 0.1, 0.3, 0.0])), (tag(b"CTyp"), corner_types([0, 0, 1, 4]))]; + fields.extend(lock.map(|l| (tag(b"Lock"), F::Bool(l)))); + rectangle(fields) + }; + + let (sub, warnings) = corners(Some(false)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(sub.start, p(5.0, 0.0)); + let ends: Vec = sub.segments.iter().map(|s| s[2]).collect(); + assert_eq!(ends, [p(9.0, 0.0), p(10.0, 1.0), p(10.0, 17.0), p(7.0, 20.0), p(0.0, 20.0), p(0.0, 5.0), p(5.0, 0.0)]); + assert_eq!(sub.segments[3], [p(7.0, 20.0); 3], "the straight corner is a line"); + assert_ne!(sub.segments[1][0], p(9.0, 0.0), "the round corner is a curve"); + + // Locked (the default): every corner follows the top left one. + let (sub, _) = corners(None); + assert_eq!(sub.segments.len(), 8); + assert_eq!(sub.segments[3][2], p(5.0, 20.0)); + } } From 96f4d68db713ee6fd86934114a0da86081e2bad1 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Tue, 13 Oct 2026 01:23:56 +0100 Subject: [PATCH 223/523] feat(ui): spring-loaded tool letters (#2672) (#4238) In Photoshop, holding a tool's letter gives a temporary tool: releasing the key returns to the tool you had. A tool letter still switches tools when pressed; if the key then stays down for 300 ms or more, or the canvas is used while it's down, the switch was temporary and the release gives the previous tool back with its options. A quick tap stays a permanent switch. - Shift+letter (cycling the group) and Alt+letter never spring; nor do letters while typing in a field, editing type or under a dialog. - The current group's own letter cycles the group, as before. - A drag in progress when the key comes up finishes with the temporary tool; the previous tool returns once the button is up. - The newest held letter wins; the original tool returns when all are up. - A tool picked another way while a letter is down (the toolbar, a menu, automation) stands. - Key repeats of tool letters are dropped instead of cycling the group. - Z is one case: hold Z to zoom, add Alt to zoom out. The state is UiState::spring, readable over the control channel and never restored from settings. Tests that pressed tool letters without releasing them now release them, as a keyboard does. docs/ui-parity.md and docs/gaps.md mark #2672 done. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/artboard_tool.rs | 6 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/hold_keys/tests.rs | 10 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/marquee_tests.rs | 5 +- crates/ui-egui/src/shortcuts.rs | 18 +- crates/ui-egui/src/spring_tools.rs | 127 +++++++++++ crates/ui-egui/src/spring_tools/tests.rs | 269 +++++++++++++++++++++++ crates/ui-egui/src/state.rs | 5 + docs/gaps.md | 8 +- docs/ui-parity.md | 5 +- 11 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/spring_tools.rs create mode 100644 crates/ui-egui/src/spring_tools/tests.rs diff --git a/crates/ui-egui/src/artboard_tool.rs b/crates/ui-egui/src/artboard_tool.rs index 79a7cd3b78..6b996c3f14 100644 --- a/crates/ui-egui/src/artboard_tool.rs +++ b/crates/ui-egui/src/artboard_tool.rs @@ -417,7 +417,11 @@ mod tests { let press = |app: &mut PhotocraftApp, modifiers| { let key = egui::Key::V; let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b053af1a41..38d1e33f69 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2903,6 +2903,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: // Under an open dialog the canvas widget is inert, but the image still pans and zooms. let under_dialog = !app.ui.dialogs.is_empty(); let free_hover = under_dialog && crate::dialogs::free_pointer_over(&ctx, rect).is_some(); + // A press on the canvas while a tool letter is held makes that tool temporary (#2672). + if !under_dialog && ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| rect.contains(p))) { + crate::spring_tools::note_canvas_press(app); + } // Navigation (wheel_nav.rs): scroll pans (⇧ or ⌘/Ctrl sideways); pinch and ⌥-scroll zoom around the pointer. let wheel = crate::wheel_nav::read(&ctx, app.session.prefs().general.zoom_with_scroll_wheel, app.session.prefs().enhanced_controls.zoom_with_trackpad_pinch); diff --git a/crates/ui-egui/src/hold_keys/tests.rs b/crates/ui-egui/src/hold_keys/tests.rs index d558a5a7b7..cfe89d4642 100644 --- a/crates/ui-egui/src/hold_keys/tests.rs +++ b/crates/ui-egui/src/hold_keys/tests.rs @@ -72,9 +72,15 @@ fn key(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, m: Modifi h.run_steps(1); } +/// A quick press and release in one frame (a tool letter held across frames springs, #2672). fn tap(h: &mut Harness<'static, PhotocraftApp>, k: Key, m: Modifiers) { - key(h, k, true, m); - key(h, k, false, m); + let m = platform(m); + h.input_mut().events.extend([ + Event::ModifiersChanged(m), + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + h.run_steps(1); h.event(Event::ModifiersChanged(Modifiers::NONE)); h.run_steps(1); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05c49b60d1..2bfa9e96fc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -175,6 +175,7 @@ pub mod slice_ui; pub mod smart_ui; pub mod snap_ui; pub(crate) mod solid_fill_ui; +pub mod spring_tools; pub mod state; pub mod stroke_constraint; pub mod stroke_trail; diff --git a/crates/ui-egui/src/marquee_tests.rs b/crates/ui-egui/src/marquee_tests.rs index 241f5aa048..cf4832904a 100644 --- a/crates/ui-egui/src/marquee_tests.rs +++ b/crates/ui-egui/src/marquee_tests.rs @@ -792,7 +792,10 @@ fn m_cycles_only_the_rectangular_and_elliptical_marquee() { let mut seen = Vec::new(); for _ in 0..4 { let raw = egui::RawInput { - events: vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }], + events: vec![ + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::SHIFT }, + ], ..Default::default() }; ctx.begin_pass(raw); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5b549d6679..6e592385a1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -297,6 +297,8 @@ pub fn clipboard_keys(ctx: &egui::Context, typing: bool, raw: &mut egui::RawInpu } pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { + // A held tool letter released: a temporary tool gives the previous one back (#2672). + crate::spring_tools::tick(app, ctx); // However the tool was picked (toolbar, flyout, shortcut, automation), its group's key brings // it back later (#2608). app.ui.remember_group_tool(); @@ -573,17 +575,24 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tool keys; pressing the key of the current group cycles within it. With Preferences › // Tools › Use Shift Key for Tool Switch, only ⇧+key cycles and the plain key keeps the // group's current tool. From another group, the key picks the group's last-used tool. + // A held tool letter is a spring-loaded tool (#2672), not a burst of presses: its key + // repeats are dropped rather than cycling the group. + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key)))); let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { - let Some(k) = Key::from_name(&t.key().to_string()) else { continue }; + let Some(k) = crate::spring_tools::key_of(t.key()) else { continue }; let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { + let before = app.ui.tool; let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect(); app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], None => app.ui.group_tool(t.key()).unwrap_or(group[0]), }; + // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. + let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); + crate::spring_tools::pressed(app, t.key(), before, plain, now); return; } } @@ -604,7 +613,11 @@ mod tests { let ctx = egui::Context::default(); let press = |app: &mut PhotocraftApp, key, modifiers| { let raw = egui::RawInput { - events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], ..Default::default() }; ctx.begin_pass(raw); @@ -644,6 +657,7 @@ mod tests { events: vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key: Key::T, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Key { key: Key::T, physical_key: None, pressed: false, repeat: false, modifiers }, ], ..Default::default() }; diff --git a/crates/ui-egui/src/spring_tools.rs b/crates/ui-egui/src/spring_tools.rs new file mode 100644 index 0000000000..5f51136cdb --- /dev/null +++ b/crates/ui-egui/src/spring_tools.rs @@ -0,0 +1,127 @@ +//! Spring-loaded tool keys (#2672): hold a tool's letter for a temporary tool. +//! +//! A tool letter switches tools when it is pressed, as before (`shortcuts::handle`). If the key is +//! then held for [`SPRING_HOLD_SECS`] or longer, or the canvas is used while it is down, the switch +//! was only temporary: releasing the key gives back the tool in effect before the press, with its +//! options. A quick tap with no canvas use stays a permanent switch. +//! +//! - ⇧+letter (cycling the group) and ⌥+letter never spring; nor do letters while typing in a +//! field, editing type or under a modal dialog, which never reach the tool keys. +//! - Pressing the current group's own letter cycles the group, as a tap does, and doesn't spring. +//! - A canvas drag still in progress when the key comes up finishes with the temporary tool; the +//! previous tool returns once the button is up (as `hold_keys` latches Space). +//! - Another letter pressed while one is held wins (newest first); releasing every held letter +//! returns the original tool. Holding two letters at once counts as a hold. +//! - Key repeats are not presses: `shortcuts::handle` drops them for tool letters. +//! +//! Z is one case: hold Z to zoom, add ⌥ to zoom out, as with the Zoom tool itself (and as +//! `hold_keys` does for ⌥ with a held Zoom In). +//! +//! The state is [`crate::state::UiState::spring`], readable over the control channel. + +use egui::Key; +use serde::{Deserialize, Serialize}; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// How long (seconds) a tool letter must stay down for its tool to be temporary. +pub const SPRING_HOLD_SECS: f64 = 0.3; + +/// A tool switch made by a letter that is still down (or whose drag is still finishing). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spring { + /// The tool to give back. + pub previous: Tool, + /// The tool the held letter picked. If the tool changes any other way while it's held (the + /// toolbar, a menu, automation), that choice stands and nothing springs back. + pub tool: Tool, + /// The tool letters held, oldest first. + pub keys: Vec, + /// `egui` input time of the first press. + pub since: f64, + /// The canvas was pressed (or a second letter pressed) while held: the switch is temporary + /// however short the hold. + pub used: bool, + /// Every letter is up and the switch is temporary, but a drag is still in progress. + pub released: bool, +} + +/// The egui key of a tool letter (`None` for the tools without one). +pub fn key_of(letter: char) -> Option { + if letter == '\0' { + return None; + } + Key::from_name(&letter.to_string()) +} + +/// Is `k` some tool's single-key shortcut? +pub fn is_tool_key(k: Key) -> bool { + Tool::ALL.iter().any(|t| key_of(t.key()) == Some(k)) +} + +/// A tool letter switched the tool from `before` (the switch is already made). `plain` is false +/// when ⇧ or ⌥ was held: those switches are permanent. `now` is the input time. +pub fn pressed(app: &mut PhotocraftApp, letter: char, before: Tool, plain: bool, now: f64) { + let active = app.ui.spring.as_ref().is_some_and(|s| !s.released); + if !plain { + app.ui.spring = None; + return; + } + if active { + if let Some(s) = app.ui.spring.as_mut() { + s.keys.retain(|k| *k != letter); + s.keys.push(letter); + s.used = true; + s.tool = app.ui.tool; + } + return; + } + // The current group's own key cycles it (or keeps it), as a tap always has. + let tool = app.ui.tool; + app.ui.spring = (before.key() != letter).then(|| Spring { previous: before, tool, keys: vec![letter], since: now, used: false, released: false }); +} + +/// The canvas was pressed: a held letter's tool becomes temporary. +pub fn note_canvas_press(app: &mut PhotocraftApp) { + if let Some(s) = app.ui.spring.as_mut().filter(|s| !s.released) { + s.used = true; + } +} + +/// Once a frame, before the key handling: notice released letters and give the previous tool +/// back when the switch was temporary and no drag is in progress. +pub fn tick(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(mut s) = app.ui.spring.take() else { return }; + // Type editing begun with the temporary Type tool keeps it: the edit owns the tool now. So + // does a tool picked another way while the letter is down. + if app.ui.text_edit.is_some() || app.ui.tool != s.tool { + return; + } + let (now, button) = ctx.input(|i| (i.time, i.pointer.any_down())); + let dragging = app.drag.is_some() || button; + if !s.released { + if app.drag.is_some() { + s.used = true; + } + s.keys.retain(|k| key_of(*k).is_some_and(|k| ctx.input(|i| i.key_down(k)))); + if !s.keys.is_empty() { + app.ui.spring = Some(s); + return; + } + if !s.used && now - s.since < SPRING_HOLD_SECS { + // A tap: the switch stands. + return; + } + s.released = true; + } + if dragging { + app.ui.spring = Some(s); + ctx.request_repaint(); + return; + } + app.ui.tool = s.previous; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/spring_tools/tests.rs b/crates/ui-egui/src/spring_tools/tests.rs new file mode 100644 index 0000000000..eef327c096 --- /dev/null +++ b/crates/ui-egui/src/spring_tools/tests.rs @@ -0,0 +1,269 @@ +//! #2672 through the real key handling and canvas: a tapped tool letter switches for good, a held +//! one (or one used on the canvas) springs back on release. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +use super::*; +use crate::canvas::ViewXform; + +/// Frames are 1/60 s apart, so a hold is a count of frames. +const DT: f32 = 1.0 / 60.0; + +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.sync_views(); + app.ui.extras.rulers = false; + app.ui.tool = tool; + let mut h = Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(DT).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + if !ctx.fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::shortcuts::handle(app, &ctx); + egui::CentralPanel::default().show(ui, |ui| crate::canvas::document_area(app, ui)); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + let v = &mut h.state_mut().ui.views[0]; + v.zoom = 1.0; + v.center = [200.0, 150.0]; + v.fit_pending = false; + h.run_steps(2); + h +} + +fn screen(h: &Harness<'static, PhotocraftApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let v = &app.ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(app, app.last_canvas_rect), + zoom: v.zoom, + center: v.center, + flip: app.ui.view.flip_horizontal, + rotation: v.rotation, + aspect: app.ui.view.display_aspect(), + }; + xf.to_screen(x, y) +} + +/// One key event with its modifiers, run as a frame. +fn key_event(h: &mut Harness<'static, PhotocraftApp>, k: Key, pressed: bool, repeat: bool, m: Modifiers) { + h.event(Event::ModifiersChanged(m)); + h.event(Event::Key { key: k, physical_key: None, pressed, repeat, modifiers: m }); + h.run_steps(1); +} + +fn down(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, true, false, Modifiers::NONE); +} + +fn up(h: &mut Harness<'static, PhotocraftApp>, k: Key) { + key_event(h, k, false, false, Modifiers::NONE); + h.run_steps(2); +} + +/// Wait `secs` of input time. +fn wait(h: &mut Harness<'static, PhotocraftApp>, secs: f64) { + h.run_steps((secs / f64::from(DT)).ceil() as usize); +} + +fn button(h: &mut Harness<'static, PhotocraftApp>, p: Pos2, pressed: bool, m: Modifiers) { + h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: m }); + h.run_steps(1); +} + +fn move_to(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32) { + let p = screen(h, x, y); + h.event(Event::PointerMoved(p)); + h.run_steps(1); +} + +fn click(h: &mut Harness<'static, PhotocraftApp>, x: f32, y: f32, m: Modifiers) { + move_to(h, x, y); + let p = screen(h, x, y); + button(h, p, true, m); + button(h, p, false, m); +} + +#[test] +fn a_tap_switches_tools_for_good() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert!(h.state().ui.spring.is_some(), "the press is pending: tap or hold"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn a_held_letter_gives_the_previous_tool_back() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + assert_eq!(h.state().ui.tool, Tool::Eraser, "the temporary tool is the tool shown meanwhile"); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn hold_z_click_zooms_then_a_quick_release_restores() { + let mut h = harness(Tool::Brush); + let z0 = h.state().ui.views[0].zoom; + down(&mut h, Key::Z); + click(&mut h, 200.0, 150.0, Modifiers::NONE); + up(&mut h, Key::Z); + let z1 = h.state().ui.views[0].zoom; + assert!(z1 > z0, "{z0} -> {z1}"); + assert_eq!(h.state().ui.tool, Tool::Brush, "a canvas click makes even a short hold temporary"); + assert!(!h.state().session.active().unwrap().history.can_undo(), "nothing was painted"); + // ⌥ with a held Z zooms out, as ⌥ does with the Zoom tool. + down(&mut h, Key::Z); + h.event(Event::ModifiersChanged(Modifiers::ALT)); + click(&mut h, 200.0, 150.0, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + up(&mut h, Key::Z); + assert!(h.state().ui.views[0].zoom < z1); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn shift_letter_cycles_and_never_springs() { + let mut h = harness(Tool::Brush); + h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); + key_event(&mut h, Key::B, true, false, Modifiers::SHIFT); + assert_eq!(h.state().ui.tool, Tool::Pencil); + assert_eq!(h.state().ui.spring, None); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::B, false, false, Modifiers::SHIFT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Pencil); + + // Without the preference ⇧E is still a permanent switch, however long it is held. + let mut h = harness(Tool::Brush); + key_event(&mut h, Key::E, true, false, Modifiers::SHIFT); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + key_event(&mut h, Key::E, false, false, Modifiers::SHIFT); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Eraser); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn key_repeats_are_not_presses() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + for _ in 0..8 { + key_event(&mut h, Key::L, true, true, Modifiers::NONE); + wait(&mut h, 0.05); + } + assert_eq!(h.state().ui.tool, Tool::Lasso, "repeats don't cycle the group"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn a_drag_keeps_the_temporary_tool_until_the_button_is_up() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee); + move_to(&mut h, 50.0, 50.0); + let p = screen(&h, 50.0, 50.0); + button(&mut h, p, true, Modifiers::NONE); + move_to(&mut h, 100.0, 90.0); + up(&mut h, Key::M); + assert_eq!(h.state().ui.tool, Tool::RectMarquee, "the drag finishes with the temporary tool"); + move_to(&mut h, 150.0, 110.0); + let p = screen(&h, 150.0, 110.0); + button(&mut h, p, false, Modifiers::NONE); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.spring, None); + let sel = h.state().session.active().unwrap().doc.selection.as_ref().map(|s| s.content_bounds()); + assert!(sel.is_some_and(|r| r.width() > 50 && r.height() > 30), "{sel:?}"); +} + +#[test] +fn the_newest_letter_wins_and_release_restores_the_original() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + down(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Lasso); + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso, "L is still held"); + up(&mut h, Key::L); + assert_eq!(h.state().ui.tool, Tool::Brush); +} + +#[test] +fn letters_type_while_editing_text() { + let mut h = harness(Tool::Type); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "a", "size": 40})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer: id, + caret: 1, + anchor: 1, + session: "spring-test".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); + h.event(Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.event(Event::Text("b".into())); + h.run_steps(1); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + up(&mut h, Key::B); + assert_eq!(h.state().ui.tool, Tool::Type); + assert_eq!(h.state().ui.spring, None); + let st = h.state().session.active().unwrap(); + match &st.doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => assert_eq!(t.text, "ab"), + _ => panic!("not text"), + } +} + +/// A tool picked another way while a letter is held (here, as the toolbar would) stands: the release +/// doesn't take it back. +#[test] +fn a_tool_picked_while_a_letter_is_held_stands() { + let mut h = harness(Tool::Brush); + down(&mut h, Key::E); + wait(&mut h, SPRING_HOLD_SECS + 0.1); + h.state_mut().ui.tool = Tool::Lasso; + up(&mut h, Key::E); + assert_eq!(h.state().ui.tool, Tool::Lasso); + assert_eq!(h.state().ui.spring, None); +} + +#[test] +fn plain_functions_follow_the_rules() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.tool = Tool::Eraser; + // The current group's own key cycles; no spring. + pressed(&mut app, 'E', Tool::Eraser, true, 0.0); + assert_eq!(app.ui.spring, None); + // A ⇧ switch clears any pending spring. + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + assert!(app.ui.spring.is_some()); + pressed(&mut app, 'L', Tool::Brush, false, 0.1); + assert_eq!(app.ui.spring, None); + // A canvas press marks a pending spring used; nothing to mark without one. + note_canvas_press(&mut app); + pressed(&mut app, 'B', Tool::Eraser, true, 0.0); + note_canvas_press(&mut app); + assert!(app.ui.spring.as_ref().is_some_and(|s| s.used && s.previous == Tool::Eraser)); + assert_eq!(key_of('\0'), None); + assert!(is_tool_key(Key::Z) && !is_tool_key(Key::F1)); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 02b4f005db..50f4b8b680 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1033,6 +1033,10 @@ pub struct UiState { /// one per group: the group's key brings it back, as in Photoshop (#2608). #[serde(default)] pub group_tools: Vec, + /// A tool letter still held (spring-loaded tool, #2672): the tool to give back on release. + /// Exposed to automation, never restored from settings. + #[serde(default, skip_deserializing)] + pub spring: Option, /// Recently opened file paths, most-recent first (File › Open Recent). Capped; de-duplicated. #[serde(default)] pub recent_files: Vec, @@ -1244,6 +1248,7 @@ impl Default for UiState { Self { tool: Tool::Brush, group_tools: Vec::new(), + spring: None, recent_files: Vec::new(), text_edit: None, type_transform: None, diff --git a/docs/gaps.md b/docs/gaps.md index 870613ae30..5a50685368 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -156,7 +156,7 @@ what an agent can't finish alone. ### G5. Transform, handles and modifiers - **Missing:** H/V skew fields and the full interpolation list; blend mode/opacity edits during a transform (#1384); path-point nudge; autoscroll at the edge while selecting (#2231); - hold-a-key spring-loaded tools (#2672); wheel and pinch behaviour (#2485, #2463, #1989); + wheel and pinch behaviour (#2485, #2463, #1989); toolbar press-and-hold flyouts (#2301). - **Done since the re-measure (2026-10-10 to 12):** right-click menu during Free Transform (TOOL-214-5); commit/cancel buttons in the options bar (#2442); transform keeps the layer's @@ -164,7 +164,8 @@ what an agent can't finish alone. locked layers refused before the drag (#2585, #2609); ⌘-drag temporary Move with box and snapping (#2681, #2684); Auto-Select box follows the picked layer (#2666, #2675); ⌘-arrow layer nudge (#2474, #2556); polygonal lasso Shift angles (#2352, #2396); double-click Zoom tool - to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625). + to 100% (#2508, #2527); tool-key memory in a group (#2608, #2625); hold-a-key spring-loaded + tools (#2672). - **Detail:** [`ui-parity.md`](ui-parity.md). - **Estimate:** 60–110 h. @@ -239,6 +240,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | | 2026-10-12 | minor | G11: Load/Save Actions `.atn` landed (AUTO-219-2 partial); remaining panel work listed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 76b59de719..70b503d0c8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -161,7 +161,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Behaviour | Status | Evidence | |---|---|---| | Space = Hand, ⌘Space / ⌘⌥Space = zoom in/out, spring-loaded while held | done | `hold_keys.rs` | -| Hold any tool letter for a temporary tool | missing | #2672 | +| Hold any tool letter for a temporary tool | done | `spring_tools.rs`: held ≥ 300 ms or used on the canvas, release gives the previous tool back; ⇧ never springs (#2672) | | Scrubby zoom | done | `zoom_tool.rs` | | Flick panning | done | `flick_pan.rs` (Preferences › Tools › Enable Flick Panning) | | Animated zoom | missing | unread pref; #566 | @@ -219,3 +219,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | ⇧-drag a guide out of a ruler lands on the nearest ruler tick (#3770) | | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | +| 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | From 0fbba911bad32654b0075796a21f1ba05c40af93 Mon Sep 17 00:00:00 2001 From: Mohammed Date: Tue, 13 Oct 2026 03:29:06 +0300 Subject: [PATCH 224/523] Quick Selection: cut window sizes in parallel, cancel the larger ones once one is kept (#4062) Releasing a Quick Selection stroke on a 24 MP photo froze the canvas for 0.2-0.5 s: the growing-window loop cut a window, found the selection touching its side, doubled the margin and started over, and on real photos every stroke needed 2-4 sizes. The window sizes are independent, so the next 4 are now cut at once (rayon) and the first in growing order that stays clear of its sides is kept: the same result as one after another. Once one is kept, larger windows of the batch stop through the cancel hook grid_cut_impl / maxflow_with already have, now also polled before the graph is built, before the max-flow and between the coarse and fine levels of cut_levels. wasm32 keeps the sequential loop. 36 strokes on a 6000x4000 photo: masks byte-identical to main, 21.9 s -> 13.9 s (-37%); small 30 px clicks +8 ms (setup of larger windows). Co-authored-by: Mohammed <24667144+syncroot@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/mod.rs | 6 ++ crates/algo/src/segment/quick.rs | 138 ++++++++++++++++++++++++++----- crates/algo/src/segment/tests.rs | 39 +++++++++ 3 files changed, 164 insertions(+), 19 deletions(-) diff --git a/crates/algo/src/segment/mod.rs b/crates/algo/src/segment/mod.rs index dba4ebfce5..90cc2f502c 100644 --- a/crates/algo/src/segment/mod.rs +++ b/crates/algo/src/segment/mod.rs @@ -288,6 +288,9 @@ fn grid_cut_impl( if count == 0 { return Some(out); } + if cancel() { + return None; + } let mut g = Graph::with_capacity(count as usize, count as usize * 4); for i in 0..n { if idx[i] != u32::MAX { @@ -330,6 +333,9 @@ fn grid_cut_impl( } } } + if cancel() { + return None; + } if OPTIMIZED { g.maxflow_with(cancel)?; } else { diff --git a/crates/algo/src/segment/quick.rs b/crates/algo/src/segment/quick.rs index 859575b2cc..8306b6e662 100644 --- a/crates/algo/src/segment/quick.rs +++ b/crates/algo/src/segment/quick.rs @@ -88,6 +88,19 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3 } pub(super) fn quick_select_using(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option { + quick_select_batched::(sampler, canvas, points, size, t, SPECULATE).map(|(region, _)| region) +} + +/// [`quick_select_using`] cutting `batch` window sizes at a time (1: one after another). Also +/// returns how many window sizes, smallest first, it took to find the kept one. +pub(super) fn quick_select_batched( + sampler: &dyn Sampler, + canvas: Rect, + points: &[(f32, f32)], + size: f32, + t: &Tuning, + batch: usize, +) -> Option<(Region, usize)> { if points.is_empty() || canvas.is_empty() { return None; } @@ -107,8 +120,15 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let step = working_step(canvas.width() as usize, canvas.height() as usize) as i32; let rw = r / step as f32; // Work in a window around the stroke; grow it while the selection runs into its border. - let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; - loop { + // The window's sides are free like the canvas' edges, so a selection that stays clear of + // them is kept; one that reaches a side inside the canvas is recomputed in a larger window. + // Index of the smallest window kept so far in this batch: larger ones stop cutting. + let kept = std::sync::atomic::AtomicUsize::new(usize::MAX); + let attempt = |index: usize, margin: i32| -> Attempt { + let cancel = || kept.load(std::sync::atomic::Ordering::Relaxed) < index; + if cancel() { + return Attempt::Cancelled; + } let snap0 = |v: i32, o: i32| o + (v - o).div_euclid(step) * step; let grown = bbox.inflate(margin).intersect(&canvas); let window = Rect::new(snap0(grown.x0, canvas.x0), snap0(grown.y0, canvas.y0), grown.x1, grown.y1); @@ -118,13 +138,13 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c let pts: Vec<(f32, f32)> = points.iter().map(|&(x, y)| ((x - window.x0 as f32) / s, (y - window.y0 as f32) / s)).collect(); let seeds = stroke_mask(&pts, rw.max(0.5), lw, lh); if !seeds.iter().any(|v| *v) { - return None; + return Attempt::NoSeeds; + } + if cancel() { + return Attempt::Cancelled; } - // The window's sides are free like the canvas' edges, so a selection that stays clear of - // them is the best over the whole canvas too (outside the window it could only add cost). - // One that reaches a side inside the canvas is recomputed in a larger window. let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1]; - let low = select_working_using::(&img, &seeds, rw, t); + let Some(low) = select_working_using::(&img, &seeds, rw, t, &cancel) else { return Attempt::Cancelled }; let touches = |side: usize| match side { 0 => (0..lh).any(|y| low[y * lw]), 1 => (0..lw).any(|x| low[x]), @@ -132,21 +152,87 @@ pub(super) fn quick_select_using(sampler: &dyn Sampler, c _ => (0..lw).any(|x| low[(lh - 1) * lw + x]), }; if (0..4).any(|k| inner[k] && touches(k)) { - margin = margin.saturating_mul(2); - continue; + return Attempt::Touches; } - let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); - return super::trim_region(Region { bbox: window, mask }); + kept.fetch_min(index, std::sync::atomic::Ordering::Relaxed); + Attempt::Done { window, low, lw, lh } + }; + let mut margin = (48.0f32).max(2.0 * rw) as i32 * step; + let mut sizes = 0; + loop { + // The attempts don't depend on each other, so a few window sizes are cut at once and the + // first in growing order that stays clear of its sides is kept: the same result as trying + // them one after another, without waiting for the smaller ones to fail first. Once one is + // kept, the larger ones still cutting stop, so a stroke settled by a small window costs + // about what that window alone costs. + let batch = margins_to_try(margin, bbox, canvas, batch); + kept.store(usize::MAX, std::sync::atomic::Ordering::Relaxed); + #[cfg(not(target_arch = "wasm32"))] + let results: Vec = { + use rayon::prelude::*; + batch.par_iter().enumerate().map(|(i, &m)| attempt(i, m)).collect() + }; + #[cfg(target_arch = "wasm32")] + let results: Vec = batch.iter().enumerate().map(|(i, &m)| attempt(i, m)).collect(); + for result in results { + sizes += 1; + match result { + Attempt::NoSeeds => return None, + // Only a window larger than one already kept is cancelled, and that one comes + // first in this order, so a cancelled attempt is never reached. + Attempt::Touches | Attempt::Cancelled => continue, + Attempt::Done { window, low, lw, lh } => { + let mask = upsample(&low, lw, lh, step as usize, window.width() as usize, window.height() as usize); + return super::trim_region(Region { bbox: window, mask }).map(|region| (region, sizes)); + } + } + } + let &last = batch.last()?; + margin = last.saturating_mul(2); } } +/// One window size's outcome in [`quick_select_using`]. +enum Attempt { + /// The stroke misses the window. + NoSeeds, + /// The selection reaches a side of the window inside the canvas. + Touches, + /// A smaller window of the same batch was kept first. + Cancelled, + Done { + window: Rect, + low: Vec, + lw: usize, + lh: usize, + }, +} + +/// Window sizes cut together: most strokes settle within this many doublings. +const SPECULATE: usize = 4; + +/// The next `count` margins from `margin`, doubling, stopping at the first one whose window +/// already covers the whole canvas (a larger one would be the same window). +pub(super) fn margins_to_try(margin: i32, bbox: Rect, canvas: Rect, count: usize) -> Vec { + let mut out = Vec::with_capacity(count.max(1)); + let mut m = margin; + for _ in 0..count.max(1) { + out.push(m); + if bbox.inflate(m).intersect(&canvas) == canvas { + break; + } + m = m.saturating_mul(2); + } + out +} + /// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are /// selected; returns the selection. pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { - select_working_using::(img, seeds, radius, t) + select_working_using::(img, seeds, radius, t, &|| false).unwrap_or_default() } -fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec { +fn select_working_using(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning, cancel: &dyn Fn() -> bool) -> Option> { let (w, h) = (img.w, img.h); let d = crate::selection::edt(seeds, w, h); let grow = (t.grow + t.grow_frac * radius).max(0.0); @@ -154,7 +240,10 @@ fn select_working_using(img: &RgbImage, seeds: &[bool], r let fixed: Vec = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect(); let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k); let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k)); - cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024)) + if cancel() { + return None; + } + cut_levels::(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024), cancel) } /// Band half-width (pixels) re-cut at each finer level. @@ -164,11 +253,19 @@ const BAND: f32 = 3.0; /// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two /// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds /// stay foreground. -fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec { +fn cut_levels( + img: &RgbImage, + fixed: &[u8], + cost_fg: &[f32], + weight: &dyn Fn(f32) -> f32, + scale: f32, + direct_px: usize, + cancel: &dyn Fn() -> bool, +) -> Option> { let (w, h) = (img.w, img.h); let zeros = vec![0.0f32; w * h]; if w * h <= direct_px || w < 8 || h < 8 { - return super::grid_cut_weighted::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2)); + return super::grid_cut_impl::(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2), cancel); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let coarse_img = img.downsample(2); @@ -183,7 +280,10 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } } } - let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px); + let coarse = cut_levels::(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px, cancel)?; + if cancel() { + return None; + } let proj: Vec = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect(); // Band: within BAND of the projected boundary. let edge: Vec = (0..w * h) @@ -194,7 +294,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 }) .collect(); if !edge.iter().any(|e| *e) { - return (0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect(); + return Some((0..w * h).map(|i| proj[i] || fixed[i] == HARD_FG).collect()); } let near = crate::selection::edt(&edge, w, h); let banded: Vec = (0..w * h) @@ -210,7 +310,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f3 } }) .collect(); - super::grid_cut_weighted::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2)) + super::grid_cut_impl::(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2), cancel) } /// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel. diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index c3a37dc6dd..4f741eb227 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -344,6 +344,45 @@ fn quick_select_with_points_far_off_canvas_selects_nothing() { } } +#[test] +fn quick_select_cuts_window_sizes_in_parallel_with_the_same_result() { + // Soft gradients and faint blobs let a selection spread, so most strokes need more than one + // window size; cutting several at once must keep exactly what one-after-another keeps. + let (w, h) = (1200usize, 900usize); + let img = RgbImage::from_fn(w, h, |x, y| { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let blob = (((fx * 9.0).sin() * (fy * 7.0).cos()) * 0.5 + 0.5) * 0.06; + [0.3 + 0.4 * fx + blob, 0.5 + 0.2 * fy, 0.7 - 0.3 * fx + blob] + }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + let t = quick::Tuning::default(); + let strokes: [(&[(f32, f32)], f32); 4] = [ + (&[(300.0, 300.0)], 30.0), + (&[(500.0, 400.0), (580.0, 400.0), (660.0, 420.0)], 60.0), + (&[(200.0, 700.0), (260.0, 650.0), (320.0, 700.0), (380.0, 650.0)], 30.0), + (&[(900.0, 200.0)], 120.0), + ]; + let mut most = 0; + for (pts, size) in strokes { + let one = quick::quick_select_batched::(&s, canvas, pts, size, &t, 1); + let many = quick::quick_select_batched::(&s, canvas, pts, size, &t, 4); + assert_eq!(one.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), many.as_ref().map(|(r, n)| (r.bbox, &r.mask, n)), "{pts:?} size {size}"); + most = most.max(one.map_or(0, |(_, n)| n)); + } + assert!(most > 1, "no stroke needed a larger window, so the test exercises nothing"); +} + +#[test] +fn quick_select_window_sizes_stop_at_the_canvas() { + let canvas = Rect::new(0, 0, 1000, 800); + let bbox = Rect::new(400, 300, 460, 360); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 4), vec![96, 192, 384, 768]); + // 768 already covers the canvas: no larger (identical) window is cut. + assert_eq!(quick::margins_to_try(384, bbox, canvas, 4), vec![384, 768]); + assert_eq!(quick::margins_to_try(96, bbox, canvas, 0), vec![96]); +} + #[test] fn quick_select_grows_without_leaking() { let img = two_regions(140, 100); From b1b3dff8f12e8b1862ba51e8bdf89582a8c8208b Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:02 +0200 Subject: [PATCH 225/523] PSD: a zero-length annotation entry no longer hangs the import (#4149) (#4117) Root cause: parse_entries sets the cursor to start + len after each Anno entry. An entry length below 8 (its own u32 plus the type) left the cursor on the same entry, so a count of 0xFFFFFFFF re-read it about 4.3e9 times, pushing an entry each time until allocation failed and the process aborted (not a panic, so the import guard could not catch it). Fix: reject an entry length below 8, so the block is reported unreadable and kept verbatim (the #944 behaviour); every entry now advances at least 8 bytes. Regression test covers lengths 0/4/7 for sndA and txtA entries plus the hostile 16-byte block. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/io/src/annotations_map.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/io/src/annotations_map.rs b/crates/io/src/annotations_map.rs index 28ab181c83..27b6c04477 100644 --- a/crates/io/src/annotations_map.rs +++ b/crates/io/src/annotations_map.rs @@ -133,6 +133,11 @@ fn parse_entries(data: &[u8]) -> Option> { for _ in 0..count { let start = r.p; let len = r.u32()? as usize; + // The length counts itself and the type: anything shorter would put the cursor back on + // this entry, and a hostile count would then loop until memory runs out. + if len < 8 { + return None; + } let end = start.checked_add(len)?; let raw = data.get(start..end)?; let kind = r.take(4)?; @@ -422,6 +427,22 @@ mod tests { assert!(!anno_unreadable(&d)); } + /// An entry length below 8 (its own `u32` plus the type) put the cursor back on the same + /// entry, so a count of 0xFFFFFFFF looped ~4.3e9 times and ran out of memory. + #[test] + fn short_entry_length_is_unreadable_not_a_loop() { + for len in [0u8, 4, 7] { + for kind in [b"sndA", b"txtA"] { + let mut b = vec![0, 2, 0, 1, 0, 0, 0, 3, 0, 0, 0, len]; + b.extend_from_slice(kind); + b.extend_from_slice(&[0; 64]); + assert!(parse_entries(&b).is_none(), "len {len} {kind:?}"); + } + } + let hostile = [0, 2, 0, 1, 0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0, b's', b'n', b'd', b'A']; + assert!(parse_anno(&hostile).is_none()); + } + #[test] fn scale_resource_roundtrip_and_keep_rules() { let s = MeasurementScale { pixel_length: 150.0, logical_length: 2.0, units: "mm".into() }; From b87180e14e454f55f1baa7a2cd6effd5abccc693 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:07 +0200 Subject: [PATCH 226/523] Swatches: reject colour values beyond f32 range so presets survive a restart (#4150) (#4118) Root cause: SwatchColor::from_param checked that each value was finite as an f64, then cast it to f32. A value like 1e39 became inf, and an HSB hue then wrapped (rem_euclid) to NaN. swatches.add stored Hsb([NaN, ..]), the prefs file wrote it as null, and on the next launch load_presets_json failed to parse the whole presets blob, so every user gradient, style, shape, tool/document preset and swatch fell back to the defaults. Fix: reject values whose magnitude exceeds f32::MAX with BadParams, for every colour model. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/presets/swatches.rs | 11 ++++++++++- crates/engine/src/swatch_cmds/tests.rs | 27 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index f46aad32da..eec8c2d0d8 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -166,7 +166,14 @@ impl SwatchColor { .and_then(Value::as_array) .ok_or("a colour needs `values`")? .iter() - .map(|x| x.as_f64().filter(|f| f.is_finite()).map(|f| f as f32).ok_or("colour values must be finite numbers")) + // Beyond f32 range a value turns into inf as f32 (an HSB hue then wraps to NaN, which + // the prefs file can't store), so reject it here. + .map(|x| { + x.as_f64() + .filter(|f| f.is_finite() && f.abs() <= f64::from(f32::MAX)) + .map(|f| f as f32) + .ok_or("colour values must be finite numbers within f32 range") + }) .collect::>()?; let need = |n: usize| if vals.len() == n { Ok(()) } else { Err(format!("a {model} colour has {n} values, got {}", vals.len())) }; let pct = |v: f32| (v / 100.0).clamp(0.0, 1.0); @@ -403,6 +410,8 @@ mod tests { json!({"model": "rgb", "values": ["a", 2, 3]}), json!({"model": "xyz", "values": [1, 2, 3]}), json!({"model": "gray", "values": []}), + json!({"model": "hsb", "values": [1e39, 100, 100]}), + json!({"model": "rgb", "values": [0, -1e39, 0]}), ] { assert!(SwatchColor::from_param(&bad).is_err(), "{bad}"); } diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index 4a92441b71..cb121a1832 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -289,3 +289,30 @@ fn library_limits() { assert_eq!(total(&g), MAX_SWATCHES); assert_eq!(g[0].name.chars().count(), MAX_NAME); } + +/// A value beyond f32 range used to pass the finite check, become inf as f32 and wrap to a NaN +/// hue, which the prefs file stores as `null`; the next launch then dropped every saved preset. +#[test] +fn values_beyond_f32_range_are_rejected_so_presets_survive_a_restart() { + let mut s = Session::new(); + s.execute("swatches.add", json!({"name": "Keep me", "color": {"model": "lab", "values": [40, 12.5, -7.25]}})).unwrap(); + let before = s.presets.swatches.clone(); + for (model, values) in [ + ("hsb", json!([1e39, 100, 100])), + ("hsb", json!([-1e39, 100, 100])), + ("rgb", json!([1e39, 0, 0])), + ("cmyk", json!([0, 0, 0, -1e300])), + ("lab", json!([50, 1e39, 0])), + ("gray", json!([1e39])), + ] { + let r = s.execute("swatches.add", json!({"name": "Bad", "color": {"model": model, "values": values}})); + assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{model} {values}: {r:?}"); + } + assert_eq!(s.presets.swatches, before, "a rejected colour leaves the library unchanged"); + // The largest f32 hue is still accepted and stays finite. + s.execute("swatches.add", json!({"name": "Big hue", "color": {"model": "hsb", "values": [f64::from(f32::MAX), 100, 100]}})).unwrap(); + let mut t = Session::new(); + t.load_prefs_json(&s.prefs_to_json()).unwrap(); + assert_eq!(t.presets.swatches, s.presets.swatches, "user swatches survive a save and reload"); + assert!(names(&t).contains(&"Keep me".to_string())); +} From b0451df33b93b8e082263cc5bc4322a9e1b036f8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:11 +0200 Subject: [PATCH 227/523] Compose: the effect cache follows Transparency Shapes Layer and Blend If (#4119) Root cause: layer_identity, the effect-map cache key, hashed pixels, masks, fill opacity and effects but not layer.advanced or layer.blend_if. Turning Transparency Shapes Layer off (which makes the whole layer the effect shape) or changing a styled group child's Blend If (which changes the composite the group's shape comes from) left the key and region unchanged, so stale maps were reused: a stroke kept rimming a hole until the cache was purged. Fix: hash layer.advanced and layer.blend_if in layer_identity (recursing into group children). Regression tests compare a warm render after each edit with a cold render after purge_effect_cache. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 7 ++++++- crates/compose/src/tests.rs | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index eafdb44616..ae9e53e39c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1387,7 +1387,8 @@ fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, O // --------------------------------------------------------------------------------------------- // Layer-effect map cache -/// Order-independent identity of a layer's pixels, masks, effects and (for groups) children. +/// Order-independent identity of a layer's pixels, masks, effects, Advanced Blending and Blend If +/// and (for groups) children. /// Pixels are identified by their copy-on-write tile pointers; cache entries pin a clone of the /// layer so those tiles (and their addresses) stay alive while the entry exists. fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHasher) { @@ -1445,6 +1446,10 @@ fn layer_identity(layer: &Layer, h: &mut std::collections::hash_map::DefaultHash format!("{vm:?}").hash(h); } format!("{:?}", layer.effects).hash(h); + // Transparency Shapes Layer picks the shape (content or the whole layer); a group child's + // Blend If and knockout change the composite a styled group's shape comes from. + layer.advanced.hash(h); + layer.blend_if.hash(h); h.write_u8(0xfe); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index aca7a10c92..e8bf005e59 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2487,3 +2487,44 @@ fn lab_documents_gradient_map_follows_lightness() { adjust::apply_in(&adj, &mut grey_buf, adjust::Transfer::Srgb, None, ColorMode::Rgb); assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } + +// The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape +// the maps are built from (a layer's own, or a styled group's children composite) without +// touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer +// had. Each case renders with the cache warm, edits, renders again and compares with a cold +// render. (A purge by a concurrent test only makes the warm render cold, never a false failure.) +#[test] +fn effect_cache_follows_transparency_shapes_layer() { + let mut d = doc_white(32, 32); + let mut l = solid_layer("holed", Rect::new(0, 0, 32, 32), [0.9, 0.2, 0.2, 1.0]); + l.surface_mut().unwrap().fill_rect(Rect::new(12, 12, 20, 20), &[0.0, 0.0, 0.0, 0.0]); + l.effects.items = vec![stroke(2.0, StrokePosition::Outside)]; + assert!(l.advanced.transparency_shapes); + d.layers.push(l); + let on = render(&d, d.bounds()); + d.layers[1].advanced.transparency_shapes = false; + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(on.px != cold.px, "the stroke should stop rimming the hole"); + assert!(warm.px == cold.px, "stale effect maps after turning Transparency Shapes Layer off"); +} + +#[test] +fn effect_cache_follows_a_group_childs_blend_if() { + let mut d = doc_white(32, 32); + let mut child = solid_layer("bw", Rect::new(4, 4, 16, 28), [0.0, 0.0, 0.0, 1.0]); + child.surface_mut().unwrap().fill_rect(Rect::new(16, 4, 28, 28), &[1.0, 1.0, 1.0, 1.0]); + let mut g = Layer::group("g", vec![child]); + g.effects.items = vec![Effect::DropShadow(Shadow { size: 2.0, spread: 0.0, ..shadow(3.0, 90.0) })]; + d.layers.push(g); + let before = render(&d, d.bounds()); + let LayerContent::Group(grp) = &mut d.layers[1].content else { panic!("not a group") }; + // Gray › This Layer: black point at 128 hides the black half. + grp.children[0].blend_if.set(0, [range([128, 128], [255, 255]), FULL]); + let warm = render(&d, d.bounds()); + purge_effect_cache(); + let cold = render(&d, d.bounds()); + assert!(before.px != cold.px, "Blend If should change the group's shadow"); + assert!(warm.px == cold.px, "stale group effect maps after a child's Blend If changed"); +} From 426747d7d2c974a7b5620dea3e876ae240de818b Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:15 +0200 Subject: [PATCH 228/523] Auto-Select: honour layer mask density when picking layers (#4153) (#4121) layer.pickAt (Auto-Select, the canvas right-click layer list and the Move tool's rollover highlight) read the raw layer-mask pixel, while the compositor uses LayerMask::value, which applies density (1 - d*(1 - v)). A hide-all mask at density 0 shows the layer in full, yet the click went through to the layer below. Read the mask through LayerMask::value, as the compositor does (it also returns 1 for a disabled mask). Regression test covers the click and the list form. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/pick_cmds.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index c8583305da..d3bee1c6ac 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -61,11 +61,8 @@ fn layers_hit(doc: &Document, x: i32, y: i32, type_bounds: bool) -> Vec None => 0.0, }, }; - let mask = l.mask.as_ref().filter(|m| m.enabled).map_or(1.0, |m| { - let mut v = [0.0f32]; - m.surface.read_pixel(x, y, &mut v); - v[0] - }); + // As the compositor sees it: density applied, all ones when the mask is disabled. + let mask = l.mask.as_ref().map_or(1.0, |m| m.value(x, y)); if alpha * mask > 0.0 { out.push(l.id); } @@ -445,4 +442,24 @@ mod tests { let mut empty = Session::new(); assert!(empty.execute("layer.pickAt", json!({"x": 1, "y": 1})).is_err()); } + + /// A hide-all mask at density 0 hides nothing (the compositor applies density through + /// `LayerMask::value`), so the layer stays pickable; at full density it is clicked through. + #[test] + fn mask_density_is_honoured_when_picking() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20})).unwrap(); + let bg = s.active().unwrap().doc.layers[0].id; + s.execute("layer.new.layer", json!({"name": "Red"})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + let red = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.layerMask.hideAll", json!({})).unwrap(); + // Full density: the mask hides the layer, so the click reaches the Background. + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], bg.0); + s.execute("layer.layerMask.edit", json!({"density": 0})).unwrap(); + assert_eq!(s.execute("layer.pickAt", json!({"x": 5, "y": 5, "select": false})).unwrap()["layer"], red.0); + let list = s.execute("layer.pickAt", json!({"x": 5, "y": 5, "list": true})).unwrap(); + let ids: Vec<_> = list["layers"].as_array().unwrap().iter().map(|l| l["layer"].clone()).collect(); + assert_eq!(ids, vec![json!(red.0), json!(bg.0)]); + } } From 1014c37eaa4381b94037be95b4b3026e07e7ed3a Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:20 +0200 Subject: [PATCH 229/523] Find and Replace Text: backward Change/Find moves past the replaced text (#4154) (#4122) After Replace Text the find cursor collapsed to the end of the replacement. A backward search takes the last match ending at or before the cursor, so when the replacement still matched (e.g. "cat" -> "Cat", case-insensitive) it found the replaced text again and never reached earlier matches. Collapse the cursor at the start of the replacement when searching backward (the end when forward), mirroring how forward search skips it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/edit_menu_cmds.rs | 5 ++++- crates/engine/src/edit_menu_cmds/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 103a5edc6e..4933ed93fc 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -903,7 +903,10 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(()) })?; let end = a + replace.len(); - s.edit_state.find_cursor = Some((id, end, end)); + // Collapse the cursor on the side the search moves away from, so the next search + // skips the replaced text even when the replacement still matches. + let c = if forward { end } else { a }; + s.edit_state.find_cursor = Some((id, c, c)); changed = Some((id, a, end)); if action == "change" { return Ok(json!({"changed": {"layer": id.0, "start": a, "end": end}})); diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 7b58891a82..0a1bab4633 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -468,3 +468,26 @@ fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { let r = s.execute("edit.findAndReplaceText", json!({"find": "abc", "allLayers": false, "action": "find"})).unwrap(); assert_eq!((r["found"]["layer"].as_u64(), r["found"]["text"].as_str()), (Some(id), Some("abc"))); } + +#[test] +fn backward_change_find_moves_past_the_replaced_text() { + // A backward Change/Find whose replacement still matches ("cat" -> "Cat", case-insensitive) + // must move on to the previous match, not find the replaced text again. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let p = |action: &str, forward: bool| json!({"find": "cat", "replace": "Cat", "action": action, "forward": forward, "allLayers": false}); + let f = s.execute("edit.findAndReplaceText", p("find", false)).unwrap(); + assert_eq!((f["found"]["start"].as_u64(), f["found"]["end"].as_u64()), (Some(8), Some(11)), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", false)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + + // Forward is unchanged: replace the first match, then find the next one. + let mut s = session(8); + s.execute("type.create", json!({"text": "cat dog cat", "x": 2, "y": 12})).unwrap(); + let f = s.execute("edit.findAndReplaceText", p("find", true)).unwrap(); + assert_eq!(f["found"]["start"].as_u64(), Some(0), "{f}"); + let r = s.execute("edit.findAndReplaceText", p("changeFind", true)).unwrap(); + assert_eq!((r["changed"]["start"].as_u64(), r["changed"]["end"].as_u64()), (Some(0), Some(3)), "{r}"); + assert_eq!((r["found"]["start"].as_u64(), r["found"]["end"].as_u64()), (Some(8), Some(11)), "{r}"); +} From 991ebe16d5ead1475d978ac7c73b6e5e2bd55942 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:24 +0200 Subject: [PATCH 230/523] Liquify, Puppet Warp and Perspective Warp: refuse locked smart objects (#4155) (#4123) Root cause: run_on_layer in distort_cmds.rs added the smart filter and returned for a smart object before it called check_locks, so a smart object with Lock All or Lock Pixels (its own or a parent group's) took the distortion and a history step. Fix: check the pixel and full locks (not position) before recording the smart filter, as regular filters do, and use the filters' error wording. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/distort_cmds.rs | 5 ++- crates/engine/src/distort_cmds/tests.rs | 59 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/distort_cmds.rs b/crates/engine/src/distort_cmds.rs index c164eaf679..55556c53d8 100644 --- a/crates/engine/src/distort_cmds.rs +++ b/crates/engine/src/distort_cmds.rs @@ -216,7 +216,7 @@ fn smart_layer(s: &Session) -> std::result::Result<(), String> { fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> { let locks = l.locks.union(group); if locks.all || locks.pixels || (position && locks.position) { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); } Ok(()) } @@ -244,6 +244,9 @@ fn run_on_layer(s: &mut Session, cmd: &str, label: &str, p: &Value, moves: bool, } let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; if let LayerContent::Smart(_) = l.content { + // As filters do: a pixel or full lock refuses the smart filter (the position lock + // doesn't, since the smart object itself stays put). + check_locks(l, group, false)?; let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true }; return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref()); } diff --git a/crates/engine/src/distort_cmds/tests.rs b/crates/engine/src/distort_cmds/tests.rs index d0ea88ed05..50a8a49e62 100644 --- a/crates/engine/src/distort_cmds/tests.rs +++ b/crates/engine/src/distort_cmds/tests.rs @@ -352,3 +352,62 @@ fn liquify_with_a_far_off_stroke_point_returns() { assert_eq!(active_surface(&s).read_region(Rect::new(0, 0, 80, 60)), before.read_region(Rect::new(0, 0, 80, 60)), "{depth}: off-canvas only"); } } + +/// The three distortions on the active smart object, each with a visible change. +fn smart_distortions() -> [(&'static str, Value); 3] { + let q = [[10.0, 10.0], [60.0, 10.0], [60.0, 40.0], [10.0, 40.0]]; + [ + (LIQUIFY, json!({"strokes": [{"tool": "twirlCw", "size": 40, "points": [[35, 25], [35, 25], [35, 25]]}]})), + (PUPPET_SMART, json!({"pins": [{"src": [30, 25], "dst": [34, 25]}], "mode": "rigid"})), + (PERSPECTIVE_SMART, json!({"planes": [{"src": q, "dst": [[12.0, 10.0], [58.0, 10.0], [60.0, 40.0], [10.0, 40.0]]}]})), + ] +} + +#[test] +fn locked_smart_objects_refuse_distortions() { + // As filters do: a pixel or full lock (the layer's own or its group's) refuses the smart + // filter, leaving the layer and the history as they were. + for lock in [json!({"all": true}), json!({"pixels": true})] { + for on_group in [false, true] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + if on_group { + s.execute("layer.new.groupFromLayers", json!({})).unwrap(); + } + s.execute("layer.lockLayers", lock.clone()).unwrap(); + s.execute("layer.select", json!({"layer": layer.0})).unwrap(); + let locks = s.active().unwrap().doc.effective_locks(layer); + assert!(locks.all || locks.pixels, "the lock reaches the smart object ({lock}, group: {on_group})"); + let steps = s.active().unwrap().history.past_len(); + let before = s.active().unwrap().doc.layer(layer).unwrap().clone(); + let r = s.execute(cmd, p); + assert!(r.is_err(), "{cmd} edited a smart object locked with {lock} (group: {on_group}): {r:?}"); + let st = s.active().unwrap(); + let LayerContent::Smart(sm) = &st.doc.layer(layer).unwrap().content else { panic!("not smart") }; + assert!(sm.smart_filters.is_empty(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.doc.layer(layer).unwrap().surface(), before.surface(), "{cmd} {lock} group: {on_group}"); + assert_eq!(st.history.past_len(), steps, "{cmd} {lock} group: {on_group}: no history step"); + } + } + } +} + +#[test] +fn unlocked_or_position_locked_smart_objects_take_distortions() { + // Filters don't check the position lock, so neither do the smart-filter distortions. + for lock in [None, Some(json!({"position": true})), Some(json!({"transparency": true}))] { + for (cmd, p) in smart_distortions() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + if let Some(lock) = &lock { + s.execute("layer.lockLayers", lock.clone()).unwrap(); + } + let steps = s.active().unwrap().history.past_len(); + s.execute(cmd, p).unwrap(); + assert_eq!(smart(&s).smart_filters.len(), 1, "{cmd} {lock:?}"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{cmd} {lock:?}"); + } + } +} From cb3aef575344861edc516c280ba246da1433a486 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:29 +0200 Subject: [PATCH 231/523] Count tool: Marker Size and Label Size fields take effect (#4156) (#4124) Root cause: the options bar sent markerSize/labelSize as f32 JSON floats (8.0) and count.setGroup read them with as_u64, which is None for floats, so the command returned Ok and changed nothing. Fix: count.setGroup accepts whole numbers sent as floats (UIs, agents and actions send them), rejects fractions, negatives and non-numbers with BadParams, and clamps in u64/f64 so huge values no longer wrap through `as u32`. The options bar now sends integers. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/analysis_cmds.rs | 25 +++++++++++++--- crates/engine/src/analysis_cmds/tests.rs | 37 ++++++++++++++++++++++++ crates/ui-egui/src/analysis_ui.rs | 22 +++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/analysis_cmds.rs b/crates/engine/src/analysis_cmds.rs index 1ba04dfe4c..64af849e59 100644 --- a/crates/engine/src/analysis_cmds.rs +++ b/crates/engine/src/analysis_cmds.rs @@ -842,6 +842,23 @@ fn count_delete_group(s: &mut Session, p: &Value) -> Result { Ok(count_json(doc(s)?.as_ref())) } +/// A size parameter (`markerSize`, `labelSize`) clamped to `lo..=hi`: `None` when absent or null. +/// Whole numbers sent as JSON floats (`8.0`, as UIs and recorded actions do) count; fractions, +/// negatives and non-numbers are bad params rather than silently ignored. Clamping happens in +/// `u64`/`f64`, so a huge value can't wrap through `as u32`. +fn whole_size(cmd: &str, p: &Value, key: &str, lo: u32, hi: u32) -> Result> { + let v = match p.get(key) { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + let n = match (v.as_u64(), v.as_f64()) { + (Some(n), _) => n.clamp(u64::from(lo), u64::from(hi)), + (None, Some(f)) if f.is_finite() && f >= 0.0 && f.fract() == 0.0 => f.clamp(f64::from(lo), f64::from(hi)) as u64, + _ => return Err(bad(cmd, format!("`{key}` = {v} must be a whole number ({lo}..={hi})"))), + }; + Ok(Some(u32::try_from(n).unwrap_or(hi))) +} + fn count_set_group(s: &mut Session, p: &Value) -> Result { const CMD: &str = "count.setGroup"; let d = doc(s)?; @@ -853,11 +870,11 @@ fn count_set_group(s: &mut Session, p: &Value) -> Result { if let Some(c) = color_of(p, "color") { grp.color = c; } - if let Some(v) = p.get("markerSize").and_then(Value::as_u64) { - grp.marker_size = (v as u32).clamp(1, 10); + if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? { + grp.marker_size = v; } - if let Some(v) = p.get("labelSize").and_then(Value::as_u64) { - grp.label_size = (v as u32).clamp(8, 72); + if let Some(v) = whole_size(CMD, p, "labelSize", 8, 72)? { + grp.label_size = v; } if let Some(v) = p.get("visible").and_then(Value::as_bool) { grp.visible = v; diff --git a/crates/engine/src/analysis_cmds/tests.rs b/crates/engine/src/analysis_cmds/tests.rs index c043665b11..e5272d558d 100644 --- a/crates/engine/src/analysis_cmds/tests.rs +++ b/crates/engine/src/analysis_cmds/tests.rs @@ -349,3 +349,40 @@ fn now_iso_shape() { assert_eq!(t.len(), 20, "{t}"); assert!(t.ends_with('Z') && &t[4..5] == "-"); } + +#[test] +fn count_set_group_takes_sizes_sent_as_whole_floats() { + // The options bar sends `{"markerSize": 8.0, "labelSize": 20.0}`; `as_u64` read neither, so + // the fields did nothing. + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 8.0, "labelSize": 20.0})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + // Out-of-range whole numbers clamp, also huge ones that `as u32` would have wrapped. + s.execute("count.setGroup", json!({"markerSize": 4_294_967_297u64, "labelSize": 1e300})).unwrap(); + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (10, 72)); +} + +#[test] +fn count_set_group_rejects_sizes_that_are_not_whole_numbers() { + let mut s = session(100, 100, 8); + s.execute("count.add", json!({"x": 10, "y": 10})).unwrap(); + s.execute("count.setGroup", json!({"markerSize": 3, "labelSize": 12})).unwrap(); + let past = s.active().unwrap().history.past_len(); + for p in [ + json!({"markerSize": 8.5}), + json!({"labelSize": 12.25}), + json!({"markerSize": -2}), + json!({"labelSize": -9.0}), + json!({"markerSize": "8"}), + json!({"labelSize": true}), + ] { + let err = s.execute("count.setGroup", p.clone()).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{p}: {err}"); + } + let g = &d(&s).measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (3, 12), "a rejected call changes nothing"); + assert_eq!(s.active().unwrap().history.past_len(), past); +} diff --git a/crates/ui-egui/src/analysis_ui.rs b/crates/ui-egui/src/analysis_ui.rs index b0ad27bdd3..09aaf17670 100644 --- a/crates/ui-egui/src/analysis_ui.rs +++ b/crates/ui-egui/src/analysis_ui.rs @@ -565,7 +565,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo ui.label(RichText::new(tl!("Label Size")).color(t.text_dim).size(11.0)); let b = crate::widgets::value_field(ui, &mut ls, 8.0..=72.0, "", 44.0).changed(); if a || b { - let _ = app.run("count.setGroup", json!({"markerSize": ms.round(), "labelSize": ls.round()})); + let _ = app.run("count.setGroup", count_size_params(ms, ls)); } } true @@ -613,6 +613,13 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo // ------------------------------------------------------------------ panels and dialogs +/// `count.setGroup` params for the options bar's Marker Size and Label Size fields, sent as JSON +/// integers (the fields edit `f32`s; `8.0` is a float on the wire). +fn count_size_params(marker: f32, label: f32) -> Value { + let whole = |v: f32, lo: f32, hi: f32| if v.is_finite() { v.round().clamp(lo, hi) as u32 } else { lo as u32 }; + json!({"markerSize": whole(marker, 1.0, 10.0), "labelSize": whole(label, 8.0, 72.0)}) +} + fn float_frame(t: &Tokens) -> egui::Frame { egui::Frame::NONE .fill(t.card) @@ -1223,4 +1230,17 @@ pub(crate) mod tests { assert!(empty.run("view.colorSamplers.add", json!({"x": 1, "y": 1})).is_err()); assert!(empty.run("view.colorSamplers.list", json!({})).is_err()); } + + #[test] + fn count_size_fields_send_whole_numbers_the_engine_applies() { + let p = count_size_params(7.6, 20.0); + assert_eq!(p, json!({"markerSize": 8, "labelSize": 20})); + assert!(p["markerSize"].is_u64() && p["labelSize"].is_u64()); + assert_eq!(count_size_params(f32::NAN, 500.0), json!({"markerSize": 1, "labelSize": 72})); + let (mut app, _ctx) = app(); + app.run("count.add", json!({"x": 10, "y": 10})).unwrap(); + app.run("count.setGroup", count_size_params(8.0, 20.0)).unwrap(); + let g = &app.session.active().unwrap().doc.measurement.count_groups[0]; + assert_eq!((g.marker_size, g.label_size), (8, 20)); + } } From a8a30aed33facd158e14ebc4220c03a62b26fc60 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:34 +0200 Subject: [PATCH 232/523] Layers panel: show the lock icon for every lock (#4158) (#4159) Root cause: the layer row's reserved width and indicators, and the Studio themes' lock button, treated a layer as locked only for transparency, position or all. A "Lock image pixels" or artboard lock left the row without its lock icon, and the Studio button drew "unlocked" although clicking it unlocked the layer (simple_lock_toggle checked all five). Fix: add Locks::any() in photocraft-doc and use it in all four places, so every lock shows the icon, as in Photoshop. Tests cover each lock on the row (reserved width and Indicator::Lock) and the Studio button state. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/lib.rs | 18 +++++++++ crates/ui-egui/src/layer_row_ui.rs | 4 +- crates/ui-egui/src/layer_row_ui/tests.rs | 47 +++++++++++++++++++++++- crates/ui-egui/src/panels.rs | 12 +++--- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 354de06d31..e2a46e4079 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -99,6 +99,11 @@ pub struct Locks { } impl Locks { + /// Whether any lock is set: Photoshop then shows the lock icon on the layer's row. + pub fn any(self) -> bool { + self.transparency || self.pixels || self.position || self.artboard || self.all + } + pub fn union(self, o: Locks) -> Locks { Locks { transparency: self.transparency || o.transparency, @@ -1195,6 +1200,19 @@ mod tests { Document::with_background("t", Size::new(100, 50), ColorMode::Rgb, SampleType::U8, Color::WHITE) } + #[test] + fn any_lock_counts_every_lock() { + assert!(!Locks::default().any()); + let each = [ + Locks { transparency: true, ..Locks::default() }, + Locks { pixels: true, ..Locks::default() }, + Locks { position: true, ..Locks::default() }, + Locks { artboard: true, ..Locks::default() }, + Locks { all: true, ..Locks::default() }, + ]; + assert!(each.iter().all(|k| k.any())); + } + #[test] fn unused_link_group_never_wraps() { let mut d = doc(); diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index fb229fcbc8..7ac725a7cb 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -71,7 +71,7 @@ pub fn layout(row: Rect, name_left: f32, items: &[(Indicator, f32)]) -> (Vec<(In /// Width the always-shown indicators of `l` take at the row's right (blend label excluded). pub fn reserved_width(l: &Layer) -> f32 { let mut w = RIGHT_PAD; - if l.locks.transparency || l.locks.position || l.locks.all { + if l.locks.any() { w += ICON_W + GAP; } if !l.effects.items.is_empty() { @@ -113,7 +113,7 @@ pub fn indicators( actions: &mut Vec<(String, Value)>, ) -> (f32, Vec<(Indicator, Rect)>, bool) { let t = Tokens::get(ui.ctx()); - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); let fx_font = theme::semibold(11.0); let blend_font = FontId::proportional(10.5); let has_fx = !l.effects.items.is_empty(); diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index f2ac633ba2..e134897cf9 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -3,7 +3,7 @@ use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{NodeT, Queryable}; use photocraft_doc::{Effect, LayerContent}; use serde_json::json; @@ -536,3 +536,48 @@ fn press_drag_release_on_the_fx_button_opens_that_style() { let effects = open[0].fields["effects"].as_array().unwrap(); assert!(effects.iter().any(|e| e["kind"] == json!("dropShadow")), "on Drop Shadow: {effects:?}"); } + +/// One layer per lock, each with only that lock set (#4158). +fn one_lock_each() -> (photocraft_engine::Session, Vec<(&'static str, u64)>) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + let mut ids = Vec::new(); + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let id = s.execute("layer.new.layer", json!({"name": key})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locks": {key: true}})).unwrap(); + ids.push((key, id)); + } + (s, ids) +} + +#[test] +fn every_lock_shows_the_lock_icon_on_the_row() { + // #4158: "Lock image pixels" and the artboard lock left the row without its lock icon. + // Photoshop shows it whenever any lock is set. + let (s, ids) = one_lock_each(); + let doc = &s.active().unwrap().doc; + for &(key, id) in &ids { + let l = doc.layers.iter().find(|l| l.id.0 == id).unwrap(); + assert_eq!(super::reserved_width(l), super::RIGHT_PAD + super::ICON_W + super::GAP, "{key}: room for the lock icon"); + } + let h = harness(s, 1.0, "promedium", 290.0); + let rows = recorded(&h.ctx); + for &(key, id) in &ids { + let r = rows.iter().find(|r| r.layer == id).unwrap_or_else(|| panic!("{key}: row drawn")); + assert!(r.indicators.iter().any(|(k, _)| *k == Indicator::Lock), "{key}: lock icon drawn ({:?})", r.indicators); + } +} + +#[test] +fn the_studio_lock_button_shows_every_lock() { + // #4158: the simple themes' lock button drew "unlocked" for a pixels or artboard lock, though + // clicking it then unlocked the layer. + for key in ["transparency", "pixels", "position", "artboard", "all"] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({"name": key})).unwrap(); + s.execute("layer.setProps", json!({"locks": {key: true}})).unwrap(); + let h = harness(s, 1.0, "studio", 290.0); + assert_eq!(h.get_by_label("Lock layer").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{key}"); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bb4d2f1e90..f0e0046711 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2013,9 +2013,8 @@ fn simple_lock_toggle(background: bool, l: &Layer) -> (String, Value) { if background { return ("layer.new.layerFromBackground".into(), json!({})); } - let k = &l.locks; - let locked = k.transparency || k.pixels || k.position || k.artboard || k.all; - let locks = if locked { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; + let locks = + if l.locks.any() { json!({"transparency": false, "pixels": false, "position": false, "artboard": false, "all": false}) } else { json!({"all": true}) }; ("layer.setProps".into(), json!({"layer": l.id.0, "locks": locks})) } @@ -2174,7 +2173,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } if !t.pro { - let locked = l.locks.transparency || l.locks.position || l.locks.all; + let locked = l.locks.any(); if icons::button(ui, if locked { "lock" } else { "lock-open" }, 22.0, locked, tl!("Lock layer")).clicked() { actions.push(simple_lock_toggle(bg, l)); } @@ -4604,7 +4603,7 @@ mod lock_tests { click_lock(&mut app); let l = &app.session.active().unwrap().doc.layers[0]; assert_eq!(l.name, "Layer 0"); - assert!(!(l.locks.transparency || l.locks.pixels || l.locks.position || l.locks.all)); + assert!(!l.locks.any()); erase_line(&mut app); assert!(!app.ui.status_error, "{}", app.ui.status); assert_eq!(app.session.active().unwrap().doc.layers[0].surface().unwrap().rgba(50, 40)[3], 0.0, "erased to transparency"); @@ -4612,8 +4611,7 @@ mod lock_tests { click_lock(&mut app); assert!(app.session.active().unwrap().doc.layers[0].locks.all); click_lock(&mut app); - let k = app.session.active().unwrap().doc.layers[0].locks; - assert!(!(k.transparency || k.pixels || k.position || k.artboard || k.all)); + assert!(!app.session.active().unwrap().doc.layers[0].locks.any()); // A layer with only transparency locked unlocks in one click. app.run("layer.setProps", json!({"locks": {"transparency": true}})).unwrap(); click_lock(&mut app); From f4b73f42ed3795b0b77cef995e8335148c3ac9ce Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:40 +0200 Subject: [PATCH 233/523] Automation: reject an invalid document index instead of using the active document (#4160) (#4161) Root cause: the JSON-lines server's `index_of` read `index` with `as_u64`, so a string, negative, fractional or boolean index became None, which means "the active document". `doc.close {"index":"0"}` therefore closed the active (possibly unsaved) document instead of failing. The MCP server types the param as Option and already rejects these values. Fix: `index_of` returns Result, AutomationError>. A missing or null index still means the active document; a non-negative integer is used; anything else is a bad request ("`index` must be a non-negative integer"). All doc.* call sites propagate the error with `?`. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/automation/src/rpc.rs | 48 ++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/automation/src/rpc.rs b/crates/automation/src/rpc.rs index 1599eb6a53..5d44f0e7a7 100644 --- a/crates/automation/src/rpc.rs +++ b/crates/automation/src/rpc.rs @@ -60,8 +60,13 @@ fn bad(msg: impl Into) -> AutomationError { AutomationError::BadRequest(msg.into()) } -fn index_of(p: &Value) -> Option { - p.get("index").and_then(Value::as_u64).map(|i| i as usize) +/// The optional `index` param: missing or null means the active document; anything else must be a +/// non-negative integer (never silently fall back to the active document, which `doc.close` would close). +fn index_of(p: &Value) -> Result, AutomationError> { + match p.get("index") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| bad("`index` must be a non-negative integer")), + } } fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> { @@ -109,7 +114,7 @@ impl Headless { let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.place(index_of(&p), path, &Value::Object(opts)) + self.place(index_of(&p)?, path, &Value::Object(opts)) } "doc.new" => self.command_run("file.new", p), "doc.save" => { @@ -123,7 +128,7 @@ impl Headless { opts.encode.webp_lossless = false; } let path = str_of(&p, "path").map(PathBuf::from); - self.save(index_of(&p), path.as_deref(), str_of(&p, "format"), &opts) + self.save(index_of(&p)?, path.as_deref(), str_of(&p, "format"), &opts) } "doc.exportLayers" | "doc.exportDataSets" => { let dir = str_of(&p, "dir").ok_or_else(|| bad(format!("{method} needs `dir`")))?; @@ -131,16 +136,16 @@ impl Headless { opts.remove("dir"); opts.remove("index"); let opts = Value::Object(opts); - if method == "doc.exportLayers" { self.export_layers(index_of(&p), dir, &opts) } else { self.export_data_sets(index_of(&p), dir, &opts) } + if method == "doc.exportLayers" { self.export_layers(index_of(&p)?, dir, &opts) } else { self.export_data_sets(index_of(&p)?, dir, &opts) } } "doc.importDataSets" => { let path = str_of(&p, "path").ok_or_else(|| bad("doc.importDataSets needs `path`"))?; let mut opts = p.as_object().cloned().unwrap_or_default(); opts.remove("path"); opts.remove("index"); - self.import_data_sets(index_of(&p), path, &Value::Object(opts)) + self.import_data_sets(index_of(&p)?, path, &Value::Object(opts)) } - "doc.inspect" => self.inspect(index_of(&p)), + "doc.inspect" => self.inspect(index_of(&p)?), "doc.render" => { let max = match p.get("maxSide") { None => 1024, @@ -148,7 +153,7 @@ impl Headless { value.as_u64().and_then(|value| u32::try_from(value).ok()).ok_or_else(|| bad("maxSide must be an unsigned 32-bit integer"))? } }; - let png = self.render_png(index_of(&p), max)?; + let png = self.render_png(index_of(&p)?, max)?; match str_of(&p, "path") { Some(path) => { self.write_render(&PathBuf::from(path), &png)?; @@ -161,10 +166,10 @@ impl Headless { } } "doc.select" => { - let i = index_of(&p).ok_or_else(|| bad("doc.select needs `index`"))?; + let i = index_of(&p)?.ok_or_else(|| bad("doc.select needs `index`"))?; self.select(i) } - "doc.close" => self.close(index_of(&p)), + "doc.close" => self.close(index_of(&p)?), "batch" => self.batch(&p), "methods" => Ok(json!(METHODS)), other => Err(bad(format!("unknown method `{other}` (try `methods`)"))), @@ -830,4 +835,27 @@ mod tests { assert_eq!(v["error"], "authentication required"); assert!(v.get("result").is_none()); } + + #[test] + fn invalid_document_index_is_rejected_not_the_active_document() { + let h = session(); + for _ in 0..2 { + assert_eq!(respond(&h, r#"{"method":"doc.new","params":{"width":8,"height":8}}"#)["ok"], true); + } + let count = |h: &Mutex| h.lock().unwrap().session_list()["documents"].as_array().unwrap().len(); + for index in [json!("0"), json!(-1), json!(0.5), json!(true)] { + for method in ["doc.close", "doc.inspect", "doc.select"] { + let reply = respond(&h, &json!({"method": method, "params": {"index": index}}).to_string()); + assert_eq!(reply["ok"], false, "{method} {index}: {reply}"); + assert!(reply["error"].as_str().unwrap().contains("`index` must be a non-negative integer"), "{reply}"); + } + assert_eq!(count(&h), 2, "index {index} closed a document"); + } + // Null means "the active document", like a missing `index`. + assert_eq!(respond(&h, r#"{"method":"doc.inspect","params":{"index":null}}"#)["ok"], true); + // A valid index still closes that document. + let reply = respond(&h, r#"{"method":"doc.close","params":{"index":0}}"#); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(count(&h), 1); + } } From a256f1c579769f12cf1f8032fc6d4a36b13f6e94 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:47 +0200 Subject: [PATCH 234/523] Paint Bucket and Gradient: validate opacity like the Magic Eraser (#4167) (#4168) paint.bucket and paint.gradient read `opacity` as f(p,"opacity",100)/100 with no range check, so 200 became a coverage of 2 and overshot the blend (#808080 on white painted near-black). Both now use the Magic Eraser's param reader: the value is clamped to 1..100, and a non-numeric opacity is BadParams before any edit or history step. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/eraser_cmds.rs | 4 +- crates/engine/src/paint_cmds.rs | 66 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs index 3fb546de4f..920c96a014 100644 --- a/crates/engine/src/eraser_cmds.rs +++ b/crates/engine/src/eraser_cmds.rs @@ -27,7 +27,9 @@ fn flag(p: &Value, k: &str, d: bool) -> bool { } /// A finite number param clamped to `range`, `default` when absent; non-numbers are an error. -fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { +/// A numeric param clamped to `range`; missing or null gives `default`, anything else not a +/// finite number is BadParams. Shared with the Paint Bucket and Gradient opacity. +pub(crate) fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { match p.get(k) { None | Some(Value::Null) => Ok(default), Some(v) => { diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index d97ca5b319..4dc39f1517 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -35,11 +35,18 @@ fn point(p: &Value, k: &str) -> Option<(f32, f32)> { Some((a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) } +/// `opacity` (1..100 %) as a coverage in 0.01..=1, clamped like the Magic Eraser's: a value +/// above 1 would overshoot the blend (#4167). +fn opacity(p: &Value, cmd: &str) -> Result { + Ok(crate::eraser_cmds::number(p, cmd, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0) +} + fn bucket(s: &mut Session, p: &Value) -> Result { let fg = s.tools.foreground; let c = color(p.get("color"), fg); let (x, y) = (f(p, "x", 0.0).floor() as i32, f(p, "y", 0.0).floor() as i32); - let (tol, contiguous, aa, opacity) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true), f(p, "opacity", 100.0) / 100.0); + let (tol, contiguous, aa) = (f(p, "tolerance", 32.0), b(p, "contiguous", true), b(p, "antiAlias", true)); + let opacity = opacity(p, "paint.bucket")?; let all = b(p, "sampleAllLayers", false) && crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels; // Other visible layers can define the region, but the fill still writes only to the target. let region = if all { @@ -117,7 +124,7 @@ fn gradient(s: &mut Session, p: &Value) -> Result { } }; let reverse = b(p, "reverse", false); - let opacity = f(p, "opacity", 100.0) / 100.0; + let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); let dither = b(p, "dither", true); // Photoshop dithers gradients by default (reduces banding). s.edit("Gradient", |doc, active| { @@ -407,4 +414,59 @@ mod tests { let noisy = run(true); assert!(noisy.windows(2).any(|w| w[0] != w[1]), "dither: per-pixel variation"); } + + /// The two commands under test, each painting #808080 over a white document. + fn grey_fill(cmd: &str, opacity: Value) -> Value { + match cmd { + "paint.bucket" => json!({"x": 2, "y": 2, "color": "#808080", "opacity": opacity}), + _ => json!({"from": [0, 0], "to": [19, 0], "colors": ["#808080", "#808080"], "dither": false, "opacity": opacity}), + } + } + + fn white(depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 10, "depth": depth, "background": "white"})).unwrap(); + s + } + + /// Issue #4167: an opacity above 100 overshot the blend (200 painted near-black on white). + #[test] + fn bucket_and_gradient_clamp_opacity_like_the_magic_eraser() { + for cmd in ["paint.bucket", "paint.gradient"] { + for depth in [8, 16, 32] { + let mut full = white(depth); + full.execute(cmd, grey_fill(cmd, json!(100))).unwrap(); + let expected = px(&full, 5, 5); + assert!((expected[0] - 128.0 / 255.0).abs() < 0.01, "{cmd} depth {depth}: 100% paints #808080, got {expected:?}"); + for over in [200.0, 1e9] { + let mut s = white(depth); + s.execute(cmd, grey_fill(cmd, json!(over))).unwrap(); + assert_eq!(px(&s, 5, 5), expected, "{cmd} depth {depth}: opacity {over} clamps to 100"); + } + let (mut low, mut one) = (white(depth), white(depth)); + low.execute(cmd, grey_fill(cmd, json!(-50))).unwrap(); + one.execute(cmd, grey_fill(cmd, json!(1))).unwrap(); + assert_eq!(px(&low, 5, 5), px(&one, 5, 5), "{cmd} depth {depth}: opacity below 1 clamps to 1"); + assert!(px(&low, 5, 5).iter().all(|v| (0.0..=1.0).contains(v)), "{cmd} depth {depth}: no overshoot"); + } + } + } + + #[test] + fn bucket_and_gradient_reject_non_numeric_opacity_without_editing() { + for cmd in ["paint.bucket", "paint.gradient"] { + for bad in [json!("50"), json!(true), json!([50]), json!({"v": 50})] { + let mut s = white(8); + let (before, steps) = (px(&s, 5, 5), s.active().unwrap().history.past_len()); + let err = s.execute(cmd, grey_fill(cmd, bad.clone())).unwrap_err(); + assert!(matches!(err, EngineError::BadParams { .. }), "{cmd} {bad}: {err:?}"); + assert_eq!(px(&s, 5, 5), before, "{cmd} {bad}: pixels unchanged"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{cmd} {bad}: no history step"); + } + // An explicit null keeps the default (100%), like the Magic Eraser. + let mut s = white(8); + s.execute(cmd, grey_fill(cmd, Value::Null)).unwrap(); + assert!((px(&s, 5, 5)[0] - 128.0 / 255.0).abs() < 0.01, "{cmd}: null opacity is 100%"); + } + } } From e4bfa7907f77b62c21bd1a61f66427f43b18ffcc Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:52 +0200 Subject: [PATCH 235/523] Video layers: edits keep the timeline work area (#4170) (#4171) Every video-layer edit ran `work_end = work_end.max(duration)`, so Show Altered Video, Restore Frame, Interpret Footage and the other frame edits reset a narrowed work area to the full duration. A new `grow_duration` helper, used by every video edit and by New Video Layer from File, grows the timeline and moves the work-area end only when it reached the old end; a narrowed work area stays where the user put it. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/video_cmds.rs | 18 +++++-- crates/engine/src/video_cmds/tests.rs | 68 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index dbb51d82d2..2a9f8ae8b4 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -72,6 +72,17 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document".into()) } +/// Grow the timeline to at least `frames` frames (never shrink it). Only a work area that reached +/// the old end follows the new end; a narrowed one stays where the user put it (#4170). +fn grow_duration(t: &mut Timeline, frames: usize) { + let old = t.duration; + t.duration = t.duration.max(frames.max(1)); + if t.work_end >= old { + t.work_end = t.duration; + } + t.clamp(); +} + /// Edit the active video layer's [`VideoData`] at the playhead, then re-sync the displayed frame. fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize) -> Result<()>) -> Result { let count = s.edit(label, |doc, active| { @@ -83,9 +94,7 @@ fn edit_video(s: &mut Session, label: &str, f: impl FnOnce(&mut VideoData, usize f(v, cur)?; let n = v.frames.len(); if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n.max(1)); - t.work_end = t.work_end.max(t.duration); - t.clamp(); + grow_duration(t, n); } sync(doc); Ok(n) @@ -295,8 +304,7 @@ fn new_from_file(s: &mut Session, p: &Value) -> Result { if doc.timeline.is_none() { doc.timeline = Some(Timeline::new(n, fps)); } else if let Some(t) = &mut doc.timeline { - t.duration = t.duration.max(n); - t.clamp(); + grow_duration(t, n); } let name = doc.next_layer_name(std::path::Path::new(&path).file_stem().and_then(|s| s.to_str()).unwrap_or("Video")); let mut l = Layer::raster(name, fmt); diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 3380d3b9d0..22d639d49b 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -430,3 +430,71 @@ fn restoring_blank_video_frames_still_produces_blank_frames() { s.execute("layer.videoLayers.restoreFrame", json!({})).unwrap(); assert_eq!(layer_px(&s, id)[3], 0.0); } + +fn work_area(s: &Session) -> (usize, usize, usize) { + let t = s.active().unwrap().doc.timeline.as_ref().unwrap(); + (t.work_start, t.work_end, t.duration) +} + +#[test] +fn video_edits_keep_a_narrowed_work_area() { + // #4170: a video edit pushed the work-area end to the full duration. + for (cmd, params) in [ + ("layer.videoLayers.showAlteredVideo", json!({})), + ("layer.videoLayers.restoreFrame", json!({})), + ("layer.videoLayers.interpretFootage", json!({"fps": 24})), + ] { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30)); + s.execute(cmd, params).unwrap(); + assert_eq!(work_area(&s), (2, 10, 30), "{cmd} moved the work area"); + } +} + +#[test] +fn a_full_work_area_grows_with_the_duration() { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 30, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newBlankVideoLayer", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 30, 30)); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (0, 31, 31)); + // A narrowed work area stays put when the duration grows. + s.execute("timeline.setProps", json!({"workStart": 2, "workEnd": 10})).unwrap(); + s.execute("layer.videoLayers.insertBlankFrame", json!({})).unwrap(); + assert_eq!(work_area(&s), (2, 10, 32)); +} + +#[test] +fn longer_footage_grows_only_a_full_work_area() { + let dir = tmpdir("workarea"); + for i in 0..5 { + write_png(&format!("{dir}/f{i:02}.png"), 16, 12, "#808080"); + } + // A work area covering everything follows the longer footage. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (0, 5, 5)); + // A narrowed one stays put. + let mut s = session(); + s.execute("timeline.create", json!({"duration": 3, "fps": 30})).unwrap(); + s.execute("timeline.setProps", json!({"workStart": 1, "workEnd": 2})).unwrap(); + s.execute("layer.videoLayers.newVideoLayerFromFile", json!({"path": dir})).unwrap(); + assert_eq!(work_area(&s), (1, 2, 5)); +} + +#[test] +fn grow_duration_never_shrinks_and_keeps_the_work_area_valid() { + let mut t = Timeline::new(10, 30.0); + grow_duration(&mut t, 4); + assert_eq!((t.duration, t.work_end), (10, 10)); + grow_duration(&mut t, 0); + assert_eq!((t.duration, t.work_end), (10, 10)); + t.work_end = 6; + grow_duration(&mut t, 12); + assert_eq!((t.work_start, t.work_end, t.duration), (0, 6, 12)); +} From 43c7584d5da0539412b92fe75ac2d49ca7937513 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:37:56 +0200 Subject: [PATCH 236/523] Warp: move only the selected part of a linked mask (#4175) (#4176) With a selection, Edit > Transform > Warp warped only the selected pixels but the whole linked layer mask, so the unselected mask no longer lined up with the unselected pixels. Warp now limits the linked mask to the selection through transform_cmds::move_gray_selected, as Free Transform and Perspective Warp already do (#3083); layers that move whole still take their whole mask along. Adds a regression test driven by edit.transform.warp. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/warp_cmds.rs | 59 +++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index 844da50345..e64c93279d 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -118,6 +118,9 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } let map = |x: f64, y: f64| w.map(x, y); + // A selection limits the linked mask exactly as it limits the pixels (#4175, as Free Transform + // and Perspective Warp do, #3083); a layer that moves whole takes its whole mask along. + let mask_sel = doc_sel.filter(|_| !crate::transform_cmds::moves_whole(l)); match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -174,7 +177,11 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, if let Some(m) = l.mask.as_mut() && m.linked { - m.surface = warp_mesh_gray(&m.surface, &map, interp); + let whole = |m: &Surface| Ok(warp_mesh_gray(m, &map, interp)); + m.surface = match mask_sel { + Some(sel) => crate::transform_cmds::move_gray_selected(&m.surface, sel, &|lifted, src| Ok(warp_mesh_surface(lifted, src, &map, interp)), &whole)?, + None => whole(&m.surface)?, + }; } if let Some(vm) = l.vector_mask.as_mut() && vm.linked @@ -750,4 +757,54 @@ mod tests { let back: Warp = serde_json::from_value(r["warp"].clone()).unwrap(); assert_eq!(back.mesh.unwrap().us.len(), 2); } + + /// A selection limits the warp of a linked mask too, as it does the pixels (#4175; Free + /// Transform and Perspective Warp did this already, #3083): the moved part of the mask lines + /// up with the moved pixels and the unselected part stays put. + #[test] + fn linked_mask_with_a_selection_warps_only_the_selected_region() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 100, "height": 100})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("paint", |doc, active| { + let l = doc.layer_mut(active.unwrap()).unwrap(); + l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 20), &[1.0, 0.0, 0.0, 1.0]); + // Hide the left quarter (selected) and the right quarter (not selected). + let m = l.mask.as_mut().unwrap(); + m.surface.fill_rect(Rect::new(10, 10, 15, 20), &[0.0]); + m.surface.fill_rect(Rect::new(25, 10, 30, 20), &[0.0]); + m.linked = true; + Ok(()) + }) + .unwrap(); + let mask_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) + }; + let alpha_at = |s: &Session, x, y| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3] + }; + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + // A mesh that translates the selected box 50 px down. + let b = [10.0, 10.0, 20.0, 20.0]; + let mesh = BezierMesh::identity(b, 1, 1).map_points(|p| [p[0], p[1] + 50.0]); + s.execute("edit.transform.warp", json!({"rect": [10, 10, 20, 20], "mesh": mesh})).unwrap(); + // Moved pixels and their mask line up. + assert!(alpha_at(&s, 12, 65) > 0.99); + assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved"); + assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved"); + // The vacated area reads as revealed, with transparent pixels. + assert!(alpha_at(&s, 12, 15) < 0.01); + assert!(mask_at(&s, 12, 15) > 0.99); + // The unselected part of the mask stays, aligned with the unselected pixels. + assert!(alpha_at(&s, 27, 15) > 0.99); + assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put"); + assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along"); + for (x, y) in [(11, 61), (13, 68), (16, 61), (18, 68)] { + let want = if x < 15 { 0.0 } else { 1.0 }; + assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y}): {}", mask_at(&s, x, y)); + } + } } From 0fe109f7b21211196f36bf07254433ae918860af Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:38:01 +0200 Subject: [PATCH 237/523] Contours (.shc): keep long names valid and readable (#4177) (#4179) The writer capped names with encode_utf16().take(1024), which could end on a lone high surrogate; it now uses io::utf16_units, like .aco/.ase, so the cap falls on a character boundary. The reader's "count left out the null" check looked at the last unit kept, not the last unit read. For a name of 1024+ units the counted null is read but not kept, so a following 0x0000 (an empty contour's point count) was swallowed and the stream shifted. It now checks the last unit actually read. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/psd/src/shc.rs | 59 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/psd/src/shc.rs b/crates/psd/src/shc.rs index dfe4855719..135fd33e62 100644 --- a/crates/psd/src/shc.rs +++ b/crates/psd/src/shc.rs @@ -50,14 +50,17 @@ fn read_name(r: &mut Reader<'_>) -> Result { r.check_count(n, 2)?; let n = usize::try_from(n).map_err(|_| PsdError::LimitExceeded("contour name length"))?; let mut units = Vec::with_capacity(n.min(MAX_NAME_UNITS)); + // The last unit read, not the last one kept: past the cap the counted null is read but not kept. + let mut last = None; for _ in 0..n { let u = r.u16()?; + last = Some(u); if units.len() < MAX_NAME_UNITS { units.push(u); } } - // A count that leaves out the terminator: the null follows (a point count is never 0). - if units.last() != Some(&0) && r.peek_rest().get(..2) == Some(&[0, 0]) { + // A count that leaves out the terminator: the null follows. + if last != Some(0) && r.peek_rest().get(..2) == Some(&[0, 0]) { r.u16()?; } while units.last() == Some(&0) { @@ -128,7 +131,8 @@ pub fn write(contours: &[ShcContour]) -> Result> { for c in contours { let n = u16::try_from(c.points.len()).ok().filter(|n| usize::from(*n) <= MAX_POINTS).ok_or(PsdError::LimitExceeded("too many points in a contour"))?; out.put_u32(2); - let units: Vec = c.name.encode_utf16().take(MAX_NAME_UNITS).collect(); + // Capped on a character boundary, so a surrogate pair is never split. + let units = crate::io::utf16_units(&c.name, MAX_NAME_UNITS); out.put_u32(units.len() as u32 + 1); for u in units { out.put_u16(u); @@ -195,4 +199,53 @@ mod tests { let many = ShcContour { name: String::new(), points: vec![ShcPoint { input: 0.0, output: 0.0, continuous: true }; MAX_POINTS + 1] }; assert!(write(&[many]).is_err()); } + + fn pts(n: usize) -> Vec { + (0..n).map(|i| ShcPoint { input: (i * 10) as f32, output: (i * 20) as f32, continuous: i % 2 == 0 }).collect() + } + + #[test] + fn shc_name_cap_never_splits_a_surrogate_pair() { + let name = format!("{}\u{1F600}", "a".repeat(MAX_NAME_UNITS - 1)); + let c = ShcContour { name, points: pts(2) }; + let back = parse(&write(&[c]).unwrap()).unwrap(); + assert_eq!(back[0].name, "a".repeat(MAX_NAME_UNITS - 1)); + } + + #[test] + fn shc_names_at_and_over_the_cap_keep_the_stream_in_sync() { + for len in [MAX_NAME_UNITS, MAX_NAME_UNITS + 1, MAX_NAME_UNITS * 3] { + for first_points in [0, 3] { + let long = ShcContour { name: "b".repeat(len), points: pts(first_points) }; + let next = ShcContour { name: "Next".into(), points: pts(4) }; + let back = parse(&write(&[long, next.clone()]).unwrap()).unwrap_or_else(|e| panic!("len {len}, points {first_points}: {e}")); + assert_eq!(back.len(), 2); + assert_eq!(back[0].name, "b".repeat(len.min(MAX_NAME_UNITS))); + assert_eq!(back[0].points.len(), first_points); + assert_eq!(back[1], next, "len {len}, points {first_points}"); + } + } + } + + #[test] + fn shc_name_count_without_null_at_the_cap_still_reads() { + // A hostile/foreign file: 1024 units counted, no null counted, null follows. + let mut v = b"8BFS".to_vec(); + v.put_u16(1); + v.put_u32(1); + v.put_u32(1); + v.put_u32(MAX_NAME_UNITS as u32); + for _ in 0..MAX_NAME_UNITS { + v.put_u16(u16::from(b'c')); + } + v.put_u16(0); + v.put_u16(1); + v.put_u16(7); + v.put_u16(9); + v.put_u32(0); + v.put_u32(255); + let c = parse(&v).unwrap(); + assert_eq!(c[0].name, "c".repeat(MAX_NAME_UNITS)); + assert_eq!((c[0].points[0].input, c[0].points[0].output), (9.0, 7.0)); + } } From 2f0979f61f3bcdef408df66f2d19446f9dedceda Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 02:38:05 +0200 Subject: [PATCH 238/523] Global Light: reject angles beyond f32 range instead of storing NaN (#4181) (#4182) layer.layerStyle.globalLight cast its params straight to f32, so an angle of 1e39 became inf and rem_euclid turned it into NaN, which was stored, recorded as a history step and fed to every effect using global light. Angle and altitude are now read as f64 and rejected with BadParams when non-finite or beyond f32 range, before the edit. In-range wrapping and the altitude clamp are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/layer_menu_cmds.rs | 10 ++++++++-- crates/engine/src/layer_menu_cmds/tests.rs | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/layer_menu_cmds.rs b/crates/engine/src/layer_menu_cmds.rs index a895210c19..366f9c8d39 100644 --- a/crates/engine/src/layer_menu_cmds.rs +++ b/crates/engine/src/layer_menu_cmds.rs @@ -612,8 +612,14 @@ fn blend_range(v: &Value) -> Option { fn global_light(s: &mut Session, p: &Value) -> Result { let cur = s.active().ok_or(EngineError::NoDocument)?.doc.global_light; - let angle = num(p, "angle", cur.angle); - let altitude = num(p, "altitude", cur.altitude).clamp(0.0, 90.0); + // Read as f64 and reject what f32 can't hold: 1e39 would become inf, and rem_euclid NaN. + let f32_param = |key: &str, default: f32| match p.get(key).and_then(Value::as_f64) { + None => Ok(default), + Some(v) if v.is_finite() && v.abs() <= f64::from(f32::MAX) => Ok(v as f32), + Some(_) => Err(bad("layer.layerStyle.globalLight", format!("{key} must be a finite number"))), + }; + let angle = f32_param("angle", cur.angle)?; + let altitude = f32_param("altitude", cur.altitude)?.clamp(0.0, 90.0); let angle = (angle + 180.0).rem_euclid(360.0) - 180.0; s.edit("Global Light", |doc, _| { doc.global_light.angle = angle; diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index 51ad90500f..708bee5fce 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,24 @@ fn global_light_blending_options_scale_effects() { } } +#[test] +fn global_light_rejects_numbers_beyond_f32() { + let mut s = session(8, "rgb"); + // In-range values still wrap into -180..=180 and clamp to 0..=90. + s.execute("layer.layerStyle.globalLight", json!({"angle": 450, "altitude": 120})).unwrap(); + assert_eq!((doc(&s).global_light.angle, doc(&s).global_light.altitude), (90.0, 90.0)); + s.execute("layer.layerStyle.globalLight", json!({"angle": 540, "altitude": 30})).unwrap(); + assert_eq!((doc(&s).global_light.angle.abs(), doc(&s).global_light.altitude), (180.0, 30.0)); + let before = doc(&s).global_light; + let steps = s.active().unwrap().history.past_len(); + // 1e39 overflowed f32 to inf, which rem_euclid turned into a stored NaN (#4181). + for p in [json!({"angle": 1e39}), json!({"angle": -1e39}), json!({"altitude": 1e39}), json!({"altitude": -1e39})] { + assert!(s.execute("layer.layerStyle.globalLight", p.clone()).is_err(), "{p}"); + assert_eq!(doc(&s).global_light, before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } +} + #[test] fn blending_options_blend_if() { for depth in DEPTHS { From 137d7c2df14b6939db47bce19b72a9e6190a6c1c Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 19:44:01 -0500 Subject: [PATCH 239/523] Tests: set the Move tool's Auto-Select target to Layer in the layout_perf clicks (#4056) (#4548) #4093 changed the Auto-Select default to Group, as in Photoshop. The two layout_perf tests that click layers inside groups with Auto-Select on expected the layer under the pointer, so they now select the group and fail on main. They set the target to Layer explicitly, which is what they test. --- crates/ui-egui/tests/layout_perf.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/tests/layout_perf.rs b/crates/ui-egui/tests/layout_perf.rs index 502390c979..fb17833e18 100644 --- a/crates/ui-egui/tests/layout_perf.rs +++ b/crates/ui-egui/tests/layout_perf.rs @@ -132,6 +132,9 @@ fn auto_select_moves_the_layer_under_the_pointer() { actual_pixels(&mut h); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + // The default target is Group (#4056); these layers sit in groups, and the test is about the + // layer under the pointer. + h.state_mut().ui.tool_options.move_target = "layer".into(); h.state_mut().run("layer.select", json!({"layer": k.pixel.0})).expect("select"); h.run_steps(2); let (pixel_before, text_before) = (bounds(&h, k.pixel), bounds(&h, k.text)); @@ -150,6 +153,7 @@ fn a_move_tool_click_selects_without_moving() { let (mut h, k) = app(); h.state_mut().ui.tool = Tool::Move; h.state_mut().ui.tool_options.move_auto_select = true; + h.state_mut().ui.tool_options.move_target = "layer".into(); for id in [k.text, k.shape, k.pixel, k.text] { let before = (bounds(&h, id), h.state().session.active().expect("doc").history.entries().len()); let p = screen(&h, point_on(&h, id)); From e627b011692bff909935ac8a8e7c03e5caab0a09 Mon Sep 17 00:00:00 2001 From: Tiimo Schulze Date: Tue, 13 Oct 2026 02:50:00 +0200 Subject: [PATCH 240/523] feat: resize Save for Web previews and custom palette editing for Gif export (#4129) * feat: resize Save for Web previews and added palette editing for Gif/PNG8 * Save for Web: add Turkish and Hungarian rows for the preview navigation hint Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/examples/bench_web_palette.rs | 30 ++ crates/engine/src/web_cmds.rs | 107 +++- .../engine/src/web_cmds/color_table_tests.rs | 88 ++++ crates/engine/src/web_cmds/tests.rs | 3 + crates/ui-egui/src/dialogs.rs | 50 +- crates/ui-egui/src/file_ui.rs | 466 ++++++++++++------ crates/ui-egui/src/file_ui/web_preview.rs | 323 ++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/control-protocol.md | 1 + 27 files changed, 908 insertions(+), 179 deletions(-) create mode 100644 crates/engine/examples/bench_web_palette.rs create mode 100644 crates/engine/src/web_cmds/color_table_tests.rs create mode 100644 crates/ui-egui/src/file_ui/web_preview.rs diff --git a/crates/engine/examples/bench_web_palette.rs b/crates/engine/examples/bench_web_palette.rs new file mode 100644 index 0000000000..5ed1c3a3b0 --- /dev/null +++ b/crates/engine/examples/bench_web_palette.rs @@ -0,0 +1,30 @@ +//! Compare generated-palette and edited fixed-palette GIF exports on a 24 MP image. +//! cargo run --release -p photocraft-engine --example bench_web_palette +use std::time::Instant; + +use photocraft_engine::web_cmds::{WebSettings, optimize}; +use photocraft_geom::Rect; +use serde_json::json; + +fn main() -> Result<(), Box> { + let (w, h) = (6000usize, 4000usize); + let mut pixels = Vec::new(); + pixels.try_reserve_exact(w * h)?; + for y in 0..h { + for x in 0..w { + pixels.push([x as f32 / w as f32, 0.5 + ((x + y) as f32 * 0.002).sin() * 0.3, y as f32 / h as f32, 1.0]); + } + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colors": 128, "dither": "diffusion"}), "bench")?; + let rect = Rect::new(0, 0, w as i32, h as i32); + let started = Instant::now(); + let original = optimize(&pixels, w, rect, &settings, None, None, 72.0, false)?; + println!("24 MP generated palette: {:.1} ms", started.elapsed().as_secs_f64() * 1000.0); + let mut edited = settings; + edited.color_table = Some(original.color_table); + edited.color_table_edits.insert(0, [0, 255, 0]); + let started = Instant::now(); + let result = optimize(&pixels, w, rect, &edited, None, None, 72.0, false)?; + println!("24 MP edited fixed palette: {:.1} ms ({} bytes)", started.elapsed().as_secs_f64() * 1000.0, result.bytes.len()); + Ok(()) +} diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index a15cb258cf..cccddbf9f3 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -79,6 +79,10 @@ pub struct WebSettings { pub format: WebFormat, pub palette: PaletteKind, pub colors: usize, + /// Fixed opaque source palette in index order. + pub color_table: Option>, + /// Replacements applied after quantization, preserving pixel indices. + pub color_table_edits: std::collections::BTreeMap, pub dither: Dither, /// 0..=1. pub dither_amount: f32, @@ -104,6 +108,8 @@ impl Default for WebSettings { format: WebFormat::Png24, palette: PaletteKind::Selective, colors: 256, + color_table: None, + color_table_edits: Default::default(), dither: Dither::Diffusion, dither_amount: 0.88, transparency: true, @@ -196,6 +202,27 @@ impl WebSettings { m => return Err(bad(cmd, format!("unknown metadata `{m}` (none|copyright|copyrightAndContact|all)"))), }; } + if let Some(table) = p.get("colorTable") { + let table = table.as_array().filter(|a| (1..=256).contains(&a.len())).ok_or_else(|| bad(cmd, "colorTable must contain 1–256 #rrggbb colours"))?; + st.color_table = Some( + table + .iter() + .map(|v| v.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTable entries must be #rrggbb colours"))) + .collect::>>()?, + ); + } + if let Some(edits) = p.get("colorTableEdits") { + let edits = edits + .as_object() + .filter(|m| m.len() <= 256) + .ok_or_else(|| bad(cmd, "colorTableEdits must be an object of palette indices to #rrggbb colours"))?; + let table = st.color_table.as_ref().ok_or_else(|| bad(cmd, "colorTableEdits requires colorTable"))?; + for (index, value) in edits { + let index = index.parse::().ok().filter(|i| *i < table.len()).ok_or_else(|| bad(cmd, "colorTableEdits index is outside colorTable"))?; + let color = value.as_str().and_then(hex3).map(|c| c.map(to8)).ok_or_else(|| bad(cmd, "colorTableEdits values must be #rrggbb colours"))?; + st.color_table_edits.insert(index, color); + } + } Ok(st) } } @@ -233,10 +260,23 @@ 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]))); - 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, "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::>()); + } + if !self.color_table_edits.is_empty() { + p["colorTableEdits"] = + json!(self.color_table_edits.iter().map(|(i, c)| (i.to_string(), rgb_hex(*c))).collect::>()); + } + p } } +/// A palette colour in command/control-channel notation. +pub fn rgb_hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + /// Save for Web's named presets. pub const PRESETS: [&str; 12] = [ "GIF 128 Dithered", @@ -324,6 +364,11 @@ pub struct Optimized { pub height: u32, /// Colours in the palette (indexed formats). pub colors: Option, + /// Opaque source palette before replacements (empty for non-indexed formats). + pub color_table: Vec<[u8; 3]>, + pub transparent_index: Option, + /// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled. + pub indices: Vec, /// What the optimised file looks like, straight RGBA8 (for the dialog's previews). pub preview: Vec, } @@ -359,13 +404,14 @@ pub fn web_xmp(doc: &Document, m: WebMetadata) -> Option { /// the file looks like (the dialog's panes); exports skip it. #[allow(clippy::too_many_arguments)] pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: Option<&[u8]>, xmp: Option<&str>, dpi: f32, preview: bool) -> Result { - let (w, h) = (rect.width() as usize, rect.height() as usize); - if w == 0 || h == 0 { - return Err(other("empty slice")); + if bw == 0 || rect.x0 < 0 || rect.y0 < 0 || rect.is_empty() || rect.x1 as usize > bw || (rect.y1 as usize).checked_mul(bw).is_none_or(|end| end > px.len()) + { + return Err(other("slice is outside the pixel buffer")); } + let (w, h) = (rect.width() as usize, rect.height() as usize); let row = |y: i32| { let o = y as usize * bw; - &px[o + rect.x0 as usize..o + rect.x1 as usize] + px.get(o + rect.x0 as usize..o + rect.x1 as usize).unwrap_or(&[]) }; let region = || (rect.y0..rect.y1).flat_map(row); let matte = st.matte.unwrap_or([1.0, 1.0, 1.0]); @@ -375,6 +421,9 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O let (ww, hh) = (w as u32, h as u32); let e = |e: photocraft_codecs::CodecError| other(e); let mut look: Vec = Vec::new(); + let mut color_table = Vec::new(); + let mut transparent_index = None; + let mut indices = Vec::new(); let (bytes, colors) = match st.format { WebFormat::Png24 => { let alpha = st.transparency && region().any(|p| p[3] < 1.0); @@ -451,16 +500,22 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O }) .collect(); let room = if transparent { st.colors.saturating_sub(1).max(2) } else { st.colors }; - // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). - let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); - let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; - let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); - if src.is_empty() { - src = work.clone(); - } - let mut pal = quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)?; - drop(src); - if st.web_snap > 0.0 { + let mut pal = if let Some(table) = &st.color_table { + if table.is_empty() || table.len() > if transparent { 255 } else { 256 } { + return Err(other("colorTable has too many entries for this image's transparency")); + } + table.clone() + } else { + // Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all). + let n_opaque = work.iter().filter(|p| p[3] >= 0.5).count(); + let step = if n_opaque > 1 << 20 && st.palette != PaletteKind::Exact { n_opaque / (1 << 20) + 1 } else { 1 }; + let mut src: Vec<[f32; 4]> = work.iter().filter(|p| p[3] >= 0.5).step_by(step).copied().collect(); + if src.is_empty() { + src = work.clone(); + } + quantize::build_palette(&src, st.palette, room, Forced::None).map_err(other)? + }; + if st.web_snap > 0.0 && st.color_table.is_none() { let tol = (st.web_snap * 255.0) as u8; for c in &mut pal { let snap = c.map(|x| ((f32::from(x) / 51.0).round() * 51.0) as u8); @@ -472,16 +527,23 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O if transparent { pal.truncate(255); } + let first = pal.first().copied().ok_or_else(|| other("empty colour table"))?; + color_table = pal.clone(); let t = transparent.then(|| { - pal.push(pal[0]); + pal.push(first); pal.len() - 1 }); + transparent_index = t; let idx = quantize_rows(&mut work, w, &pal, st.dither, st.dither_amount, t); + for (index, color) in &st.color_table_edits { + let slot = pal.get_mut(*index).filter(|_| Some(*index) != t).ok_or_else(|| other("colour edit is outside the opaque palette"))?; + *slot = *color; + } if preview { look = idx .iter() .flat_map(|k| { - let c = pal[*k as usize]; + let c = pal.get(*k as usize).copied().unwrap_or(first); [c[0], c[1], c[2], if Some(*k as usize) == t { 0 } else { 255 }] }) .collect(); @@ -492,10 +554,13 @@ pub fn optimize(px: &[[f32; 4]], bw: usize, rect: Rect, st: &WebSettings, icc: O } else { photocraft_codecs::encode_png_indexed(ww, hh, &idx, &pal, tt, None).map_err(e)? }; + if preview { + indices = idx; + } (bytes, Some(pal.len())) } }; - Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, preview: look }) + Ok(Optimized { bytes, ext: st.format.ext(), width: ww, height: hh, colors, color_table, transparent_index, indices, preview: look }) } /// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each @@ -811,7 +876,9 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u // Estimate only (the dialog's annotations): the whole image, or each chosen slice. if single.is_none() && dir.is_none() { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; - return Ok(json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); + return Ok( + json!({"format": o.ext, "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors, "colorTable": o.color_table.iter().map(|c| rgb_hex(*c)).collect::>(), "transparentIndex": o.transparent_index}), + ); } if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; @@ -1378,7 +1445,7 @@ pub fn specs() -> Vec { "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,"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","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} estimate; else {files, html, bytes}"##, + 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"="#ffffff","interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|all"="copyright","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}"##, has_doc, save_for_web ), diff --git a/crates/engine/src/web_cmds/color_table_tests.rs b/crates/engine/src/web_cmds/color_table_tests.rs new file mode 100644 index 0000000000..1d319cb382 --- /dev/null +++ b/crates/engine/src/web_cmds/color_table_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn color_replacement_preserves_pixel_membership_and_transparency() { + let px = vec![[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.6, 0.0, 0.4, 1.0], [0.0, 0.0, 0.0, 0.0]]; + for format in ["gif", "png8"] { + for dither in ["none", "diffusion", "pattern", "noise"] { + let p = json!({"format": format, "colorTable": ["#ff0000", "#0000ff"], "dither": dither, "matte": "none"}); + let original = WebSettings::from_params(&p, "test").unwrap(); + let before = optimize(&px, 4, Rect::new(0, 0, 4, 1), &original, None, None, 72.0, true).unwrap(); + let mut edited = p.clone(); + // Replace red with blue: re-quantization would now send all opaque pixels to blue. + edited["colorTableEdits"] = json!({"0": "#0000ff", "1": "#00ff00"}); + let settings = WebSettings::from_params(&edited, "test").unwrap(); + assert_eq!(WebSettings::from_params(&settings.to_params(), "test").unwrap(), settings); + let after = optimize(&px, 4, Rect::new(0, 0, 4, 1), &settings, None, None, 72.0, true).unwrap(); + for (old, new) in before.preview.as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + if old[3] == 0 { + assert_eq!(new[3], 0); + } else if old[..3] == [255, 0, 0] { + assert_eq!(*new, [0, 0, 255, 255]); + } else { + assert_eq!(*new, [0, 255, 0, 255]); + } + } + assert_eq!(before.indices, after.indices, "palette edits must not remap pixels"); + assert_eq!(after.transparent_index, Some(2)); + assert_eq!(after.color_table, vec![[255, 0, 0], [0, 0, 255]]); + let decoded = photocraft_codecs::decode(&after.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + // Transparent RGB has no significance; opaque preview pixels must equal the file. + for (file, preview) in decoded.data().as_chunks::<4>().0.iter().zip(after.preview.as_chunks::<4>().0) { + assert_eq!(file[3], preview[3]); + if preview[3] != 0 { + assert_eq!(file, preview); + } + } + } + } +} + +#[test] +fn exported_palette_edits_work_at_every_document_depth() { + let dir = tmp("table-edits"); + for depth in [8, 16, 32] { + let mut s = session(depth); + let mut p = json!({"format": "gif", "colors": 16, "dither": "none"}); + let estimate = s.execute("file.export.saveForWebLegacy", p.clone()).unwrap(); + let table = estimate["colorTable"].as_array().unwrap(); + let red = table.iter().position(|c| c == "#ff0000").unwrap(); + p["colorTable"] = estimate["colorTable"].clone(); + p["colorTableEdits"] = json!({red.to_string(): "#00ff00"}); + let path = format!("{dir}/{depth}.gif"); + p["path"] = json!(path); + let original_doc = s.active().unwrap().doc.clone(); + s.execute("file.export.saveForWebLegacy", p).unwrap(); + assert_eq!(s.active().unwrap().doc, original_doc, "export must not edit the document"); + let img = decode(&path).convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + assert_eq!(&img.data()[(16 * 64 + 16) * 4..(16 * 64 + 16) * 4 + 4], &[0, 255, 0, 255]); + assert_eq!(img.data()[3], 0); + } +} + +#[test] +fn malformed_tables_and_out_of_bounds_slices_return_errors() { + for p in [ + json!({"colorTable": []}), + json!({"colorTable": "bad"}), + json!({"colorTable": ["#ff00é"]}), + json!({"colorTable": vec!["#000000"; 257]}), + json!({"colorTable": [123]}), + json!({"colorTableEdits": {"0": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"-1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": {"1": "#ffffff"}}), + json!({"colorTable": ["#ff0000"], "colorTableEdits": []}), + ] { + assert!(WebSettings::from_params(&p, "test").is_err(), "{p}"); + let mut s = session(8); + let doc = s.active().unwrap().doc.clone(); + assert!(s.execute("file.export.saveForWebLegacy", p).is_err()); + assert_eq!(s.active().unwrap().doc, doc); + } + let settings = WebSettings::default(); + for (bw, rect) in [(0, Rect::new(0, 0, 1, 1)), (1, Rect::new(-1, 0, 1, 1)), (1, Rect::new(0, 0, 2, 1)), (1, Rect::new(0, 0, 1, i32::MAX))] { + assert!(optimize(&[[0.0; 4]], bw, rect, &settings, None, None, 72.0, false).is_err()); + } + let settings = WebSettings::from_params(&json!({"format": "gif", "colorTable": vec!["#000000"; 256]}), "test").unwrap(); + assert!(optimize(&[[0.0; 4]], 1, Rect::new(0, 0, 1, 1), &settings, None, None, 72.0, true).is_err()); +} diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index f64e309e95..787560f425 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -656,3 +656,6 @@ fn overlapping_slices_keep_the_later_slice_in_html() { assert_eq!(area, 64 * 48); assert!(files.iter().any(|f| f.ends_with("images/under_01.png")), "the covered slice is cut into pieces: {files:?}"); } + +#[path = "color_table_tests.rs"] +mod color_table_tests; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b5e0113784..678941484d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -99,6 +99,33 @@ fn older_dialog_above(ctx: &egui::Context, live: egui::LayerId, dialogs: &[Dialo ctx.memory(|m| m.layer_ids().skip_while(|l| *l != live).skip(1).any(|l| others.contains(&l))) } +fn show_modal( + modal: egui::Modal, + ctx: &egui::Context, + id: egui::Id, + resizable: bool, + interactive: bool, + contents: impl FnOnce(&mut egui::Ui) -> T, +) -> egui::ModalResponse { + modal.show(ctx, |ui| { + // Disable the entire older modal, including its resize grip, not just its body. + if !interactive { + ui.disable(); + } + if resizable { + let max = (ctx.content_rect().size() - egui::vec2(40.0, 40.0)).max(egui::vec2(560.0, 400.0)); + egui::Resize::default() + .id(id.with("resize")) + .default_size(egui::vec2(960.0, 900.0).min(max)) + .min_size(egui::vec2(560.0, 400.0)) + .max_size(max) + .show(ui, contents) + } else { + contents(ui) + } + }) +} + pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { crate::layer_style::color_picker::prune(app); crate::type_panels_ui::color_picker::prune(app); @@ -147,11 +174,11 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // Tab / ⇧Tab walk the topmost dialog's text fields, not every widget (field_tab.rs). let tab = if top == Some(d.id) && !egui::Popup::is_any_open(ctx) { crate::field_tab::take_step(ctx) } else { 0 }; // Photoshop doesn't dim the window behind dialogs: previews must be judged at true contrast. - let modal = modal.show(ctx, |ui| { - if !interactive { - ui.disable(); - } + let web = fields.contains_key("__web"); + let web_popup_open = web && egui::Popup::is_any_open(ctx); + let modal = show_modal(modal, ctx, id, web, interactive, |ui| { sizing = ui.is_sizing_pass(); + let available = ui.available_size(); ui.set_min_width(380.0); let wide = crate::prefs_ui::width(&d.fields, (ctx.content_rect().width() - 48.0).max(380.0)); if let Some(w) = wide { @@ -174,7 +201,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { } else { 440.0 })); - if let Some(w) = crate::file_ui::dialog_width(&d.fields) { + if web { + ui.set_max_width(available.x); + ui.set_min_size(available); + } else if let Some(w) = crate::file_ui::dialog_width(&d.fields) { ui.set_min_width(w); ui.set_max_width(w); } @@ -358,7 +388,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { Some(ButtonRole::Cancel) => outcome = Some(false), Some(ButtonRole::Apply) => apply_requested = true, Some(_) => outcome = Some(true), - None if interactive && can_ok && ui.input(|i| i.key_pressed(egui::Key::Enter)) => outcome = Some(true), + None if interactive + && can_ok + && (!web || (!web_popup_open && !egui::Popup::is_any_open(ui.ctx()))) + && ui.input(|i| i.key_pressed(egui::Key::Enter)) => + { + outcome = Some(true) + } None => {} } } @@ -370,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { // The picker has a larger frame margin; include that whole frame in the canvas hit guard. shown.push(if color_picker { modal.response.rect } else { modal.inner }); // Pin once laid out at its real size (the first frame is an invisible sizing pass). - if !sizing && (pinned.is_none() || drag != egui::Vec2::ZERO) { + if !sizing && (web || pinned.is_none() || drag != egui::Vec2::ZERO) { let screen = ctx.content_rect(); // Keep the whole dialog (and so its title bar) on screen. let room = (screen.size() - modal.response.rect.size()).max(egui::Vec2::ZERO); diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index 310c9fea04..e3884796f8 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -180,6 +180,9 @@ fn b(f: &Map, k: &str, d: bool) -> bool { // ---------- Save for Web ---------- +#[path = "file_ui/web_preview.rs"] +mod web_preview; + const WEB_FORMATS: [(&str, &str); 5] = [("gif", "GIF"), ("png8", "PNG-8"), ("png24", "PNG-24"), ("jpeg", "JPEG"), ("wbmp", "WBMP")]; const WEB_COMMAND: &str = "file.export.saveForWebLegacy"; @@ -193,7 +196,7 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { - if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent") { + if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { f.insert(k, v); } } @@ -227,6 +230,10 @@ fn variants(p: &Value) -> Vec { let fmt = p["format"].as_str().unwrap_or("jpeg"); for k in [2.0, 4.0] { let mut v = p.clone(); + if let Some(m) = v.as_object_mut() { + m.remove("colorTable"); + m.remove("colorTableEdits"); + } if fmt == "jpeg" { v["quality"] = json!((p["quality"].as_f64().unwrap_or(60.0) / k).round().max(1.0)); } else if fmt == "gif" || fmt == "png8" { @@ -241,51 +248,65 @@ fn variants(p: &Value) -> Vec { } /// The flattened preview image: pixels, width, height, full-size / proxy pixel ratio. -type WebProxy = (Arc>, u32, u32, f64); +type WebProxy = (Arc>, u32, u32, f64, egui::Vec2); type WebCache = (String, Arc, Option<(usize, Option, &'static str)>); /// Renders one preview pane: the original (`None`) or an optimised variant. -fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, full_px: f64) { +fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option<&Value>, f: &mut Map) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let rev = st.revision; - // The proxy: the export size, at most 640 px on the long side. + // A bounded proxy; navigation never recomposites or re-encodes it. let base = p.cloned().unwrap_or_else(|| json!({})); - let size_sig = format!("{}|{}|{}", base["percent"], base["width"], base["height"]); - let wkey = egui::Id::new(("web-proxy", doc.id.0, rev, size_sig.clone())); - let proxy: Option = ui.data(|d| d.get_temp(wkey)); - let (px, w, h, ratio) = match proxy { + let size_sig = format!("{}|{}|{}|{}", base["percent"], base["width"], base["height"], base["convertToSrgb"]); + let wkey = egui::Id::new(("web-proxy", doc.id.0, p.is_some())); + let proxy_sig = format!("{rev}|{size_sig}"); + let cached_proxy: Option<(String, WebProxy)> = ui.data(|d| d.get_temp(wkey)); + let proxy = cached_proxy.filter(|(sig, _)| sig == &proxy_sig).map(|(_, proxy)| proxy); + let (px, w, h, ratio, source_size) = match proxy { Some(v) => v, None => { - let pct = base["percent"].as_f64().unwrap_or(100.0).max(1.0) / 100.0; - let (fw, fh) = (f64::from(doc.size.width) * pct, f64::from(doc.size.height) * pct); - let k = (640.0 / fw.max(fh)).min(1.0); - let st = WebSettings::default(); + let (fw, fh) = match web_preview::dimensions(&doc, &base) { + Ok(size) => size, + Err(error) => { + ui.label(error); + return; + } + }; + let k = (2048.0 / fw.max(fh)).min(1.0); + let st = WebSettings::from_params(&base, "preview").unwrap_or_default(); let Ok((wd, _, _)) = web_cmds::web_document(&doc, &json!({"width": (fw * k).round().max(1.0), "height": (fh * k).round().max(1.0), "resample": "bilinear"}), &st) else { return; }; let buf = photocraft_compose::flatten(&wd); - let v = (Arc::new(buf.px), wd.size.width, wd.size.height, (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0)); - ui.data_mut(|d| d.insert_temp(wkey, v.clone())); + let v = ( + Arc::new(buf.px), + wd.size.width, + wd.size.height, + (fw * fh) / (f64::from(wd.size.width) * f64::from(wd.size.height)).max(1.0), + egui::vec2(fw as f32, fh as f32), + ); + ui.data_mut(|d| d.insert_temp(wkey, (proxy_sig, v.clone()))); v } }; - let sig = format!("{}{}", size_sig, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); - let key = egui::Id::new(("web-pane", doc.id.0, rev, sig.clone())); + let sig = format!("{}|{rev}|{size_sig}|{}", doc.id.0, p.map(|v| v.to_string()).unwrap_or_else(|| "original".into())); + let key = ui.next_auto_id().with("web-pane"); let cached: Option = ui.data(|d| d.get_temp(key)); let (tex, info) = match cached.filter(|c| c.0 == sig) { Some((_, tex, info)) => (tex, info), None => { let (rgba, info) = match p { None => (px.iter().flat_map(|q| q.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect::>(), None), - Some(p) => match WebSettings::from_params(p, "preview") - .and_then(|st| web_cmds::optimize(&px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &st, None, None, 72.0, true)) - { - Ok(Optimized { preview, bytes, colors, ext, .. }) => (preview, Some(((bytes.len() as f64 * ratio) as usize, colors, ext))), - Err(_) => (vec![0; (w * h * 4) as usize], None), + Some(p) => match web_preview::optimized(ui, key, &format!("{}|{rev}|{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) + } }, }; let img = egui::ColorImage::from_rgba_unmultiplied([w as usize, h as usize], &rgba); @@ -295,14 +316,15 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } }; ui.vertical(|ui| { - let (r, _) = ui.allocate_exact_size(size - egui::vec2(0.0, 34.0), egui::Sense::hover()); + ui.set_width(size.x); + let (r, response) = ui.allocate_exact_size((size - egui::vec2(0.0, 42.0)).max(egui::vec2(1.0, 1.0)), egui::Sense::drag()); + let response = response.on_hover_cursor(egui::CursorIcon::Grab); + if let Some(rects) = f.get_mut("__previewRects").and_then(Value::as_array_mut) { + rects.push(json!([r.left(), r.top(), r.right(), r.bottom()])); + } crate::widgets::checker(ui.painter(), r, 8.0); - let ts = tex.size_vec2(); - let k = (r.width() / ts.x).min(r.height() / ts.y).min(1.0); - let ir = egui::Rect::from_center_size(r.center(), ts * k); - ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE); - ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - let _ = full_px; + web_preview::paint(ui, r, &response, &tex, source_size, f); + ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); let (l1, l2) = match (p, info) { (None, _) => ("Original".to_string(), format!("{} · {} × {} px", doc.name, doc.size.width, doc.size.height)), (Some(_), Some((bytes, colors, ext))) => { @@ -315,8 +337,9 @@ fn web_pane(app: &PhotocraftApp, ui: &mut egui::Ui, size: egui::Vec2, p: Option< } (Some(_), None) => ("—".into(), String::new()), }; - ui.label(egui::RichText::new(l1).color(t.text).size(11.5)); - ui.label(egui::RichText::new(l2).color(t.text_dim).size(11.0)); + let l1 = if ratio > 1.001 { format!("{l1} · Proxy preview") } else { l1 }; + ui.add(egui::Label::new(egui::RichText::new(l1).color(t.text).size(11.5)).truncate()); + ui.add(egui::Label::new(egui::RichText::new(l2).color(t.text_dim).size(11.0)).truncate()); }); } @@ -357,158 +380,185 @@ fn web_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map web_pane(app, ui, area, None, full_px), - "optimized" => web_pane(app, ui, area, Some(&p), full_px), + "original" => web_pane(app, ui, area, None, f), + "optimized" => web_pane(app, ui, area, Some(&p), f), "2up" => { ui.horizontal(|ui| { - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, full_px); - web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), full_px); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), None, f); + web_pane(app, ui, egui::vec2(area.x / 2.0 - 4.0, area.y), Some(&p), f); }); } _ => { let v = variants(&p); - let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 2.0); + let cell = egui::vec2(area.x / 2.0 - 4.0, area.y / 2.0 - 4.0); ui.horizontal(|ui| { - web_pane(app, ui, cell, None, full_px); - web_pane(app, ui, cell, Some(&v[0]), full_px); + web_pane(app, ui, cell, None, f); + web_pane(app, ui, cell, Some(&v[0]), f); }); ui.horizontal(|ui| { - web_pane(app, ui, cell, Some(&v[1]), full_px); - web_pane(app, ui, cell, Some(&v[2]), full_px); + web_pane(app, ui, cell, Some(&v[1]), f); + web_pane(app, ui, cell, Some(&v[2]), f); }); } } }); ui.add_space(10.0); - // Settings. + // Settings scroll independently on smaller windows. ui.vertical(|ui| { ui.set_width(250.0); - ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); - let mut preset = s(f, "__preset", ""); - let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; - opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); - if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { - if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { - f.extend(m); - } - f.insert("__preset".into(), json!(preset)); - } - ui.add_space(4.0); - dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); - ui.add_space(4.0); - let fmt = s(f, "format", "jpeg"); - match fmt.as_str() { - "gif" | "png8" => { - dropdown_str( - ui, - "web-palette", - f, - "palette", - &[ - ("perceptual", tl!("Perceptual")), - ("selective", tl!("Selective")), - ("adaptive", tl!("Adaptive")), - ("restrictive", tl!("Restrictive (Web)")), - ("exact", tl!("Exact")), - ], - 150.0, - ); - number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], - 150.0, - ); - number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); - } - "jpeg" => { - number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); - check(ui, f, "progressive", tl!("Progressive"), false); - check(ui, f, "optimized", tl!("Optimized"), true); - check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); - } - "wbmp" => { - dropdown_str( - ui, - "web-dither", - f, - "dither", - &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], - 150.0, - ); - } - _ => { - check(ui, f, "transparency", tl!("Transparency"), true); - check(ui, f, "interlaced", tl!("Interlaced"), false); - } - } - if fmt != "png24" || !b(f, "transparency", true) { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); - let mut m = s(f, "matte", "#ffffff"); - if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { - f.insert("matte".into(), json!(m)); + egui::ScrollArea::vertical().id_salt("web-settings").max_height(area.y).show(ui, |ui| { + ui.label(egui::RichText::new(tl!("Preset")).color(t.text_dim)); + let mut preset = s(f, "__preset", ""); + let mut opts: Vec<(String, &str)> = vec![("".into(), "[Unnamed]")]; + opts.extend(web_cmds::PRESETS.iter().map(|p| (p.to_string(), *p))); + if crate::widgets::dropdown(ui, "web-preset", &mut preset, &opts, 230.0) && !preset.is_empty() { + if let Some(Value::Object(m)) = web_cmds::preset_settings(&preset).map(|st| st.to_params()) { + f.extend(m); } - }); - } - ui.add_space(6.0); - check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); - dropdown_str( - ui, - "web-meta", - f, - "metadata", - &[("none", tl!("None")), ("copyright", tl!("Copyright")), ("copyrightAndContact", tl!("Copyright and Contact Info")), ("all", tl!("All"))], - 160.0, - ); - }); - ui.add_space(8.0); - ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); - number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); - let pct = n(f, "percent", 100.0) / 100.0; - ui.label( - egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) - .color(t.text_dim) - .size(11.5), - ); - let nslices = photocraft_doc::slices::resolve(&doc).len(); - if !doc.slices.is_empty() { + f.insert("__preset".into(), json!(preset)); + } + ui.add_space(4.0); + dropdown_str(ui, "web-format", f, "format", &WEB_FORMATS, 230.0); + ui.add_space(4.0); + let fmt = s(f, "format", "jpeg"); + match fmt.as_str() { + "gif" | "png8" => { + dropdown_str( + ui, + "web-palette", + f, + "palette", + &[ + ("perceptual", tl!("Perceptual")), + ("selective", tl!("Selective")), + ("adaptive", tl!("Adaptive")), + ("restrictive", tl!("Restrictive (Web)")), + ("exact", tl!("Exact")), + ], + 150.0, + ); + number(ui, f, "colors", tl!("Colors"), 2.0..=256.0, "", 128.0); + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern")), ("noise", tl!("Noise"))], + 150.0, + ); + number(ui, f, "ditherAmount", tl!("Dither"), 0.0..=100.0, "%", 88.0); + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + number(ui, f, "webSnap", tl!("Web Snap"), 0.0..=100.0, "%", 0.0); + } + "jpeg" => { + number(ui, f, "quality", tl!("Quality"), 0.0..=100.0, "", 60.0); + check(ui, f, "progressive", tl!("Progressive"), false); + check(ui, f, "optimized", tl!("Optimized"), true); + check(ui, f, "embedIcc", tl!("Embed Color Profile"), false); + } + "wbmp" => { + dropdown_str( + ui, + "web-dither", + f, + "dither", + &[("none", tl!("No Dither")), ("diffusion", tl!("Diffusion")), ("pattern", tl!("Pattern"))], + 150.0, + ); + } + _ => { + check(ui, f, "transparency", tl!("Transparency"), true); + check(ui, f, "interlaced", tl!("Interlaced"), false); + } + } + if fmt != "png24" || !b(f, "transparency", true) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Matte")).color(t.text_dim)); + let mut m = s(f, "matte", "#ffffff"); + if ui.add(egui::TextEdit::singleline(&mut m).desired_width(80.0)).changed() { + f.insert("matte".into(), json!(m)); + } + }); + } ui.add_space(6.0); - ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); - check(ui, f, "html", tl!("Save HTML and Images"), true); - dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); - } + check(ui, f, "convertToSrgb", tl!("Convert to sRGB"), true); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Metadata")).color(t.text_dim)); + dropdown_str( + ui, + "web-meta", + f, + "metadata", + &[ + ("none", tl!("None")), + ("copyright", tl!("Copyright")), + ("copyrightAndContact", tl!("Copyright and Contact Info")), + ("all", tl!("All")), + ], + 160.0, + ); + }); + if matches!(fmt.as_str(), "gif" | "png8") { + web_preview::table(ui, f); + } + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Image Size")).font(crate::theme::semibold(12.0)).color(t.text)); + number(ui, f, "percent", tl!("Percent"), 1.0..=1000.0, "%", 100.0); + let pct = n(f, "percent", 100.0) / 100.0; + ui.label( + egui::RichText::new(format!("W: {} px H: {} px", (f64::from(doc.size.width) * pct).round(), (f64::from(doc.size.height) * pct).round())) + .color(t.text_dim) + .size(11.5), + ); + let nslices = photocraft_doc::slices::resolve(&doc).len(); + if !doc.slices.is_empty() { + ui.add_space(6.0); + ui.label(egui::RichText::new(crate::i18n::trn(crate::i18n::current(), nslices as u64, "{n} slice", "{n} slices")).color(t.text_dim)); + check(ui, f, "html", tl!("Save HTML and Images"), true); + dropdown_str(ui, "web-slices", f, "slices", &[("all", tl!("All Slices")), ("user", tl!("All User Slices"))], 150.0); + } + }); }); }); } fn web_confirm(app: &mut PhotocraftApp, f: &Map) -> Result { - let mut p = params(f); // Check while the control/input policy is still installed, before an asynchronous picker // can outlive the request. A browser download uses the interactive platform writer. - if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { - authorize(WEB_COMMAND, &p).map_err(|e| e.to_string())?; - } - if app.automation_input - && let Some(authorize) = app.services.automation_command.as_ref() - { - authorize(WEB_COMMAND, &p)?; + let authorize = |p: &Value| -> Result<(), String> { + if let Some(authorize) = app.session.authorize.or(if app.automation_input { app.services.automation_authorize } else { None }) { + authorize(WEB_COMMAND, p).map_err(|e| e.to_string())?; + } + if app.automation_input + && let Some(authorize) = app.services.automation_command.as_ref() + { + authorize(WEB_COMMAND, p)?; + } + Ok(()) + }; + let mut p = params(f); + authorize(&p)?; + // Confirmation may follow a field edit without an intervening frame (Enter or automation). + let mut fields = f.clone(); + web_preview::prepare_table(app, &mut fields)?; + let prepared = params(&fields); + if prepared != p { + // The policy must also approve the actual palette-bearing export parameters. + authorize(&prepared)?; + p = prepared; } if p.get("path").is_some() || p.get("dir").is_some() { return save_for_web(app, p); @@ -964,6 +1014,118 @@ mod tests { } } + #[test] + fn save_for_web_authorizes_generated_palette_before_opening_a_picker() { + fn deny_palette(_: &str, p: &Value) -> photocraft_engine::Result<()> { + if p.get("colorTable").is_some() { Err(photocraft_engine::EngineError::Other("palette denied".into())) } else { Ok(()) } + } + for synthetic_input in [false, true] { + let (mut app, _) = app(); + let id = open_web(&mut app); + let fields = &mut app.ui.dialog_mut(id).unwrap().fields; + fields.insert("format".into(), json!("gif")); + assert!(!fields.contains_key("colorTable")); + if synthetic_input { + app.automation_input = true; + app.services.automation_command = Some(Box::new(|cmd, p| deny_palette(cmd, p).map_err(|e| e.to_string()))); + } else { + app.session.authorize = Some(deny_palette); + } + app.services.file_dialog = Some(Box::new(|_, _, _| panic!("denied palette must not show a picker"))); + app.services.write = Some(Box::new(|_, _| panic!("denied palette must not write"))); + assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "palette denied"); + assert!(!app.file_dialog_open()); + } + } + + #[test] + fn web_preview_grows_with_resize_and_accepts_zoom_and_pan() { + let (mut app, _) = app(); + let id = open_web(&mut app); + let revision = app.session.active().unwrap().revision; + app.ui.dialog_mut(id).unwrap().fields.insert("__view".into(), json!("optimized")); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1100.0)).build_ui_state( + |ui, app| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + crate::dialogs::show(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(5); + let pane = |h: &egui_kittest::Harness<'_, PhotocraftApp>| { + let r = &h.state().ui.dialogs[0].fields["__previewRects"][0]; + egui::Rect::from_min_max( + egui::pos2(r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32), + egui::pos2(r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32), + ) + }; + let before = pane(&h); + let rects: Vec = h.ctx.data(|m| m.get_temp(egui::Id::new("pc-dialog-rects"))).unwrap(); + let corner = rects[0].max - egui::vec2(14.0, 14.0); + h.event(egui::Event::PointerMoved(corner)); + h.step(); + h.event(egui::Event::PointerButton { pos: corner, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = corner + egui::vec2(180.0, 100.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + let after = pane(&h); + assert!(after.width() > before.width() + 100.0, "before {before:?}, after {after:?}, corner {corner:?}"); + assert!(after.height() > before.height() + 50.0); + // Zoom in enough that the small fixture can pan, then drag the real pane. + h.state_mut().ui.dialogs[0].fields.insert("__zoom".into(), json!(16.0)); + h.run_steps(3); + let at = pane(&h).center(); + h.event(egui::Event::PointerMoved(at)); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + let end = at + egui::vec2(35.0, 20.0); + h.event(egui::Event::PointerMoved(end)); + h.step(); + h.event(egui::Event::PointerButton { pos: end, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + assert_ne!(h.state().ui.dialogs[0].fields["__center"], json!([0.5, 0.5])); + assert_eq!(h.state().session.active().unwrap().revision, revision); + // Enter while editing a swatch must not export and close the parent dialog. + h.state_mut().ui.dialogs[0].fields.insert("format".into(), json!("gif")); + h.run_steps(4); + use egui_kittest::kittest::Queryable; + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx)); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1); + } + + #[test] + fn palette_draft_survives_navigation_and_resets_when_colors_change() { + let (app, _) = app(); + let mut f = serde_json::from_value::>(json!({"format": "gif", "colors": 16})).unwrap(); + web_preview::prepare_table(&app, &mut f).unwrap(); + let table = f["colorTable"].clone(); + f.insert("colorTableEdits".into(), json!({"0": "#12ab34"})); + f.insert("__zoom".into(), json!(2.0)); + f.insert("__center".into(), json!([0.25, 0.75])); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTable"], table); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34"); + f.insert("metadata".into(), json!("none")); + f.insert("interlaced".into(), json!(true)); + f.insert("dither".into(), json!("pattern")); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert_eq!(f["colorTableEdits"]["0"], "#12ab34", "non-palette settings must retain edits"); + f.insert("colors".into(), json!(8)); + web_preview::prepare_table(&app, &mut f).unwrap(); + assert!(!f.contains_key("colorTableEdits")); + assert!(f["colorTable"].as_array().unwrap().len() <= 8); + } + #[test] fn print_dialog_and_toggles() { let (mut app, ctx) = app(); diff --git a/crates/ui-egui/src/file_ui/web_preview.rs b/crates/ui-egui/src/file_ui/web_preview.rs new file mode 100644 index 0000000000..a9ee7a933c --- /dev/null +++ b/crates/ui-egui/src/file_ui/web_preview.rs @@ -0,0 +1,323 @@ +//! Save for Web navigation and indexed palette UI. Draft state is serialized in dialog fields. +use super::*; + +/// Export dimensions, using the same percent/width/height precedence as web_document. +pub fn dimensions(doc: &photocraft_doc::Document, p: &Value) -> Result<(f64, f64), String> { + let (w0, h0) = (f64::from(doc.size.width).max(1.0), f64::from(doc.size.height).max(1.0)); + let positive = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let (w, h) = match (positive("percent"), positive("width"), positive("height")) { + (Some(pct), _, _) => (w0 * pct / 100.0, h0 * pct / 100.0), + (_, Some(w), Some(h)) => (w, h), + (_, Some(w), None) => (w, h0 * w / w0), + (_, None, Some(h)) => (w0 * h / h0, h), + _ => (w0, h0), + }; + if !w.is_finite() || !h.is_finite() || w > 300_000.0 || h > 300_000.0 { + return Err("export dimensions exceed the 300,000 px per-side limit".into()); + } + Ok((w.round().max(1.0), h.round().max(1.0))) +} + +pub fn prepare_table(app: &PhotocraftApp, f: &mut Map) -> Result<(), String> { + if !matches!(s(f, "format", "jpeg").as_str(), "gif" | "png8") { + return Ok(()); + } + let st = app.session.active().ok_or("no document")?; + let mut source = params(f); + let Some(m) = source.as_object_mut() else { return Err("invalid web settings".into()) }; + // Only palette-affecting options invalidate index-based edits. Metadata, interlacing, + // dithering and output paths must not discard the user's replacement colours. + m.retain(|k, _| { + matches!( + k.as_str(), + "preset" | "format" | "palette" | "colors" | "transparency" | "matte" | "webSnap" | "convertToSrgb" | "width" | "height" | "percent" | "resample" + ) + }); + let signature = format!("{}|{}|{}", st.doc.id.0, st.revision, source); + if f.get("__tableSignature").and_then(Value::as_str) == Some(&signature) { + return Ok(()); + } + // A regenerated palette invalidates index-based edits. Explicit tables supplied through + // ui.dialog.set remain usable until a palette/size setting changes. + if f.contains_key("__tableSignature") { + f.remove("colorTable"); + f.remove("colorTableEdits"); + f.remove("__selectedColor"); + } + if !f.contains_key("colorTable") { + let settings = WebSettings::from_params(&source, "preview").map_err(|e| e.to_string())?; + // Bound palette preparation as well as the displayed image. The fixed table is then + // passed unchanged to full-resolution export, so the swatches are the file's colours. + let (w, h) = dimensions(&st.doc, &source)?; + let scale = (2048.0 / w.max(h)).min(1.0); + let mut proxy = source.clone(); + if let Some(m) = proxy.as_object_mut() { + m.remove("percent"); + m.insert("width".into(), json!((w * scale).round().max(1.0))); + m.insert("height".into(), json!((h * scale).round().max(1.0))); + m.insert("resample".into(), json!("bilinear")); + } + let (doc, _, _) = web_cmds::web_document(&st.doc, &proxy, &settings).map_err(|e| e.to_string())?; + f.insert("__paletteApproximate".into(), json!(scale < 1.0)); + let buf = photocraft_compose::flatten(&doc); + let mut optimized = + web_cmds::optimize(&buf.px, doc.size.width as usize, doc.bounds(), &settings, None, None, doc.resolution_dpi, false).map_err(|e| e.to_string())?; + // Tiny transparent features may disappear in the proxy; keep room for their index. + if settings.transparency { + optimized.color_table.truncate(255); + } + f.insert("colorTable".into(), json!(optimized.color_table.iter().map(|c| web_cmds::rgb_hex(*c)).collect::>())); + f.insert("__transparentIndex".into(), json!(optimized.transparent_index)); + } + f.insert("__tableSignature".into(), json!(signature)); + Ok(()) +} + +pub fn table(ui: &mut egui::Ui, f: &mut Map) { + let p = params(f); + let Ok(settings) = WebSettings::from_params(&p, "preview") else { return }; + let Some(table) = settings.color_table else { return }; + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Color Table…").trim_end_matches('…')).font(crate::theme::semibold(12.0))); + ui.scope(|ui| { + ui.spacing_mut().interact_size = egui::vec2(14.0, 14.0); + ui.spacing_mut().item_spacing = egui::vec2(1.0, 1.0); + egui::Grid::new("web-color-table").spacing(egui::vec2(1.0, 1.0)).show(ui, |ui| { + for (i, source) in table.iter().enumerate() { + let mut color = settings.color_table_edits.get(&i).copied().unwrap_or(*source); + let response = ui.push_id(i, |ui| crate::widgets::color_edit_button_srgb(ui, &mut color)).inner; + response.clone().on_hover_text(format!("Index {i}: {}", web_cmds::rgb_hex(color))); + if response.clicked() { + f.insert("__selectedColor".into(), json!(i)); + } + if f.get("__selectedColor").and_then(Value::as_u64) == Some(i as u64) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(response.rect, t.radius_sm, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if response.changed() { + let edits = f.entry("colorTableEdits").or_insert_with(|| json!({})); + if let Some(edits) = edits.as_object_mut() { + edits.insert(i.to_string(), json!(web_cmds::rgb_hex(color))); + } + } + if i % 16 == 15 { + ui.end_row(); + } + } + if f.get("__transparentIndex").and_then(Value::as_u64).is_some() { + let (rect, response) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::checker(ui.painter(), rect, 4.0); + response.on_hover_text(tl!("Transparency")); + } + }); + }); + ui.horizontal(|ui| { + ui.weak(format!("{}: {}", tl!("Colors"), table.len())); + if ui.small_button(tl!("Reset")).clicked() { + f.remove("colorTableEdits"); + } + }); +} + +type EncodedPreview = (Vec, usize, Option, &'static str); + +/// Palette replacements reuse the cached index buffer; picker drags do not re-quantize or +/// re-encode the proxy. The GIF payload length is unchanged by RGB table replacements. +pub fn optimized(ui: &mut egui::Ui, key: egui::Id, signature: &str, p: &Value, px: &[[f32; 4]], w: u32, h: u32) -> Result { + let settings = WebSettings::from_params(p, "preview").map_err(|e| e.to_string())?; + let mut base = p.clone(); + if let Some(m) = base.as_object_mut() { + m.remove("colorTableEdits"); + } + let signature = format!("{signature}|{base}"); + let key = key.with("indexed-source"); + let cached: Option<(String, Arc)> = ui.data(|d| d.get_temp(key)); + let encoded = if let Some((_, encoded)) = cached.filter(|(sig, _)| sig == &signature) { + encoded + } else { + let settings = WebSettings::from_params(&base, "preview").map_err(|e| e.to_string())?; + let encoded = Arc::new( + web_cmds::optimize(px, w as usize, photocraft_geom::Rect::new(0, 0, w as i32, h as i32), &settings, None, None, 72.0, true) + .map_err(|e| e.to_string())?, + ); + ui.data_mut(|d| d.insert_temp(key, (signature, encoded.clone()))); + encoded + }; + let rgba = if settings.color_table_edits.is_empty() || !settings.format.indexed() { + encoded.preview.clone() + } else { + encoded + .indices + .iter() + .flat_map(|index| { + let index = usize::from(*index); + let color = settings.color_table_edits.get(&index).copied().or_else(|| encoded.color_table.get(index).copied()).unwrap_or([0; 3]); + [color[0], color[1], color[2], if Some(index) == encoded.transparent_index { 0 } else { 255 }] + }) + .collect() + }; + Ok((rgba, encoded.bytes.len(), encoded.colors, encoded.ext)) +} + +#[derive(Clone, Copy, Debug)] +struct Navigation { + zoom: Option, + center: egui::Vec2, +} +impl Navigation { + fn read(f: &Map) -> Self { + let zoom = f.get("__zoom").and_then(Value::as_f64).filter(|z| z.is_finite() && *z > 0.0).map(|z| (z as f32).clamp(0.0001, 32.0)); + let center = f + .get("__center") + .and_then(Value::as_array) + .and_then(|a| Some(egui::vec2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))) + .filter(|c| c.is_finite()) + .unwrap_or(egui::vec2(0.5, 0.5)); + Self { zoom, center } + } + fn write(self, f: &mut Map) { + f.insert("__zoom".into(), json!(self.zoom)); + f.insert("__center".into(), json!([self.center.x, self.center.y])); + } + fn scale(self, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) -> f32 { + self.zoom.map_or((viewport.x / source.x).min(viewport.y / source.y), |z| z / ppp) + } + fn clamp(&mut self, viewport: egui::Vec2, image: egui::Vec2) { + let half = (viewport / image * 0.5).min(egui::vec2(0.5, 0.5)); + self.center = self.center.clamp(half, egui::Vec2::splat(1.0) - half); + } + fn zoom_at(&mut self, factor: f32, at: egui::Vec2, viewport: egui::Vec2, source: egui::Vec2, ppp: f32) { + if !factor.is_finite() || factor <= 0.0 { + return; + } + let old = self.scale(viewport, source, ppp); + self.zoom = Some((old * ppp * factor).clamp(0.0001, 32.0)); + let new = self.scale(viewport, source, ppp); + self.center += at / source * (1.0 / old - 1.0 / new); + self.clamp(viewport, source * new); + } +} + +pub fn controls(ui: &mut egui::Ui, f: &mut Map) { + let mut nav = Navigation::read(f); + let effective = n(f, "__effectiveZoom", 1.0) as f32; + ui.horizontal(|ui| { + if ui.small_button(tl!("Fit")).clicked() { + nav.zoom = None; + nav.center = egui::vec2(0.5, 0.5); + } + if ui.small_button("−").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) / 1.25).max(0.0001)); + } + if ui.small_button("+").clicked() { + nav.zoom = Some((nav.zoom.unwrap_or(effective) * 1.25).min(32.0)); + } + let mut percent = nav.zoom.unwrap_or(effective) * 100.0; + if ui.add(egui::DragValue::new(&mut percent).range(0.01..=3200.0).suffix("%").speed(10.0)).changed() { + nav.zoom = Some(percent / 100.0); + } + if ui.small_button(tl!("100%")).clicked() { + nav.zoom = Some(1.0); + } + ui.add(egui::Label::new(egui::RichText::new(tl!("Drag to pan · Scroll to zoom · Double-click to fit")).weak()).truncate()); + }); + nav.write(f); +} + +pub fn paint(ui: &mut egui::Ui, rect: egui::Rect, response: &egui::Response, tex: &egui::TextureHandle, source: egui::Vec2, f: &mut Map) { + let mut nav = Navigation::read(f); + let ppp = ui.ctx().pixels_per_point(); + if response.double_clicked() { + nav.zoom = None; + nav.center = egui::Vec2::splat(0.5); + } + let mut scale = nav.scale(rect.size(), source, ppp); + if response.dragged() { + nav.zoom = Some(scale * ppp); + nav.center -= response.drag_delta() / (source * scale); + } + if response.hovered() { + let (scroll, pinch) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta())); + let factor = if pinch != 1.0 { pinch } else { (scroll * 0.005).exp() }; + if factor != 1.0 { + let at = response.hover_pos().unwrap_or(rect.center()) - rect.center(); + nav.zoom_at(factor, at, rect.size(), source, ppp); + scale = nav.scale(rect.size(), source, ppp); + ui.ctx().request_repaint(); + } + } + nav.clamp(rect.size(), source * scale); + let image = egui::Rect::from_min_size(rect.center() - nav.center * source * scale, source * scale); + ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).image( + tex.id(), + image, + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + // Fit follows resizing; explicit zoom/pan shares the same normalized centre in every pane. + if nav.zoom.is_some() { + f.insert("__effectiveZoom".into(), json!(nav.zoom)); + } else { + f.insert("__effectiveZoom".into(), json!(scale * ppp)); + } + nav.write(f); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn anchored_zoom_keeps_pixel_under_pointer_and_pan_is_bounded() { + let mut nav = Navigation { zoom: Some(1.0), center: egui::Vec2::splat(0.5) }; + let viewport = egui::vec2(200.0, 100.0); + let source = egui::vec2(800.0, 400.0); + let at = egui::vec2(30.0, 20.0); + let before = nav.center + at / source; + nav.zoom_at(2.0, at, viewport, source, 1.0); + let after = nav.center + at / (source * 2.0); + assert!((before - after).length() < 0.00001); + nav.center = egui::vec2(-5.0, 50.0); + nav.clamp(viewport, source * 2.0); + assert_eq!(nav.center, egui::vec2(0.0625, 0.9375)); + nav.zoom = None; + assert_eq!(nav.scale(viewport, source, 1.0), 0.25); + assert_eq!(nav.scale(viewport * 2.0, source, 1.0), 0.5); + } + + #[test] + fn palette_picker_reuses_indices_and_updates_preview_pixels() { + let key = egui::Id::new("test-web-preview"); + let p = json!({"format": "gif", "colorTable": ["#ff0000", "#0000ff"], "dither": "none"}); + let mut h = egui_kittest::Harness::builder().build_ui_state( + move |ui, state: &mut (Value, Vec)| { + state.1 = optimized(ui, key, "fixture", &state.0, &[[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]], 2, 1).unwrap().0; + }, + (p, Vec::new()), + ); + let cached = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(String, Arc)>(key.with("indexed-source"))).unwrap().1; + let before = cached(&h.ctx); + assert_eq!(h.state().1, [255, 0, 0, 255, 0, 0, 255, 255]); + h.state_mut().0["colorTableEdits"] = json!({"0": "#00ff00"}); + h.run_steps(3); + assert!(Arc::ptr_eq(&before, &cached(&h.ctx)), "picker drag must not re-encode"); + assert_eq!(h.state().1, [0, 255, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn absurd_dimensions_and_navigation_fields_are_safe() { + let doc = + photocraft_doc::Document::new("fixture", photocraft_geom::Size::new(100, 50), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8); + assert!(dimensions(&doc, &json!({"percent": 1e308})).is_err()); + assert!(dimensions(&doc, &json!({"width": 1e300})).is_err()); + let f = serde_json::from_value::>(json!({"__zoom": 1e300, "__center": [1e300, -1e300]})).unwrap(); + let nav = Navigation::read(&f); + assert_eq!(nav.zoom, Some(32.0)); + assert_eq!(nav.center, egui::Vec2::splat(0.5)); + let mut nav = nav; + for factor in [f32::NAN, f32::INFINITY, 0.0, -1.0] { + nav.zoom_at(factor, egui::Vec2::ZERO, egui::vec2(200.0, 100.0), egui::vec2(400.0, 200.0), 1.0); + assert_eq!(nav.zoom, Some(32.0)); + assert!(nav.center.is_finite()); + } + } +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 36fc380769..89fa3da9f6 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2323,6 +2323,7 @@ cameraRaw Color Barvy Click to pick · Esc to cancel Kliknutí vybere · Esc zruší Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení Scroll to zoom Rolováním přibližujte + Drag to pan · Scroll to zoom · Double-click to fit Tažením posouvejte · Rolováním přibližujte · Dvojklikem přizpůsobte Show/Hide All Panels Zobrazit/skrýt všechny panely Show/Hide Panels Zobrazit/skrýt panely Could not open Nelze otevřít diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dc366b4553..fde840b1ed 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2360,6 +2360,7 @@ Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen Scroll to zoom Zum Zoomen scrollen + Drag to pan · Scroll to zoom · Double-click to fit Ziehen zum Verschieben · Scrollen zum Zoomen · Doppelklick zum Einpassen Show/Hide All Panels Alle Bedienfelder ein-/ausblenden Show/Hide Panels Bedienfelder ein-/ausblenden Could not open Öffnen nicht möglich diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3f693d543f..a3da64e9dc 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3503,6 +3503,7 @@ fontWeight Thin Πολύ λεπτά Sample a pixel anywhere on the screen Δειγματοληψία ενός pixel οπουδήποτε στην οθόνη Click to pick · Esc to cancel Κλικ για επιλογή · Esc για ακύρωση Scroll to zoom Κύλιση για μεγέθυνση + Drag to pan · Scroll to zoom · Double-click to fit Σύρετε για μετακίνηση · Κυλήστε για μεγέθυνση · Διπλό κλικ για προσαρμογή # Saved New Document presets (#1516) Preset Name Όνομα προεπιλογής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f001c478b..96ecc18d39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2391,6 +2391,7 @@ cameraRaw Amount Cantidad Click to pick · Esc to cancel Clic para elegir · Esc para cancelar Pick a screen pixel or press Esc to cancel Elija un píxel de la pantalla o pulse Esc para cancelar Scroll to zoom Desplace para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arrastre para desplazar · Desplace para ampliar · Doble clic para ajustar Show/Hide All Panels Mostrar/ocultar todos los paneles Show/Hide Panels Mostrar/ocultar paneles Could not open No se pudo abrir diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4dd927500c..3f9ec9fb37 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2745,6 +2745,7 @@ cameraRaw Blending Fusion Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler Scroll to zoom Défiler pour zoomer + Drag to pan · Scroll to zoom · Double-click to fit Glisser pour déplacer · Défiler pour zoomer · Double-cliquer pour ajuster Show/Hide All Panels Afficher/masquer tous les panneaux Show/Hide Panels Afficher/masquer les panneaux Could not open Impossible d’ouvrir diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 151a997bfe..f37ef4d335 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3993,3 +3993,4 @@ cameraRaw Yellow Sárga Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet + Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ea51c03775..18d2c776f8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2732,6 +2732,7 @@ cameraRaw Blending Pembauran Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan Scroll to zoom Gulir untuk memperbesar + Drag to pan · Scroll to zoom · Double-click to fit Seret untuk menggeser · Gulir untuk memperbesar · Klik dua kali untuk menyesuaikan Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel Show/Hide Panels Tampilkan/Sembunyikan Panel Could not open Tidak dapat membuka diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 060ac72fc6..de47f7bcaa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2395,6 +2395,7 @@ cameraRaw Amount Quantità Click to pick · Esc to cancel Clic per scegliere · Esc per annullare Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare Scroll to zoom Scorri per ingrandire + Drag to pan · Scroll to zoom · Double-click to fit Trascina per spostare · Scorri per ingrandire · Doppio clic per adattare Show/Hide All Panels Mostra/nascondi tutti i pannelli Show/Hide Panels Mostra/nascondi pannelli Could not open Impossibile aprire diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 40749c2d0b..f2d3bd6694 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2295,6 +2295,7 @@ cameraRaw Color カラー Click to pick · Esc to cancel クリックで選択 · Escでキャンセル Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル Scroll to zoom スクロールしてズーム + Drag to pan · Scroll to zoom · Double-click to fit ドラッグで移動 · スクロールでズーム · ダブルクリックで全体表示 Show/Hide All Panels すべてのパネルを表示/非表示 Show/Hide Panels パネルを表示/非表示 Could not open 開けませんでした diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index a93a20be59..84f30fc19c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2908,6 +2908,7 @@ cameraRaw Color 색상 Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소 Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소 Scroll to zoom 스크롤하여 확대/축소 + Drag to pan · Scroll to zoom · Double-click to fit 드래그하여 이동 · 스크롤하여 확대/축소 · 두 번 클릭하여 맞추기 Show/Hide All Panels 모든 패널 표시/숨기기 Show/Hide Panels 패널 표시/숨기기 Could not open 열 수 없음 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fc5809033c..fde9f99743 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2331,6 +2331,7 @@ Click to pick · Esc to cancel Klik om te kiezen · Esc om te annuleren Pick a screen pixel or press Esc to cancel Kies een schermpixel of druk op Esc om te annuleren Scroll to zoom Scrollen om te zoomen + Drag to pan · Scroll to zoom · Double-click to fit Sleep om te pannen · Scroll om te zoomen · Dubbelklik om passend te maken Show/Hide All Panels Alle panelen tonen/verbergen Show/Hide Panels Panelen tonen/verbergen Could not open Kan niet openen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9d3ecd9433..29dfcef28d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3335,6 +3335,7 @@ cameraRaw Roughness Chropowatość Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować Scroll to zoom Przewijaj, aby zmieniać powiększenie + Drag to pan · Scroll to zoom · Double-click to fit Przeciągnij, aby przesunąć · Przewijaj, aby powiększyć · Kliknij dwukrotnie, aby dopasować Open in camera raw Otwórz w Camera Raw System title bar Systemowy pasek tytułu More documents Więcej dokumentów diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26e9fd07e..a68ba566ff 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2750,6 +2750,7 @@ cameraRaw Whites Brancos Click to pick · Esc to cancel Clique para escolher · Esc para cancelar Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar Scroll to zoom Role para ampliar + Drag to pan · Scroll to zoom · Double-click to fit Arraste para mover · Role para ampliar · Clique duas vezes para ajustar Show/Hide All Panels Mostrar/ocultar todos os painéis Show/Hide Panels Mostrar/ocultar painéis Could not open Não foi possível abrir diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e57d0686b9..e6caf04cb9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2592,6 +2592,7 @@ cameraRaw Roughness Неровность Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены Scroll to zoom Колесо — изменить масштаб + Drag to pan · Scroll to zoom · Double-click to fit Перетаскивание — перемещение · Колесо — масштаб · Двойной щелчок — вписать Show/Hide All Panels Показать/скрыть все панели Show/Hide Panels Показать/скрыть панели Could not open Не удалось открыть diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3f552460ca..d944622ec2 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3805,3 +3805,4 @@ cameraData Lens Lens Action playback stopped Eylem oynatma durduruldu Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor @plural {n} action|{n} actions {n} eylem + Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f7feba19a7..bd93075fa2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2564,6 +2564,7 @@ cameraRaw Roughness Шорсткість Click to pick · Esc to cancel Клацніть для вибору · Esc для скасування Pick a screen pixel or press Esc to cancel Виберіть піксель екрана або натисніть Esc для скасування Scroll to zoom Прокрутіть для масштабування + Drag to pan · Scroll to zoom · Double-click to fit Перетягніть для панорамування · Прокрутіть для масштабування · Двічі клацніть, щоб умістити Show/Hide All Panels Показати/приховати всі панелі Show/Hide Panels Показати/приховати панелі Could not open Не вдалося відкрити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 890533bab2..ea93bc326c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2038,6 +2038,7 @@ layers Fill: Độ tô: Sample Size: Kích thước mẫu: Save As Preset Lưu thành thiết lập sẵn Scroll to zoom Cuộn để thu phóng + Drag to pan · Scroll to zoom · Double-click to fit Kéo để xem toàn cảnh · Cuộn để thu phóng · Bấm đúp để vừa khung Selection Preview: Xem trước vùng chọn: Set additional Crop options Đặt tùy chọn Cắt bổ sung Set Black Point Đặt điểm đen diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2f13bc3bfb..9e66946f4f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2330,6 +2330,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 单击选取 · Esc 取消 Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消 Scroll to zoom 滚动以缩放 + Drag to pan · Scroll to zoom · Double-click to fit 拖动以平移 · 滚动以缩放 · 双击以适应窗口 Show/Hide All Panels 显示/隐藏所有面板 Show/Hide Panels 显示/隐藏面板 Could not open 无法打开 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b72aa4509b..b7685958e3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2300,6 +2300,7 @@ cameraRaw Blending 混合 Click to pick · Esc to cancel 按一下選取 · Esc 取消 Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消 Scroll to zoom 捲動以縮放 + Drag to pan · Scroll to zoom · Double-click to fit 拖曳以平移 · 捲動以縮放 · 按兩下以符合視窗 Show/Hide All Panels 顯示/隱藏所有面板 Show/Hide Panels 顯示/隱藏面板 Could not open 無法開啟 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f23745efdb..cd4df4fbd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,6 +64,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor` - Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart. - Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`). + - Save for Web (`ui.menu.invoke {id: "file.export.saveForWebLegacy"}`) is resizable with independently scrolling settings. Dialog fields `__view` (`original|optimized|2up|4up`), `__zoom` (`null` = Fit, otherwise a factor; 1 = 100% in physical display pixels), and `__center: [u,v]` control the shared preview navigation. `__previewRects` reports pane rectangles in screen points. Drag a preview to pan, scroll/pinch to zoom at the pointer, or double-click to fit. GIF/PNG-8 expose `colorTable: ["#rrggbb", …]` (opaque source entries) and `colorTableEdits: {"0":"#rrggbb", …}` (zero-based replacements after quantization). A swatch opens a color picker; edits update the preview and saved file without remapping pixel indices or changing the document. Reset clears replacements. Changing palette/size/matte/transparency settings regenerates the table and clears index-based edits. Previews and generated tables use a bounded 2048-pixel proxy; full-size export uses that explicit table unchanged. `__paletteApproximate` reports proxy-derived palette generation, and enlarged proxy previews are labelled. The engine export command accepts the same table/edit parameters; its no-path estimate returns the source `colorTable` and `transparentIndex` as well as size statistics. Opening a new dialog does not carry a previous image's table across. - A pixel tool pressed on a type, shape, Smart Object or fill layer (e.g. through `ui.pointer`) opens the "Rasterize?" prompt instead of painting: a dialog with `__rasterize` (`type|shape|smartObject|fill`), `message`, `layer`, `tool` and `at`. `ui.dialog.confirm` runs the `layer.rasterize.*` command and then paints at `at` (two history states, `{"rasterized", "painted"}`); `ui.dialog.cancel` does nothing. - Edit › Transform › Warp on editable type (`ui.menu.invoke {"id":"edit.transform.warp"}`, or the warp toggle of an open transform box) opens Warp mode with no prompt, as in Photoshop: the mesh can't be dragged or split, only the options bar's warp style, Bend, H and V apply, and the commit sets the layer's Warp Text in one step, so the type stays editable. Directly: `engine.execute {"command":"edit.transform.warp","params":{"style":"flag","bend":40}}` on a type layer. Distort and Perspective stay greyed for type. - `ui.window.open {document?}` / `ui.window.close {window}`: extra document windows From 3e3a948c0bf3a18adbfdd1565eb2a54db37fdd08 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 12 Oct 2026 17:53:09 -0700 Subject: [PATCH 241/523] Tests: GPU startup marker tests wait while another test's child process holds the lock (#4251) returned_non_graphics_errors_do_not_trigger_backend_recovery failed at random in full `cargo test` runs: "gpu-error: ...", left: false, right: true. Its gpu-error case drops the sentinel, which leaves the marker file in place, and calls `Sentinel::begin` again for the same directory. Tests in monitor_profile.rs spawn processes in the same binary. A child process started while the marker is open gets a copy of its file descriptor, and the marker stays locked through that copy until the child's exec closes it. When `begin` runs in that window, it returns `Previous::Busy` instead of the crashed marker, and the assertion fails. sentinel_detects_a_crash_and_clears_on_success has retried `begin` for this since 3b7870be (#289). The retry moves into a test helper, `begin_when_unlocked`, which also runs where returned_non_graphics_errors_do_not_trigger_backend_recovery and a_failed_cpu_start_does_not_pin_later_starts start again after dropping or finishing their sentinel. In a_failed_cpu_start_does_not_pin_later_starts the CPU plan finishes the sentinel, which removes the marker, so the helper's first `begin` finds the lock free; the helper covers that test's branch for a plan that keeps the marker. A new test, begin_when_unlocked_waits_for_a_briefly_held_lock, takes the marker's lock through a second handle after the sentinel is dropped, releases it from a thread after 50 ms, and checks that the helper returns the crashed marker. It fails when the helper calls `begin` only once. --- apps/photocraft/src/gpu_startup.rs | 54 +++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/photocraft/src/gpu_startup.rs b/apps/photocraft/src/gpu_startup.rs index 4a612d3a0e..bef5bb41f6 100644 --- a/apps/photocraft/src/gpu_startup.rs +++ b/apps/photocraft/src/gpu_startup.rs @@ -672,7 +672,7 @@ mod tests { } else { s.finish(); } - let (prev, _s) = Sentinel::begin(&dir); + let (prev, _s) = begin_when_unlocked(&dir); assert_eq!(prev, Previous::Clean); assert_eq!(plan(Auto, prev.crashed(), None, false, Os::Other).backend, Auto); let _ = std::fs::remove_dir_all(&dir); @@ -684,6 +684,22 @@ mod tests { d } + /// `Sentinel::begin`, called again up to 50 times, 10 ms apart, while it returns + /// `Previous::Busy`. Other tests in this binary spawn processes: between fork and exec a child + /// briefly shares the marker's flock, so `begin` can see the lock as busy just after a test + /// drops its sentinel. + fn begin_when_unlocked(dir: &Path) -> (Previous, Option) { + let (mut prev, mut s) = Sentinel::begin(dir); + for _ in 0..50 { + if prev != Previous::Busy { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + (prev, s) = Sentinel::begin(dir); + } + (prev, s) + } + #[test] fn sentinel_detects_a_crash_and_clears_on_success() { let dir = temp_dir("cycle"); @@ -699,16 +715,7 @@ mod tests { assert!(none.is_none()); // The process dies in the driver: the lock goes, the marker stays. drop(s); - // Another test in this binary spawns a process: between its fork and exec the child briefly - // shares this file's flock, so `begin` can see the lock as busy. Retry for a moment. - let (mut prev, mut s) = Sentinel::begin(&dir); - for _ in 0..50 { - if prev != Previous::Busy { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - (prev, s) = Sentinel::begin(&dir); - } + let (prev, s) = begin_when_unlocked(&dir); let m = prev.crashed().cloned().expect("crash detected"); assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan))); // This start succeeds: the next one finds nothing. @@ -721,6 +728,29 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn begin_when_unlocked_waits_for_a_briefly_held_lock() { + let dir = temp_dir("shared-lock"); + let (_, s) = Sentinel::begin(&dir); + let mut s = s.expect("sentinel"); + s.write(Marker { backend: "vulkan".into(), ..Default::default() }).unwrap(); + // After the start dies, a second handle on the marker holds the lock for 50 ms, as a + // spawned child's copy of the descriptor does until the child calls exec. + let held = File::open(dir.join(MARKER_FILE)).unwrap(); + drop(s); + held.lock().unwrap(); + assert_eq!(Sentinel::begin(&dir).0, Previous::Busy); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + held.unlock().unwrap(); + }); + let (prev, s) = begin_when_unlocked(&dir); + release.join().unwrap(); + assert_eq!(prev.crashed().map(|m| m.backend.as_str()), Some("vulkan")); + s.expect("sentinel").finish(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn returned_non_graphics_errors_do_not_trigger_backend_recovery() { let cases = [ @@ -738,7 +768,7 @@ mod tests { } else { s.finish(); } - let (previous, s) = Sentinel::begin(&dir); + let (previous, s) = begin_when_unlocked(&dir); assert_eq!(previous.crashed().is_some(), keep, "{name}: {result:?}"); if !keep { assert_eq!(plan(Vulkan, previous.crashed(), None, false, Os::Other).backend, Vulkan); From 8145bea70f4f8684bef3ebcfa46d77ecbf23c3bc Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:53:33 +0200 Subject: [PATCH 242/523] Control: a transport that gives up knows whether the command was dispatched (#3662) (#4256) * Control: a timeout says whether the command ran (#3662) A request still queued when its deadline passes is dropped before dispatch, but it answered with the same text as the transport giving up on a command that may still apply. The two now read differently: "timeout: not run ... safe to re-send" and "timeout: no reply in 60s ... may still apply", both keeping the `timeout` prefix machines match on. * Control: a transport that gives up knows whether the command was dispatched (#3662) The transport and the UI thread share the reply deadline, and the transport's wait runs out first, so the UI thread's "not run" reply almost never reached a client. A request now carries a dispatch flag settled once between them: the UI thread claims it before running, the transport abandons it when it stops waiting. The transport then replies "not run" for a request that will never run, and "may still apply" only for one that was dispatched; a request given up on is never run. --- apps/photocraft/src/control_server.rs | 5 +- crates/ui-egui/src/control.rs | 97 +++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 +- docs/control-protocol.md | 2 +- 4 files changed, 97 insertions(+), 11 deletions(-) diff --git a/apps/photocraft/src/control_server.rs b/apps/photocraft/src/control_server.rs index b1282419d8..258d89e1ec 100644 --- a/apps/photocraft/src/control_server.rs +++ b/apps/photocraft/src/control_server.rs @@ -12,7 +12,7 @@ use photocraft_automation::security::{ ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line, repeat_auth_reply, }; use photocraft_ui_egui::ControlRequest; -use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error}; +use photocraft_ui_egui::control::REPLY_DEADLINE; use serde_json::{Value, json}; pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver { @@ -93,12 +93,13 @@ fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui:: let deadline = Instant::now() + REPLY_DEADLINE; let (mut req, rrx) = ControlRequest::new(method, params); req.deadline = Some(deadline); + let dispatch = req.dispatch.clone(); if tx.send(req).is_err() { break; } ctx.request_repaint(); let mut r = - rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()})); + rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": dispatch.give_up()})); if let Some(o) = r.as_object_mut() { o.insert("id".into(), id); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d0ef48f11..fb1f652576 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -46,12 +46,22 @@ pub type ControlResponse = Value; /// The control server's per-request reply deadline. pub const REPLY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(60); -/// The error both timeout sites return when a request's [`REPLY_DEADLINE`] passes: the -/// transport thread waiting for a reply, and the UI thread rejecting a request it is only -/// now draining. Built from the same constant, so the text cannot drift from the deadline. +/// The error the transport thread returns when a dispatched command gave no reply within +/// [`REPLY_DEADLINE`]. It warns that the command may still apply: re-sending blindly could apply +/// an edit twice (#3662). Built from the deadline constant, so the text cannot drift from it. pub fn timeout_error() -> String { format!( - "timeout: no reply in {}s — the command may still be running, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + "timeout: no reply in {}s — the command may still be running and may still apply, or the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", + REPLY_DEADLINE.as_secs() + ) +} + +/// The error for a request still queued when its deadline passed, from the UI thread or the +/// transport (see [`Dispatch`]). It was dropped before dispatch, so re-sending it is safe (#3662). Same `timeout` prefix as +/// [`timeout_error`], which machines match on. +pub fn expired_error() -> String { + format!( + "timeout: not run — the request was still queued when its {}s deadline passed, so it was dropped before dispatch and is safe to re-send; the app was not drawing frames (display asleep, window fully occluded, or a long operation blocked the frame loop)", REPLY_DEADLINE.as_secs() ) } @@ -62,12 +72,44 @@ pub struct ControlRequest { pub reply: Sender, /// Requests still queued at this instant are rejected; already dispatched work continues. pub deadline: Option, + /// Shared with the transport, so that when it stops waiting it knows whether the command ran. + pub dispatch: Dispatch, } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx, deadline: None }, rx) + (Self { method: method.into(), params, reply: tx, deadline: None, dispatch: Dispatch::default() }, rx) + } +} + +/// Whether a request was dispatched, settled once between the UI thread (about to run it) and +/// the transport (giving up on its reply). The transport's clock runs out no later than the UI +/// thread's, so without this it could only ever say the command may still apply (#3662). +#[derive(Clone, Default)] +pub struct Dispatch(std::sync::Arc); + +const QUEUED: u8 = 0; +const DISPATCHED: u8 = 1; +const ABANDONED: u8 = 2; + +impl Dispatch { + /// The UI thread claims the request to run it: false once the transport gave up on it. A + /// request the UI thread rejects as expired stays queued, which `give_up` reads as not run. + pub(crate) fn start(&self) -> bool { + self.settle(DISPATCHED) == DISPATCHED + } + + /// The transport stops waiting: the error to reply with, [`expired_error`] when the command + /// can no longer run and [`timeout_error`] when it was dispatched and may still apply. + pub fn give_up(&self) -> String { + if self.settle(ABANDONED) == DISPATCHED { timeout_error() } else { expired_error() } + } + + /// Moves a queued request to `to`; returns the state it ends in. + fn settle(&self, to: u8) -> u8 { + use std::sync::atomic::Ordering; + self.0.compare_exchange(QUEUED, to, Ordering::AcqRel, Ordering::Acquire).map_or_else(|state| state, |_| to) } } @@ -1565,11 +1607,54 @@ mod tests { retry.deadline = Some(Instant::now() + Duration::from_secs(60)); tx.send(retry).unwrap(); app.drain_control(&egui::Context::default()); - assert_eq!(expired_reply.try_recv().unwrap(), json!({"ok": false, "error": super::timeout_error()})); + let expired_error = expired_reply.try_recv().unwrap()["error"].as_str().unwrap().to_string(); assert_eq!(retry_reply.try_recv().unwrap()["ok"], true); let after = app.session.active().unwrap(); assert_eq!(after.doc.layers.len(), layers + 1, "only the live retry adds a layer"); assert_eq!(after.history.past_len(), steps + 1, "the expired edit adds no undo step"); + // #3662: a caller must be able to tell this reply, safe to re-send, from the + // transport's, sent while the command may still apply. + assert!(expired_error.starts_with("timeout"), "machines match on the prefix: {expired_error}"); + assert!(expired_error.contains("not run"), "the expired request says it never ran: {expired_error}"); + let transport_error = super::timeout_error(); + assert!(!transport_error.contains("not run"), "the transport can't know it didn't run: {transport_error}"); + assert!(transport_error.contains("may still apply"), "the transport warns against a blind re-send: {transport_error}"); + } + + #[test] + fn a_transport_that_gives_up_learns_whether_the_command_can_still_run() { + // #3662: the transport's clock runs out no later than the UI thread's, so it is the + // one that answers; it must still tell a request that will never run from one that may. + use std::time::{Duration, Instant}; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 8, "height": 8})).unwrap(); + let layers = app.session.active().unwrap().doc.layers.len(); + let (tx, rx) = std::sync::mpsc::channel(); + app = app.with_control(rx); + let ctx = egui::Context::default(); + let request = |deadline: Instant| { + let (mut req, reply) = ControlRequest::new("engine.execute", json!({"command": "layer.new.layer"})); + req.deadline = Some(deadline); + (req.dispatch.clone(), req, reply) + }; + + let (dispatch, queued, _) = request(Instant::now() + Duration::from_secs(60)); + tx.send(queued).unwrap(); + assert_eq!(dispatch.give_up(), super::expired_error(), "still queued: it will not run"); + app.drain_control(&ctx); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers, "a request given up on never runs"); + + let (dispatch, expired, _) = request(Instant::now() - Duration::from_secs(1)); + tx.send(expired).unwrap(); + app.drain_control(&ctx); + assert_eq!(dispatch.give_up(), super::expired_error(), "rejected by the UI thread: it did not run"); + + let (dispatch, live, reply) = request(Instant::now() + Duration::from_secs(60)); + tx.send(live).unwrap(); + app.drain_control(&ctx); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert_eq!(app.session.active().unwrap().doc.layers.len(), layers + 1); + assert_eq!(dispatch.give_up(), super::timeout_error(), "dispatched: it may still apply"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2bfa9e96fc..96547c0d2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1510,8 +1510,8 @@ impl PhotocraftApp { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); - if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { - let _ = reply.send(serde_json::json!({"ok": false, "error": control::timeout_error()})); + if req.deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) || !req.dispatch.start() { + let _ = reply.send(serde_json::json!({"ok": false, "error": control::expired_error()})); continue; } match control::handle(self, ctx, &req) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index cd4df4fbd5..f698ee2a6c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ Each reply is one JSON line with the same `id`: {"id": 2, "ok": false, "error": "unknown tool `foo`"} ``` -The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. +The desktop server waits up to 60 seconds for a reply. A request still queued at that deadline is rejected before dispatch; a timeout does not cancel work that has already started. The two timeouts read differently, both starting with `timeout`: a request that was never dispatched (still queued at the deadline) says `timeout: not run` and is safe to re-send, while `timeout: no reply in 60s` means the command was dispatched and may still be running and still apply, so check the document before re-sending it. The timeout error names the likely causes: the command may still be running, or the request was queued while the window was not drawing frames — on Wayland the frame update (and with it the control drain) only runs on compositor frame callbacks, so a display that is asleep or a fully occluded window makes every request time out while the app itself is healthy. The transport is `apps/photocraft/src/control_server.rs`, and the handlers are in `crates/ui-egui/src/control.rs`. The MCP server (`photocraft-cli mcp --bridge 127.0.0.1:`, crate `photocraft-automation`) wraps this same protocol. See [MCP bridge](#mcp-bridge) below. From 6f41b324a66d608d0e3ba604b8f52e79277f6668 Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 08:53:49 +0800 Subject: [PATCH 243/523] Canvas: a tool drag past the edge auto-scrolls the view (#4183) (#4257) * Canvas: a tool drag past the edge auto-scrolls the view (#4183) Dragging a marquee, lasso, stroke or crop off the visible part of a zoomed image now scrolls toward the pointer, as in Photoshop, and stops at the scroll limits. An axis where the image fits does not scroll, and an overscrolled view is not pulled back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VFAjTZQw6MTyhfpQqjAfoW * Fix rotated canvas auto-scroll limits --------- Co-authored-by: Claude Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 25 ++- crates/ui-egui/src/canvas_autoscroll.rs | 223 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/scrollbars.rs | 30 ++- crates/ui-egui/tests/marquee_autoscroll.rs | 183 +++++++++++++++++ 5 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/canvas_autoscroll.rs create mode 100644 crates/ui-egui/tests/marquee_autoscroll.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 38d1e33f69..d028918266 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -887,6 +887,14 @@ impl ViewXform { let u = self.unmap_vec(d); [(u.x + self.center[0]) as f64, (u.y + self.center[1]) as f64] } + /// On-screen bounds of the image, including camera rotation and pixel aspect correction. + pub fn image_bounds(self, size: [u32; 2]) -> Rect { + let (c, s) = self.sincos(); + let w = size[0] as f32 * self.sx() * self.zoom; + let h = size[1] as f32 * self.zoom; + let center = self.to_screen(size[0] as f32 / 2.0, size[1] as f32 / 2.0); + Rect::from_center_size(center, vec2(c.abs() * w + s.abs() * h, s.abs() * w + c.abs() * h)) + } pub fn doc_rect(&self, r: DRect) -> Rect { let pts = [ self.to_screen(r.x0 as f32, r.y0 as f32), @@ -2657,7 +2665,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: fill_view(&mut view, &doc, rect.size(), ppp, aspect); } // Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs). - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.request_repaint(); } let flip = app.ui.view.flip_horizontal; @@ -3055,7 +3063,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: view.center[1] -= d.y; } // Overscroll off: the glide stops at the pan limit, as the scrollbars do. - if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size() / vec2(aspect, 1.0), ppp) { + if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view_with_aspect(&mut view, rect.size(), ppp, aspect) { ctx.data_mut(|m| m.get_temp_mut_or_default::(flick_id).stop()); } } @@ -3678,6 +3686,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if let Some(b) = layer_edges_rect(app, idx) { painter.set(layer_edges, egui::Shape::rect_stroke(xf.doc_rect(b), 0, Stroke::new(1.0, LAYER_EDGES), egui::StrokeKind::Outside)); } + // A tool drag held past the canvas edge scrolls the view toward the pointer (#4183); the + // drag's next move then extends into what scrolled in. + if primary + && app.drag.is_some() + && tool != Tool::Hand + && !middle + && let Some(p) = ui.input(|i| i.pointer.primary_down().then(|| i.pointer.latest_pos()).flatten()) + { + let step = crate::canvas_autoscroll::edge_step(p, rect, ui.input(|i| i.stable_dt)); + if crate::canvas_autoscroll::scroll(&mut view, step, xf) { + ctx.request_repaint(); + } + } // Scrollbars (scrollbars.rs): drawn over the canvas edges, they take the pointer there. let t0 = crate::gpu_canvas::now_ms(); let before = view.center; diff --git a/crates/ui-egui/src/canvas_autoscroll.rs b/crates/ui-egui/src/canvas_autoscroll.rs new file mode 100644 index 0000000000..2fafbdb105 --- /dev/null +++ b/crates/ui-egui/src/canvas_autoscroll.rs @@ -0,0 +1,223 @@ +//! Auto-scroll while a tool drag runs past the canvas edge (#4183). As in Photoshop, dragging a +//! marquee, lasso, brush stroke or crop off the visible part of a zoomed image scrolls the view +//! toward the pointer, so a selection can be larger than what is on screen. The view stops at the +//! scroll limits (it never auto-scrolls an image that already fits into empty pasteboard). + +use crate::canvas::ViewXform; +use crate::state::View; +use egui::{Pos2, Rect, Vec2, vec2}; + +/// Slowest and fastest auto-scroll, in egui points per second. +const MIN_SPEED: f32 = 120.0; +const MAX_SPEED: f32 = 2400.0; +/// Extra speed per point the pointer is past the edge. +const GAIN: f32 = 12.0; +/// A long frame moves at most this many seconds' worth, so a stall doesn't jump the view. +const MAX_DT: f32 = 0.05; + +/// How far, in screen points, to scroll this frame for a drag whose pointer is at `pointer` over a +/// canvas of `rect`. Zero while the pointer is inside the canvas. Each axis points toward the +/// pointer and speeds up the further past the edge it is. +pub fn edge_step(pointer: Pos2, rect: Rect, dt: f32) -> Vec2 { + if !pointer.x.is_finite() || !pointer.y.is_finite() || !dt.is_finite() || dt <= 0.0 || rect.width() <= 0.0 || rect.height() <= 0.0 { + return Vec2::ZERO; + } + let past = |p: f32, lo: f32, hi: f32| { + if p < lo { + p - lo + } else if p > hi { + p - hi + } else { + 0.0 + } + }; + let dt = dt.min(MAX_DT); + let axis = |d: f32| if d == 0.0 { 0.0 } else { d.signum() * (MIN_SPEED + GAIN * d.abs()).min(MAX_SPEED) * dt }; + vec2(axis(past(pointer.x, rect.left(), rect.right())), axis(past(pointer.y, rect.top(), rect.bottom()))) +} + +/// Move the camera by `step` in screen points, never past the image's on-screen bounds in the +/// direction of travel. A view the user already panned past a limit (Overscroll on) is not pulled +/// back: it just doesn't scroll further out. True when the view moved. +pub fn scroll(view: &mut View, step: Vec2, xf: ViewXform) -> bool { + if !step.is_finite() || step == Vec2::ZERO || !xf.zoom.is_finite() || xf.zoom <= 0.0 { + return false; + } + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() || !xf.rect.is_finite() { + return false; + } + // Compare screen axes before unrotating: a document axis that fits before rotation may + // be clipped afterwards, and a vertical screen drag must remain vertical at any angle. + let axis = |step: f32, lo: f32, hi: f32, v0: f32, v1: f32| { + if hi - lo <= v1 - v0 { + 0.0 + } else if step > 0.0 { + step.min((hi - v1).max(0.0)) + } else { + step.max((lo - v0).min(0.0)) + } + }; + let step = vec2( + axis(step.x, image.left(), image.right(), xf.rect.left(), xf.rect.right()), + axis(step.y, image.top(), image.bottom(), xf.rect.top(), xf.rect.bottom()), + ); + let d = xf.unmap_vec(step) / xf.zoom; + let moved = d.is_finite() && (d.x.abs() > 1e-3 || d.y.abs() > 1e-3); + if moved { + view.center[0] += d.x; + view.center[1] += d.y; + } + moved +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::pos2; + + // Existing unrotated cases express their requested movement in document pixels. + fn scroll(view: &mut View, doc_step: Vec2, size: Vec2, ppp: f32) -> bool { + let xf = ViewXform { + rect: Rect::from_min_size(Pos2::ZERO, size), + zoom: view.zoom / ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + }; + super::scroll(view, doc_step * xf.zoom, xf) + } + + fn rect() -> Rect { + Rect::from_min_max(pos2(100.0, 50.0), pos2(500.0, 350.0)) + } + + #[test] + fn inside_the_canvas_does_not_scroll() { + assert_eq!(edge_step(pos2(300.0, 200.0), rect(), 1.0 / 60.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(100.0, 350.0), rect(), 1.0 / 60.0), Vec2::ZERO, "the edge itself is inside"); + } + + #[test] + fn past_an_edge_scrolls_toward_the_pointer() { + let dt = 1.0 / 60.0; + let right = edge_step(pos2(520.0, 200.0), rect(), dt); + assert!(right.x > 0.0 && right.y == 0.0, "{right:?}"); + let left = edge_step(pos2(80.0, 200.0), rect(), dt); + assert!(left.x < 0.0 && left.y == 0.0, "{left:?}"); + let corner = edge_step(pos2(90.0, 360.0), rect(), dt); + assert!(corner.x < 0.0 && corner.y > 0.0, "{corner:?}"); + } + + #[test] + fn further_out_is_faster_up_to_a_cap() { + let dt = 1.0 / 60.0; + let near = edge_step(pos2(505.0, 200.0), rect(), dt).x; + let far = edge_step(pos2(600.0, 200.0), rect(), dt).x; + let very_far = edge_step(pos2(50_000.0, 200.0), rect(), dt).x; + assert!(near < far, "{near} < {far}"); + assert!((very_far - MAX_SPEED * dt).abs() < 1e-3, "capped: {very_far}"); + } + + #[test] + fn hostile_input_does_not_scroll() { + let dt = 1.0 / 60.0; + assert_eq!(edge_step(pos2(f32::NAN, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(f32::INFINITY, 200.0), rect(), dt), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), f32::NAN), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), rect(), -1.0), Vec2::ZERO); + assert_eq!(edge_step(pos2(600.0, 200.0), Rect::NOTHING, dt), Vec2::ZERO); + // A multi-second stall moves no more than one capped frame. + assert!(edge_step(pos2(600.0, 200.0), rect(), 10.0).x <= MAX_SPEED * MAX_DT + 1e-3); + } + + fn view(zoom: f32, center: [f32; 2]) -> View { + View { zoom, center, doc_size: [2000, 1000], ..Default::default() } + } + + #[test] + fn a_zoomed_view_scrolls_and_stops_at_the_limit() { + // 2000 × 1000 at 100 % in a 400 × 300 canvas: the centre can range over 200..1800 in x. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + let mut v = view(1.0, [1790.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1800.0, 500.0], "stops at the right limit"); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0), "and stays there"); + } + + #[test] + fn an_image_that_fits_does_not_scroll() { + let mut v = view(0.1, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + // Wider than the view but not taller: only x scrolls. + let mut v = view(0.25, [1000.0, 500.0]); + assert!(scroll(&mut v, vec2(50.0, 50.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1050.0, 500.0]); + } + + #[test] + fn an_overscrolled_view_is_not_pulled_back() { + // Already panned past the right limit (Overscroll on): scrolling further right does + // nothing, scrolling left moves normally. + let size = vec2(400.0, 300.0); + let mut v = view(1.0, [1900.0, 500.0]); + assert!(!scroll(&mut v, vec2(50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1900.0, 500.0]); + assert!(scroll(&mut v, vec2(-50.0, 0.0), size, 1.0)); + assert_eq!(v.center, [1850.0, 500.0]); + } + + #[test] + fn non_finite_steps_are_ignored() { + let mut v = view(1.0, [1000.0, 500.0]); + assert!(!scroll(&mut v, vec2(f32::NAN, 0.0), vec2(400.0, 300.0), 1.0)); + assert_eq!(v.center, [1000.0, 500.0]); + } + + #[test] + fn quarter_turn_limits_follow_the_displayed_image_with_hidpi_and_pixel_aspect() { + for rotation in [-90.0, 90.0] { + for flip in [false, true] { + let mut v = View { zoom: 2.0, center: [300.0, 600.0], doc_size: [600, 1200], rotation, ..Default::default() }; + let xf = ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation, aspect: 2.0 }; + assert!(super::scroll(&mut v, vec2(0.0, 10_000.0), xf)); + // At 2:1 pixel aspect the document's 600-pixel width is 1200 screen points + // tall after rotation. The 400-point viewport exposes 200 document pixels. + let want = if rotation > 0.0 { 500.0 } else { 100.0 }; + assert!((v.center[0] - want).abs() < 0.01, "{rotation}, flip={flip}: {:?}", v.center); + assert!((v.center[1] - 600.0).abs() < 0.01); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0), "the next frame preserves the auto-scroll limit"); + let xf = ViewXform { center: v.center, ..xf }; + assert!(!super::scroll(&mut v, vec2(0.0, 20.0), xf)); + v.center = [300.0, 600.0]; + let xf = ViewXform { center: v.center, ..xf }; + assert!(super::scroll(&mut v, vec2(10_000.0, 0.0), xf)); + let want_y = if (rotation > 0.0) == flip { 800.0 } else { 400.0 }; + assert!((v.center[1] - want_y).abs() < 0.01 && (v.center[0] - 300.0).abs() < 0.01, "mirrored horizontal limit: {:?}", v.center); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 2.0, 2.0)); + } + } + } + + #[test] + fn oblique_rotation_keeps_a_vertical_scroll_vertical_and_a_fitting_axis_still() { + for flip in [false, true] { + let mut v = View { zoom: 1.0, center: [300.0, 300.0], doc_size: [600, 600], rotation: 45.0, ..Default::default() }; + let xf = + ViewXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(900.0, 400.0)), zoom: 1.0, center: v.center, flip, rotation: v.rotation, aspect: 1.0 }; + // A 600-pixel square at 45 degrees is about 849 points across: it fits horizontally + // but is clipped vertically. Gating document axes would produce a diagonal drift. + assert!(!super::scroll(&mut v, vec2(30.0, 0.0), xf)); + assert!(super::scroll(&mut v, vec2(0.0, 30.0), xf)); + let after = ViewXform { center: v.center, ..xf }; + let displacement = after.to_screen(300.0, 300.0) - xf.to_screen(300.0, 300.0); + assert!(displacement.x.abs() < 0.01 && (displacement.y + 30.0).abs() < 0.01, "{displacement:?}"); + assert!(!crate::scrollbars::clamp_view_with_aspect(&mut v, xf.rect.size(), 1.0, 1.0)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96547c0d2d..a71d8134a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ mod camera_raw_preview_ui; mod camera_raw_scope_ui; pub mod camera_raw_ui; pub mod canvas; +pub mod canvas_autoscroll; mod canvas_popup; pub mod canvas_tool_menu; pub mod cas_transform; diff --git a/crates/ui-egui/src/scrollbars.rs b/crates/ui-egui/src/scrollbars.rs index 2ab7cdeab6..e0867436c4 100644 --- a/crates/ui-egui/src/scrollbars.rs +++ b/crates/ui-egui/src/scrollbars.rs @@ -90,14 +90,40 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 { /// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a /// display with `ppp` physical pixels per point. True when the view moved (then repaint). pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool { + clamp_view_with_aspect(view, size, ppp, 1.0) +} + +/// Clamp against the displayed image bounds, so rotated tool auto-scroll is not undone by +/// the next frame's Overscroll-off clamp. `size` is the unmodified screen-space canvas size. +pub fn clamp_view_with_aspect(view: &mut View, size: egui::Vec2, ppp: f32, aspect: f32) -> bool { let [w, h] = view.doc_size; if w == 0 || h == 0 { return false; } let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; - let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)]; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.0 }; + let c = if view.rotation.abs() < 1e-8 { + [clamp_axis(view.center[0], w as f32, size.x / aspect, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)] + } else { + if !zoom.is_finite() || zoom <= 0.0 || !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 { + return false; + } + let center = [ + if view.center[0].is_finite() { view.center[0] } else { w as f32 / 2.0 }, + if view.center[1].is_finite() { view.center[1] } else { h as f32 / 2.0 }, + ]; + let xf = crate::canvas::ViewXform { rect: egui::Rect::from_min_size(Pos2::ZERO, size), zoom, center, flip: false, rotation: view.rotation, aspect }; + let image = xf.image_bounds(view.doc_size); + if !image.is_finite() { + return false; + } + let offset = xf.rect.center() - image.min; + let delta = vec2(clamp_axis(offset.x, image.width(), size.x, 1.0), clamp_axis(offset.y, image.height(), size.y, 1.0)) - offset; + let d = xf.unmap_vec(delta) / zoom; + [center[0] + d.x, center[1] + d.y] + }; // Ignore sub-pixel float noise so the clamp never keeps requesting frames. - let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; + let moved = !view.center[0].is_finite() || !view.center[1].is_finite() || (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3; if moved { view.center = c; } diff --git a/crates/ui-egui/tests/marquee_autoscroll.rs b/crates/ui-egui/tests/marquee_autoscroll.rs new file mode 100644 index 0000000000..2fc5772604 --- /dev/null +++ b/crates/ui-egui/tests/marquee_autoscroll.rs @@ -0,0 +1,183 @@ +//! #4183: dragging a Rectangular Marquee past the edge of a zoomed-in canvas scrolls the view, so +//! the selection can be larger than what is on screen (as in Photoshop). +//! +//! Before the fix the view stayed put and the selection stopped at the visible edge. The real app +//! runs offscreen on wgpu with real egui pointer events. Skips when no GPU adapter exists (like +//! `drag_preview_canvas.rs`). + +use egui::{Modifiers, PointerButton, Pos2}; +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, handle}; +use serde_json::{Value, json}; + +type Harness = egui_kittest::Harness<'static, PhotocraftApp>; + +fn harness() -> Option { + harness_with_size(egui::vec2(900.0, 640.0)) +} + +fn harness_with_size(size: egui::Vec2) -> Option { + let built = std::panic::catch_unwind(|| { + egui_kittest::Harness::builder().with_size(size).with_max_steps(64).wgpu().build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + if let Some(rs) = cc.wgpu_render_state.as_ref() { + app.set_wgpu(rs.clone()); + } + app + }) + }); + match built { + Ok(h) => Some(h), + Err(_) => { + eprintln!("skipping: no GPU adapter"); + None + } + } +} + +fn control(h: &mut Harness, method: &str, params: Value) { + let ctx = h.ctx.clone(); + let (req, _rx) = ControlRequest::new(method, params); + handle(h.state_mut(), &ctx, &req); + h.run_steps(3); +} + +/// A 3000 × 2000 document at 100 %, centred, with `tool` active: far larger than the canvas. +fn setup(h: &mut Harness, tool: &str) { + { + let app = h.state_mut(); + while app.session.active().is_some() { + app.run("file.close", json!({"discard": true})).expect("close"); + } + app.run("file.new", json!({"width": 3000, "height": 2000, "background": "white"})).expect("new"); + app.sync_views(); + app.ui.extras.rulers = false; + } + control(h, "ui.set", json!({"tool": tool, "zoom": 1.0, "center": [1500, 1000]})); + h.run_steps(3); +} + +fn xf(h: &Harness) -> ViewXform { + ViewXform::active(h.state()).expect("an active document view") +} + +fn pointer_at(h: &mut Harness, p: Pos2) { + h.event(egui::Event::PointerMoved(p)); + h.step(); +} + +fn button_at(h: &mut Harness, p: Pos2, pressed: bool) { + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); +} + +/// Selection bounds `[x0, y0, x1, y1]` in document pixels. +fn selection(h: &Harness) -> Option<[i32; 4]> { + let st = h.state().session.active()?; + let b = st.doc.selection.as_ref()?.content_bounds(); + (!b.is_empty()).then_some([b.x0, b.y0, b.x1, b.y1]) +} + +#[test] +fn marquee_dragged_past_the_right_edge_scrolls_and_selects_beyond_the_view() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let rect = start.rect; + // The document column at the canvas's right edge before the drag: the most a selection + // could reach without scrolling. + let visible_right = start.to_doc(egui::pos2(rect.right(), rect.center().y))[0]; + + let from = rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + let t = i as f32 / 6.0; + pointer_at(&mut h, from + egui::vec2((rect.right() - from.x - 10.0) * t, 60.0 * t)); + } + // Past the edge, then held still for a while: the view keeps scrolling. + let outside = egui::pos2(rect.right() + 40.0, from.y + 60.0); + pointer_at(&mut h, outside); + for _ in 0..30 { + h.step(); + } + let moved = xf(&h).center[0] - start.center[0]; + assert!(moved > 50.0, "the view scrolls right while the drag is held past the edge (moved {moved})"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 1e-3, "and only horizontally"); + + button_at(&mut h, outside, false); + h.run_steps(3); + let sel = selection(&h).expect("a selection"); + assert!(sel[2] as f64 > visible_right + 50.0, "the selection reaches past the column that was visible at the press: {sel:?} vs {visible_right}"); + + // Once released, the view no longer scrolls even with the pointer outside. + let after = xf(&h).center; + pointer_at(&mut h, outside); + h.run_steps(10); + assert_eq!(xf(&h).center, after); +} + +#[test] +fn a_drag_inside_the_canvas_does_not_scroll() { + let Some(mut h) = harness() else { return }; + setup(&mut h, "rectMarquee"); + let start = xf(&h); + let from = start.rect.center(); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + for i in 1..=6 { + pointer_at(&mut h, from + egui::vec2(15.0 * i as f32, 10.0 * i as f32)); + } + for _ in 0..20 { + h.step(); + } + assert_eq!(xf(&h).center, start.center); + button_at(&mut h, from + egui::vec2(90.0, 60.0), false); +} + +#[test] +fn rotated_marquee_scrolls_on_the_axis_that_is_visibly_clipped() { + let Some(mut h) = harness_with_size(egui::vec2(1200.0, 640.0)) else { return }; + setup(&mut h, "rectMarquee"); + let rect = xf(&h).rect; + assert!(rect.width() > rect.height()); + // Fits horizontally before rotation, but at 90 degrees it overflows vertically. + let width = ((rect.width() + rect.height()) / 2.0) as u32; + let height = (rect.width() * 2.0) as u32; + for overscroll in [true, false] { + { + let app = h.state_mut(); + app.run("file.close", json!({"discard": true})).expect("close"); + app.run("file.new", json!({"width": width, "height": height, "background": "white"})).expect("new"); + app.run("prefs.set", json!({"path": "tools.overscroll", "value": overscroll})).expect("overscroll"); + app.sync_views(); + } + control(&mut h, "ui.set", json!({"tool": "rectMarquee", "zoom": 1.0, "center": [width / 2, height / 2]})); + h.state_mut().ui.views[0].rotation = 90.0; + h.run_steps(3); + let start = xf(&h); + let from = start.rect.center(); + let outside = egui::pos2(from.x + 30.0, start.rect.bottom() + 40.0); + pointer_at(&mut h, from); + button_at(&mut h, from, true); + pointer_at(&mut h, outside); + h.run_steps(30); + let moved = xf(&h).center[0] - start.center[0]; + if let Some(dir) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).expect("fixture directory"); + h.render().expect("render").save(dir.join(format!("rotated-marquee-overscroll-{overscroll}.png"))).expect("save fixture"); + } + assert!(moved > 10.0, "rotated vertical overflow must scroll document x, overscroll={overscroll}, moved={moved}"); + let limit = width as f32 - start.rect.height() / (2.0 * start.zoom); + assert!((xf(&h).center[0] - limit).abs() < 0.01, "stops at the rotated image edge, overscroll={overscroll}"); + assert!((xf(&h).center[1] - start.center[1]).abs() < 0.01); + button_at(&mut h, outside, false); + h.run_steps(3); + let after = xf(&h).center; + h.run_steps(10); + assert_eq!(xf(&h).center, after); + } +} From 94b60422891de71fb66663a92781f6a44bb11ec8 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:53:58 +0700 Subject: [PATCH 244/523] fix(linux): follow GNOME's light default when the portal has no preference (#4289) --- apps/photocraft/src/appearance.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index 8389ee83eb..efaf6ccdb5 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -14,7 +14,7 @@ fn portal_proxy() -> Option> { #[cfg(target_os = "linux")] fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; - decode(portal_code(value)?) + portal_code(value).and_then(decode).or_else(gtk_theme) } #[cfg(target_os = "linux")] @@ -39,8 +39,8 @@ fn decode(code: u32) -> Option { } } -/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by -/// absolute path, and is never repeated. +/// GNOME fallback when the portal has no preference. Read only on the appearance worker, +/// using an absolute path rather than the shell's PATH. #[cfg(target_os = "linux")] fn gtk_theme() -> Option { let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; @@ -55,7 +55,7 @@ fn gtk_theme() -> Option { fn parse_gtk_scheme(s: &str) -> Option { match s.trim().trim_matches('\'') { "prefer-dark" => Some(egui::Theme::Dark), - "prefer-light" => Some(egui::Theme::Light), + "prefer-light" | "default" => Some(egui::Theme::Light), _ => None, } } @@ -97,7 +97,7 @@ pub fn service() -> Option { if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { continue; } - publisher.publish(portal_code(changed).and_then(decode)); + publisher.publish(portal_code(changed).and_then(decode).or_else(gtk_theme)); } }) } @@ -184,7 +184,10 @@ mod tests { assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); - assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + // GNOME's "default" appearance is light, even though the portal reports no preference. + assert_eq!(super::parse_gtk_scheme("'default'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("default"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'not-a-scheme'"), None); } /// #2798: a first read that misses the start-up wait still wakes the idle UI. From 21b018c8a9440751e75cddcaa39f52488b1ac334 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:54:11 -0600 Subject: [PATCH 245/523] Brush Settings: Brush Tip Shape lines up with the other section names (#4282) (#4294) The section list indented every row without an enable box by the box's width, except the first, so Brush Tip Shape started in the checkbox column while Smoothing, the other box-less row, lined up with the names. Photoshop indents Brush Tip Shape too. Every name now starts in the same column. Test: section_names_line_up_whether_or_not_they_have_a_box checks the painted x of every section name (Brush Tip Shape was 20 px left before). Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 4 +++- crates/ui-egui/src/brush_panel_tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index d018219238..8f51ca8572 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -295,7 +295,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting *flag = true; } x += 20.0; - } else if i > 0 { + } else { + // No box (Brush Tip Shape): the name still starts in the names' column, + // as in Photoshop (#4282). x += 20.0; } let color = if sel { t.text } else { t.text_dim }; diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 34dc0d9d4d..49ddc0063c 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -599,6 +599,31 @@ fn editing_the_options_bar_amount_reenables_smoothing() { assert_eq!(last_journal(h.state()).unwrap().1["brush"]["smoothing"]["enabled"], true); } +/// #4282: every name in the section list starts in the same column, Brush Tip Shape included, +/// whether or not its row has an enable box (Photoshop indents the rows without one). +#[test] +fn section_names_line_up_whether_or_not_they_have_a_box() { + fn text_x(shape: &egui::Shape, out: &mut Vec<(String, f32)>) { + match shape { + egui::Shape::Text(text) => out.push((text.galley.job.text.clone(), text.pos.x)), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_x(s, out)), + _ => {} + } + } + // Shape Dynamics is shown, so its heading paints too; the list's copy is the leftmost. + let h = harness(0, 1); + let mut painted = Vec::new(); + for shape in &h.output().shapes { + text_x(&shape.shape, &mut painted); + } + let left = |name: &str| painted.iter().filter(|(t, _)| t == name).map(|(_, x)| *x).reduce(f32::min); + let columns: Vec<(&str, f32)> = SECTIONS.iter().map(|(name, _)| (*name, left(name).unwrap_or_else(|| panic!("{name} painted")))).collect(); + let (_, boxed) = columns[1]; + for (name, x) in &columns { + assert!((x - boxed).abs() < 0.5, "{name} starts at {x}, the boxed names at {boxed}: {columns:?}"); + } +} + #[test] fn preview_strip_re_renders_only_when_the_brush_changes() { let mut h = harness(0, 0); From e777690ac611b44fc64bff4cf9f2d950f98bd729 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:54:20 -0600 Subject: [PATCH 246/523] Properties: the Studio-layout themes show it for every layer (#4246) (#4299) In the Studio-layout themes (Studio, Studio Light, Breeze, Adwaita, Solarized) Properties is a floating card over the canvas. The card returned early unless the active layer was an adjustment or fill layer or a mask was targeted, so for a pixel layer, the Background, type or a group nothing was drawn, although Properties was on (the rail button showed it). The Pro theme's docked panel shows all of those. The card now renders the docked panel's body (properties_body), so both show the same content: the Document properties for the Background or no selection, layer, adjustment and mask properties. Its title bar, hide button, position and drag are unchanged. Tests: floating_properties_card_shows_document_properties_for_the_background and floating_properties_card_shows_pixel_layer_properties (Studio and Breeze Dark) failed before the fix. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/mask_props_ui_tests.rs | 40 ++++++++++++++++++++ crates/ui-egui/src/panels.rs | 46 ++--------------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index d02af4f257..1cde904682 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -213,3 +213,43 @@ fn refine_row_opens_the_select_and_mask_and_color_range_dialogs() { } } } + +/// A Studio-layout theme's floating Properties card, with no mask targeted. +fn card_harness(new_layer: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64, "background": "white"})).unwrap(); + if new_layer { + session.execute("layer.new.layer", json!({})).unwrap(); + } + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + properties_window(app, ui.ctx()); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h +} + +#[test] +fn floating_properties_card_shows_document_properties_for_the_background() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(false, theme); + assert!(h.query_all_by_label("Document").next().is_some(), "{theme:?}: Document properties in the card"); + } +} + +#[test] +fn floating_properties_card_shows_pixel_layer_properties() { + for theme in [crate::theme::ThemeKind::Studio, crate::theme::ThemeKind::BreezeDark] { + let h = card_harness(true, theme); + assert!(h.query_all_by_label("Opacity").next().is_some(), "{theme:?}: layer controls in the card"); + assert!(h.query_all_by_label("Document").next().is_none()); + } +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f0e0046711..e8f5803b21 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3072,7 +3072,8 @@ fn snapshot_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> Option<(&'static // ----------------------------------------------------------------------------- properties -/// Floating Properties card anchored to the canvas' top-right corner. +/// Floating Properties card anchored to the canvas' top-right corner. It shows the same content as +/// the docked panel (`properties_body`): document, layer, adjustment and mask properties. pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.docking.is_some() { return; @@ -3081,14 +3082,7 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.properties || Tokens::get(ctx).pro { return; } - let Some(st) = app.session.active() else { return }; - let Some(id) = st.active_layer else { return }; - let doc = st.doc.clone(); - let Some(layer) = doc.layer(id) else { return }; - // Mask controls (pixel or vector) also belong here when their thumbnail is targeted. - let mask_target = crate::mask_props_ui::targeted(app, layer); - let vector_target = crate::mask_props_ui::vector_targeted(app, layer); - if !mask_target && !vector_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) { + if app.session.active().is_none() { return; } let t = Tokens::get(ctx); @@ -3124,41 +3118,9 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } drag = grip.drag_delta(); ui.add_space(6.0); - let icon = match &layer.content { - LayerContent::Adjustment(_) => "sliders-horizontal", - LayerContent::Group(_) => "folder", - LayerContent::Fill(_) => "paint-bucket", - _ => "image", - }; - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover()); - ui.painter().rect_filled(r, t.radius_sm, t.field); - icons::paint(ui, r, icon, 17.0, t.icon); - ui.vertical(|ui| { - ui.label(RichText::new(&layer.name).font(theme::medium(13.0)).color(t.text)); - let kind = match &layer.content { - LayerContent::Adjustment(a) => crate::i18n::fmt(tl!("{name} Properties"), &[("name", tl!(a.label()))]), - // Whole phrases ("Type Layer"), as the Properties header translates them. - other => tl!(&format!("{} Layer", other.kind_name())).to_string(), - }; - ui.label(RichText::new(kind).small().color(t.text_faint)); - }); - }); - ui.add_space(8.0); widgets::hairline(ui); ui.add_space(8.0); - // Per-layer ids, so text still being typed for one layer can't commit to the next. - ui.push_id((doc.id, id, mask_target), |ui| { - if mask_target { - crate::mask_props_ui::properties(app, ui, layer); - } else if vector_target { - crate::mask_props_ui::vector_properties(app, ui, layer); - } else if let LayerContent::Adjustment(adj) = &layer.content { - adjustment_controls(app, ui, id, adj); - } else { - layer_controls(app, ui, layer); - } - }); + properties_body(app, ui); }); }); if drag != egui::Vec2::ZERO && canvas.is_positive() { From 315c9cc0bcbd0560c85a5e6e6265fc28248f1550 Mon Sep 17 00:00:00 2001 From: Lessa Date: Mon, 12 Oct 2026 21:55:28 -0300 Subject: [PATCH 247/523] Give numeric type controls translated accessible names (#4310) * Give numeric type controls translated accessible names * Find the size and leading row by accessible names in layout tests --- crates/ui-egui/src/props_layout_tests.rs | 8 +++-- crates/ui-egui/src/type_tool.rs | 10 ++++-- crates/ui-egui/src/type_tool_tests.rs | 41 ++++++++++++++++++++---- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/props_layout_tests.rs b/crates/ui-egui/src/props_layout_tests.rs index 0b99569fe7..8f7fbc15f0 100644 --- a/crates/ui-egui/src/props_layout_tests.rs +++ b/crates/ui-egui/src/props_layout_tests.rs @@ -175,8 +175,12 @@ fn type_controls_fill_the_panel_at_every_width_and_scale() { .map(|r| r.right()) .fold(f32::MIN, f32::max); // (Both rows have a unit suffix: compare with the size/leading row.) - let top = fields.iter().map(|r| r.top()).fold(f32::MAX, f32::min); - let lead = fields.iter().filter(|r| (r.top() - top).abs() < 0.5).map(|r| r.right()).fold(f32::MIN, f32::max); + // Variable-font axes can precede the size/leading row and have no unit suffix. + // Compare the intended point-valued controls, retaining the same edge tolerance. + let lead = ["Font size", "Leading (set to the font size × auto-leading when Auto)"] + .into_iter() + .map(|label| h.get_by_role_and_label(egui::accesskit::Role::SpinButton, label).rect().right()) + .fold(f32::MIN, f32::max); assert!((t_right - lead).abs() < 0.5, "{what}: Transform fields end at {t_right}, Character at {lead}"); // Quick Actions buttons span the panel too. let qa = h.get_by_label("Rasterize Type").rect(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 28d4d7c56b..4bfa31227c 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -1733,8 +1733,14 @@ fn num_field(ui: &mut egui::Ui, label: &str, tip: &str, v: f32, range: std::ops: ui.spacing_mut().item_spacing.x = 4.0; let (r, resp) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::hover()); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, crate::theme::semibold(10.0), t.text_dim); - crate::widgets::scrub_label(ui, &resp.on_hover_text(tip)); - if crate::widgets::value_field(ui, &mut v, range, unit, width).changed() { + crate::widgets::scrub_label(ui, &resp.on_hover_text(tl!(tip))); + let response = crate::widgets::value_field(ui, &mut v, range, unit, width); + response.widget_info(|| { + let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), f64::from(v)); + info.label = Some(tl!(tip).to_owned()); + info + }); + if response.changed() { out = Some(v); } }); diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index a6b045bc1b..25011745e2 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -14,6 +14,33 @@ use super::{layout, text_layer}; use crate::PhotocraftApp; use crate::canvas::ViewXform; +#[test] +fn numeric_type_fields_have_localized_accessible_names() { + let pt = crate::i18n::Lang::from_code("pt-br").unwrap(); + let mut h = Harness::builder().build_ui_state( + move |ui, value: &mut f32| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::i18n::with_language(pt, || { + if let Some(next) = super::num_field(ui, "tT", "Font size", *value, 1.0..=999.0, "pt", 80.0) { + *value = next; + } + }); + }, + 24.0, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tamanho da fonte").click(); + h.run(); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("36".into())); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(*h.state(), 36.0); +} + fn harness(ppp: f32, app: PhotocraftApp) -> Harness<'static, PhotocraftApp> { make_harness(ppp, app, false) } @@ -1333,8 +1360,10 @@ mod multi_layer_formatting { h } - fn number(h: &mut Harness<'static, PhotocraftApp>, index: usize, value: &str) { - h.get_all_by_role(Role::SpinButton).nth(index).expect("numeric field").click(); + fn number(h: &mut Harness<'static, PhotocraftApp>, label: &str, value: &str) { + // Variable-font axes add numeric fields before size/leading. Select the setting by + // its accessible name, so a font scan cannot shift this action onto another control. + h.get_by_role_and_label(Role::SpinButton, label).click(); h.run(); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text(value.into())); @@ -1409,16 +1438,16 @@ mod multi_layer_formatting { let raster = LayerId(app.run("layer.new.layer", json!({"name": "Selected raster"})).unwrap()["layer"].as_u64().unwrap()); selected(&mut app, &[ids[0], ids[1], raster]); let mut h = controls(app, true); - number(&mut h, 0, "60"); + number(&mut h, "Font size", "60"); // Differ from the automatic 72 pt leading so this is an actual property change. - number(&mut h, 1, "84"); + number(&mut h, "Leading (set to the font size × auto-leading when Auto)", "84"); h.get_by_label("Faux Bold").click(); h.run(); h.get_by_label("Center text").click(); h.run(); h.get_by_label("Hyphenate").click(); h.run(); - number(&mut h, 6, "9"); + number(&mut h, "Indent left margin", "9"); for (id, scale) in [(ids[0], 2.0), (ids[1], 4.0)] { let c = style(h.state(), id, 0); assert_eq!(c.size_pt * scale, 60.0); @@ -1435,7 +1464,7 @@ mod multi_layer_formatting { let peer = text(h.state(), ids[1]).char_runs(); editing(h.state_mut(), ids[0]); h.run(); - number(&mut h, 0, "40"); + number(&mut h, "Font size", "40"); assert_eq!((size_at(h.state(), ids[0], 0), size_at(h.state(), ids[0], 1), size_at(h.state(), ids[0], 3)), (30.0, 20.0, 30.0)); assert_eq!(text(h.state(), ids[1]).char_runs(), peer); } From 68a31b5a123b66584616b62a2f236c0a53acf1e3 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 18:55:38 -0600 Subject: [PATCH 248/523] Free Transform: unlinking W and H frees the handles too (#4174) (#4322) The options bar's Maintain Aspect Ratio link only affected typed W/H values; dragging a corner or edge handle still scaled proportionally, so unlinking seemed to do nothing. As in Photoshop CC 2019 and later, the link now also sets the drag default: unlinked, corner and edge drags stretch freely and Shift keeps the proportions. Linked (the default) and the legacy preference are unchanged. The link state moves from egui temp data into UiState (transform_unlinked), next to the transform session. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/src/transform_tool.rs | 56 +++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 50f4b8b680..ff76da78c8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1055,6 +1055,10 @@ pub struct UiState { /// Free Transform session, if any. #[serde(default)] pub transform: Option, + /// The options bar's Maintain Aspect Ratio link is off: W/H change on their own, and handle + /// drags stretch freely (⇧ keeps the proportions), as in Photoshop (#4174). + #[serde(default)] + pub transform_unlinked: bool, /// Temporary symmetry-axis editing, exposed to automation but never restored from settings. #[serde(default, skip_deserializing)] pub symmetry_transform: Option, @@ -1255,6 +1259,7 @@ impl Default for UiState { type_transform_pivot: None, cam_transform: None, transform: None, + transform_unlinked: false, symmetry_transform: None, layer_picker: None, mask_target: false, diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 84d1031bb6..ecb19fff7b 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -1287,6 +1287,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> return true; } let legacy = app.session.prefs().general.use_legacy_free_transform; + let unlinked = app.ui.transform_unlinked; let type_layer = moves_type(app, &t); if let (Some(g), Some(s)) = (g, app.ui.transform.as_mut()) && mode_allows(t.mode, g.hit) @@ -1294,12 +1295,12 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> { let (mode, mods) = if no_distort { scale_not_distort(t.mode, g.hit, mods) } else { (t.mode, mods) }; if t.content_aware.is_some() { - let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, mods)); + let mut mods = mode_mods(mode, g.hit, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); // Content-Aware Scale: ⌘ neither skews nor distorts. mods.command = false; apply_drag(s, g, [x, y], mods); } else { - apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, mods)); + apply_mode_drag(s, g, [x, y], mode, type_layer, legacy_mods(legacy, g.hit, unlinked_mods(unlinked, legacy, g.hit, mods))); } } } @@ -1342,6 +1343,16 @@ pub(crate) fn legacy_mods(legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> mods } +/// The options bar's Maintain Aspect Ratio link turned off: corner and edge drags stretch freely +/// and ⇧ keeps the proportions, as in Photoshop CC 2019 and later (#4174). The legacy preference +/// already works that way, so it is left alone there. +pub(crate) fn unlinked_mods(unlinked: bool, legacy: bool, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { + if unlinked && !legacy && !mods.command && matches!(hit, Hit::Corner(_) | Hit::Edge(_)) { + mods.shift = !mods.shift; + } + mods +} + /// Distort / Perspective modes: a corner drag acts as if that gesture's keys were held (⌘-drag a /// corner distorts, ⌘⌥⇧-drag a corner adds perspective). Skew has its own ([`apply_mode_drag`]). fn mode_mods(mode: TransformMode, hit: Hit, mut mods: egui::Modifiers) -> egui::Modifiers { @@ -2449,11 +2460,9 @@ pub(crate) fn position_size_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t let size_w = if u.unit == photocraft_engine::prefs::Unit::Percent { 66.0 } else { 84.0 }; lbl(ui, "W:"); let rw = crate::widgets::value_field_prec(ui, &mut w, -wlim_u..=wlim_u, suffix, size_w, places); - let link_id = egui::Id::new("transform-link"); - let mut link: bool = ui.data(|d| d.get_temp(link_id)).unwrap_or(true); + let link = !app.ui.transform_unlinked; if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() { - link = !link; - ui.data_mut(|d| d.insert_temp(link_id, link)); + app.ui.transform_unlinked = link; } lbl(ui, "H:"); let rh = crate::widgets::value_field_prec(ui, &mut h, -hlim_u..=hlim_u, suffix, size_w, places); @@ -3843,6 +3852,41 @@ mod tests { } } + /// #4174: turning off the options bar's Maintain Aspect Ratio link frees the handles too, as in + /// Photoshop: a plain corner or edge drag stretches one way, ⇧ keeps the proportions. Linked + /// (the default) is unchanged, and so is the legacy preference. + #[test] + fn the_aspect_link_decides_whether_handle_drags_keep_proportions() { + let (none, shift) = (egui::Modifiers::NONE, egui::Modifiers::SHIFT); + for (unlinked, legacy, mods, proportional) in + [(false, false, none, true), (true, false, none, false), (true, false, shift, true), (true, true, none, false)] + { + for at_edge in [false, true] { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.ui.extras.snap = false; + app.ui.transform_unlinked = unlinked; + app.session.prefs.edit(|p| p.general.use_legacy_free_transform = legacy); + begin(&mut app, &egui::Context::default()).unwrap(); + let q0 = app.ui.transform.as_ref().unwrap().quad; + let from = if at_edge { [q0[1][0], (q0[1][1] + q0[2][1]) / 2.0] } else { q0[2] }; + press_drag(&mut app, from, [from[0] + 16.0, from[1]], mods); + let q = app.ui.transform.as_ref().unwrap().quad; + let (w, h) = (q[2][0] - q[0][0], q[2][1] - q[0][1]); + let case = format!("unlinked {unlinked}, legacy {legacy}, shift {}, edge {at_edge}: {w} × {h}", mods.shift); + if legacy && at_edge { + // Legacy edges always stretch their one axis. + assert!((w - 32.0).abs() < 1e-6 && (h - 16.0).abs() < 1e-6, "{case}"); + } else { + assert_eq!((w - h).abs() < 1e-6, proportional, "{case}"); + assert!((w - 32.0).abs() < 1e-6, "{case}"); + } + } + } + // ⌘ gestures (skew, distort) are left alone. + assert!(!unlinked_mods(true, false, Hit::Corner(0), egui::Modifiers::COMMAND).shift); + assert!(!unlinked_mods(true, false, Hit::Inside, none).shift); + } + #[test] fn right_click_while_transforming_switches_the_mode() { let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); From ec61df6f06c14d9b8feb15a1d30faf51683e8e72 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:55:47 +0300 Subject: [PATCH 249/523] fix(ui): make clickable tab, checkbox, and radio labels non-selectable (#3972) (#4331) * fix(ui): make clickable tab and radio labels non-selectable with pointing hand cursor (#3972) * test(ui): verify New Document preset tabs show pointing hand cursor instead of text caret (#3972) --- crates/ui-egui/src/color_range_ui.rs | 4 +++- crates/ui-egui/src/gallery_ui.rs | 3 ++- crates/ui-egui/src/new_doc_ui.rs | 19 ++++++++++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/color_range_ui.rs b/crates/ui-egui/src/color_range_ui.rs index 18084a6cc1..37c90c18e4 100644 --- a/crates/ui-egui/src/color_range_ui.rs +++ b/crates/ui-egui/src/color_range_ui.rs @@ -390,7 +390,9 @@ fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> egui::Response { if on { ui.painter().circle_filled(r.center(), 3.0, t.accent); } - let l = ui.add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).sense(Sense::click())); + let l = ui + .add(egui::Label::new(egui::RichText::new(tl!(&label)).color(t.text_dim)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand); resp.union(l) }) .inner diff --git a/crates/ui-egui/src/gallery_ui.rs b/crates/ui-egui/src/gallery_ui.rs index ed1597deb1..ccf1a7637e 100644 --- a/crates/ui-egui/src/gallery_ui.rs +++ b/crates/ui-egui/src/gallery_ui.rs @@ -609,7 +609,8 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let r = ui.horizontal(|ui| { let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); crate::icons::paint(ui, ir, icon, 14.0, t.icon); - ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).sense(Sense::click())) + ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).selectable(false).sense(Sense::click())) + .on_hover_cursor(egui::CursorIcon::PointingHand) }); if r.inner.clicked() { d.open[ci] = !d.open[ci]; diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index fe37bd0d21..9fe06a1a50 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -450,7 +450,9 @@ pub fn body(app: &mut crate::PhotocraftApp, ui: &mut egui::Ui, f: &mut Map Date: Tue, 13 Oct 2026 03:56:09 +0300 Subject: [PATCH 250/523] fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) (#4335) * fix(ui): use a linear gradient ramp glyph for the Gradient tool icon (#4172) * test(ui): verify Gradient tool icon rasterizes a left-to-right ramp (#4172) --- ATTRIBUTION.md | 3 ++- assets/icons/gradient.svg | 19 +++++++++++++++++++ crates/ui-egui/src/icon_data.rs | 2 ++ crates/ui-egui/src/icons.rs | 24 +++++++++++++++++++++++- crates/ui-egui/src/panels.rs | 2 +- 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 assets/icons/gradient.svg diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8c838b64d9..4b88e0901b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,7 +25,7 @@ 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) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | 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 | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -37,6 +37,7 @@ next to them. | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/assets/icons/gradient.svg b/assets/icons/gradient.svg new file mode 100644 index 0000000000..8567fc675e --- /dev/null +++ b/assets/icons/gradient.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index dc0bc0b500..4839fdfc76 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -54,6 +54,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")), ("frame-placeholder", include_bytes!("../../../assets/icons/frame-placeholder.svg")), ("gauge", include_bytes!("../../../assets/icons/gauge.svg")), + // PhotoCraft original: the Gradient tool glyph (a rectangle filled with a linear ramp). + ("gradient", include_bytes!("../../../assets/icons/gradient.svg")), ("grid-2x2", include_bytes!("../../../assets/icons/grid-2x2.svg")), ("grid-3x3", include_bytes!("../../../assets/icons/grid-3x3.svg")), ("hand", include_bytes!("../../../assets/icons/hand.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0b3b58dd04..390b378716 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,7 +111,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Slice => "slice-knife", Tool::SliceSelect => "square-dashed-mouse-pointer", Tool::Frame => "frame-placeholder", - Tool::Gradient => "blend", + Tool::Gradient => "gradient", Tool::PaintBucket => "paint-bucket", Tool::Type | Tool::VerticalType => "type", Tool::HorizontalTypeMask => "type-mask", @@ -218,6 +218,28 @@ mod tests { for t in Tool::ALL { assert!(exists(tool_icon(t)), "{t:?}"); } + assert_eq!(tool_icon(Tool::Gradient), "gradient"); + assert_ne!(tool_icon(Tool::Gradient), tool_icon(Tool::PaintBucket)); + } + + #[test] + fn gradient_tool_icon_rasterizes_left_to_right_ramp() { + let egui::ImageSource::Bytes { bytes, .. } = white_icons().source(tool_icon(Tool::Gradient)) else { + panic!("icon source must be embedded bytes"); + }; + let image = egui_extras::image::load_svg_bytes_with_size( + bytes.as_ref(), + egui::load::SizeHint::Size { width: 24, height: 24, maintain_aspect_ratio: true }, + &Default::default(), + ) + .unwrap(); + let left_alpha = image[(6, 12)].a(); + let mid_alpha = image[(12, 12)].a(); + let right_alpha = image[(18, 12)].a(); + assert!( + left_alpha > mid_alpha && mid_alpha > right_alpha, + "interior ramp should fade from left ({left_alpha}) through middle ({mid_alpha}) to right ({right_alpha})" + ); } #[test] diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e8f5803b21..30a3bb400d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1187,7 +1187,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.x = 2.0; let before = app.ui.tool_options.clone(); for (style, icon, tip) in [ - ("linear", "blend", tl!("Linear Gradient")), + ("linear", "gradient", tl!("Linear Gradient")), ("radial", "circle", tl!("Radial Gradient")), ("angle", "rotate-cw", tl!("Angle Gradient")), ("reflected", "arrow-left-right", tl!("Reflected Gradient")), From 0d6a83e3ce95e382277d2b4da0ff1437d7b68b23 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:56:18 +0300 Subject: [PATCH 251/523] fix(ui): add Brushes button at top of Brush Settings control list (#4265) (#4354) * fix(ui): add Brushes button at top of Brush Settings control list (#4265) * test(ui): verify Brushes button at top of Brush Settings opens Brushes panel (#4265) --- crates/ui-egui/src/brush_panel.rs | 15 +++++++++++++++ crates/ui-egui/src/brush_panel_tests.rs | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 8f51ca8572..dbf73f46ca 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -248,6 +248,21 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting ui.vertical(|ui| { ui.set_width(150.0); ui.spacing_mut().item_spacing.y = 1.0; + let (br_rect, br_resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click()); + br_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Brushes"))); + if br_resp.hovered() { + ui.painter().rect_filled(br_rect, t.radius_sm, t.hover); + } + let icon_rect = egui::Rect::from_center_size(egui::pos2(br_rect.left() + 12.0, br_rect.center().y), vec2(13.0, 13.0)); + icons::paint(ui, icon_rect, "brush", 12.0, if br_resp.hovered() { t.text } else { t.icon }); + let galley = ui.painter().layout_no_wrap(tl!("Brushes").to_string(), theme::semibold(12.0), if br_resp.hovered() { t.text } else { t.text_dim }); + ui.painter().galley(egui::pos2(br_rect.left() + 26.0, br_rect.center().y - galley.size().y / 2.0), galley, t.text); + if br_resp.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.panel.brushes", json!({})); + } + ui.add_space(3.0); + widgets::hairline(ui); + ui.add_space(3.0); for (i, (name, has_box)) in SECTIONS.iter().enumerate() { let toggle_only = checkbox_only(i); // Group the switches with whitespace, not separators between individual rows. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 49ddc0063c..731b13e1a1 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1120,3 +1120,15 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(h.state().ui.views[0].fill_pending); assert!(!h.state().ui.views[0].fit_pending); } + +#[test] +fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { + use egui_kittest::kittest::Queryable; + + let mut h = harness(0, 0); + assert_eq!(h.state().ui.brush_tab, 0); + h.get_by_label("Brushes").click(); + h.run_steps(3); + assert!(h.state().ui.panels.brush_settings); + assert_eq!(h.state().ui.brush_tab, 1); +} From 1874214f79e5b57b6caeca5e024daabea9cab742 Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:00:58 +0300 Subject: [PATCH 252/523] fix(ui): keep brush settings section lock icons permanently visible (#4278) (#4300) * fix(ui): keep brush settings section lock icons permanently visible (#4278) * test(ui): verify brush settings locks stay visible and toggle on click (#4278) * Format the lock-icon test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 8 ++-- crates/ui-egui/src/brush_panel_tests.rs | 55 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index dbf73f46ca..4cadb6b212 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -327,11 +327,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); - // Like Photoshop: the lock shows only when set or hovered. - if *lock || resp.hovered() || lresp.hovered() { - let tint = if *lock { t.text } else { t.text_faint }; - icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); - } + // Like Photoshop: lock icons stay visible on every lockable section row. + let tint = if *lock { t.text } else { t.text_faint }; + icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 731b13e1a1..6a2338295c 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -220,6 +220,61 @@ fn section_locks_round_trip_and_survive_preset_picks() { assert_eq!(b.tip, chalk.tip); } +#[test] +fn section_locks_are_always_visible_and_toggle_on_click() { + use egui_kittest::kittest::Queryable; + + fn collect_icon_rects(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(r) if (r.rect.size() - vec2(12.0, 12.0)).length() < 0.5 => out.push(r.rect), + egui::Shape::Mesh(mesh) => out.push(mesh.calc_bounds()), + egui::Shape::Vec(shapes) => { + for s in shapes { + collect_icon_rects(s, out); + } + } + _ => {} + } + } + + let mut h = harness(0, 0); + let mut icons = Vec::new(); + for clipped in &h.output().shapes { + collect_icon_rects(&clipped.shape, &mut icons); + } + + // Every lockable section (1..SECTIONS.len()) paints its lock icon even when unlocked and unhovered. + for (name, _) in SECTIONS.iter().skip(1) { + let row = h.get_by_label(name).rect(); + let lock_center = egui::pos2(row.right() - 11.0, row.center().y); + assert!(icons.iter().any(|r| r.contains(lock_center)), "expected lock icon at {lock_center:?} for {name}"); + } + + // Clicking the lock toggles it on and off without changing the selected section. + let row = h.get_by_label("Scattering").rect(); + let lock_pos = egui::pos2(row.right() - 11.0, row.center().y); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); + + h.hover_at(lock_pos); + h.run_steps(1); + h.drag_at(lock_pos); + h.run_steps(1); + h.drop_at(lock_pos); + h.run_steps(2); + assert!(!h.state().session.tools.brush.locks.scattering); + assert_eq!(h.state().ui.brush_section, 0); +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); From 908c47021f1b49cea3c40e90047e824489255bf8 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:05:59 -0600 Subject: [PATCH 253/523] Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) (#4308) * Layers: Fill and the Paint Bucket edit an adjustment layer's mask when its icon is targeted (#4298) An adjustment layer has no pixels, but once its icon thumbnail was clicked (or it was reached with Alt+[ / Alt+]) the Layers panel no longer targeted its mask, so Edit > Fill (Shift+F5) and the fill keys were greyed out, and the Paint Bucket and brushes failed with "not a pixel layer". As in Photoshop, painting and filling an adjustment layer now always edit its mask; the panel's target (brackets, trash, Delete) is unchanged. Co-Authored-By: Claude Opus 5.5 * Keep the web read_dropped doc comment on its function Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/canvas.rs | 41 ++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 15 +++++--- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 5 +-- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d028918266..362eef2a30 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -5450,7 +5450,8 @@ pub fn commit_crop(app: &mut PhotocraftApp) { } } -/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, else "pixels". +/// "mask" when the Layers panel targets (or the canvas shows) the active layer's mask, or the layer +/// is an adjustment layer with one (it has no pixels: #4298), else "pixels". pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { use photocraft_engine::channel_cmds::ChannelTarget; let Some(st) = app.session.active() else { return json!("pixels") }; @@ -5464,7 +5465,8 @@ pub fn paint_target(app: &PhotocraftApp) -> serde_json::Value { let has_mask = st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| l.mask.is_some()); // Viewing the mask (⌥-click its thumbnail, #196) paints the mask. let viewing = photocraft_engine::mask_view_cmds::current(st).is_some(); - json!(if (app.ui.mask_target || viewing) && has_mask { "mask" } else { "pixels" }) + let mask = app.ui.mask_target || viewing || crate::adjustment_layer_active(st); + json!(if mask && has_mask { "mask" } else { "pixels" }) } #[cfg(test)] @@ -6306,6 +6308,41 @@ mod tests { assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01); } + /// #4298: an adjustment layer has no pixels, so after its icon thumbnail is clicked (the + /// Layers panel no longer targets the mask) Fill, the fill keys and the Paint Bucket still + /// edit its mask, as in Photoshop, instead of failing with "not a pixel layer". + #[test] + fn fills_on_an_adjustment_layer_edit_its_mask_when_the_icon_is_targeted() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 40, "height": 20, "background": "white"})).unwrap(); + app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); + // Click the Curves icon thumbnail: the panel targets the layer, not its mask. + app.ui.mask_target = false; + app.sync_mask_targets(); + assert!(!app.ui.mask_target, "the panel's target is the icon"); + let mask_at = |app: &PhotocraftApp| { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(10, 10, 0) + }; + assert_eq!(paint_target(&app), json!("mask")); + assert!(crate::menus::is_enabled(&app, "edit.fill"), "Edit > Fill (⇧F5) is enabled"); + assert!(crate::menus::is_enabled(&app, "edit.fillForeground")); + app.ui.tool = Tool::PaintBucket; + app.run("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0], "background": [1.0, 1.0, 1.0, 1.0]})).unwrap(); + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 10.0, y: 10.0 }, egui::Modifiers::NONE); + assert_eq!(mask_at(&app), 0.0, "the Paint Bucket filled the mask black"); + app.run("edit.fill", json!({"contents": "white"})).unwrap(); + assert_eq!(mask_at(&app), 1.0, "Edit > Fill filled the mask white"); + assert!(!app.ui.mask_target, "filling doesn't move the panel's target"); + // A pixel layer with an untargeted mask still paints its pixels. + app.run("layer.new.layer", json!({})).unwrap(); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = false; + assert_eq!(paint_target(&app), json!("pixels")); + assert!(!app.layer_mask_targeted()); + } + #[test] fn alt_with_a_painting_tool_samples_the_foreground_instead_of_painting() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a71d8134a8..bc219f5ff8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1810,6 +1810,13 @@ fn read_dropped(f: &(dyn egui::DroppedFile + Send + Sync)) -> Result, St f.bytes() } +/// The active layer is an adjustment layer. It has no pixels, so painting and filling it edit its +/// mask even when its icon thumbnail, not the mask, is the Layers panel's target, as in +/// Photoshop (#4298). +pub(crate) fn adjustment_layer_active(st: &photocraft_engine::DocState) -> bool { + st.active_layer.and_then(|id| st.doc.layer(id)).is_some_and(|l| matches!(l.content, photocraft_doc::LayerContent::Adjustment(_))) +} + /// On the web, dropped-file bytes arrive asynchronously; the web shell handles drops itself. #[cfg(target_arch = "wasm32")] fn read_dropped(_f: &dyn egui::DroppedFile) -> Result, String> { @@ -1834,9 +1841,9 @@ impl PhotocraftApp { } } - /// The active layer's mask is what pixel commands edit: the Layers panel targets it, or every - /// colour channel is off and that mask is the picture. An alpha channel or Quick Mask wins, - /// as the engine routes those itself. + /// The active layer's mask is what pixel commands edit: the Layers panel targets it, the layer + /// is an adjustment layer (no pixels of its own), or every colour channel is off and that mask + /// is the picture. An alpha channel or Quick Mask wins, as the engine routes those itself. pub fn layer_mask_targeted(&self) -> bool { let Some(st) = self.session.active() else { return false }; let layer_mask = st.channel_view.target == photocraft_engine::channel_cmds::ChannelTarget::LayerMask; @@ -1845,7 +1852,7 @@ impl PhotocraftApp { if !composite || !has_mask { return false; } - if self.ui.mask_target || layer_mask { + if self.ui.mask_target || layer_mask || adjustment_layer_active(st) { return true; } let colors_off = st.channel_view.visible_colors(photocraft_engine::channel_cmds::color_count(&st.doc)) == 0; diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index ad0e70ca84..e18cb0e9a0 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -629,12 +629,13 @@ fn command_i_inverts_the_targeted_mask() { let mask = &l.mask.as_ref().unwrap().surface; assert_eq!((mask.sample_channel(50, 50, 0), mask.sample_channel(150, 10, 0)), (1.0, 0.0), "the mask inverted"); assert!(l.content == pixels, "the layer's pixels are untouched"); - // An adjustment layer's mask: Invert is enabled only through the mask target. + // An adjustment layer's mask: with no pixels of its own, Invert edits the mask whether the + // panel targets the mask or the icon thumbnail (#4298). let app = h.state_mut(); app.run("layer.newAdjustmentLayer.curves", json!({})).unwrap(); app.run("layer.layerMask.revealAll", json!({})).unwrap(); app.ui.mask_target = false; - assert!(!crate::menus::is_enabled(app, "image.adjustments.invert")); + assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.ui.mask_target = true; assert!(crate::menus::is_enabled(app, "image.adjustments.invert")); app.run("image.adjustments.invert", json!({})).unwrap(); From 858fbcf1ad19fe731057bd800c01ef1a48655d9f Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:06:58 +0200 Subject: [PATCH 254/523] Object Selection: hover preview of the object it would select (Object Finder) (#4087) (#4095) * WIP: Object Selection hover preview (#4087) * Object Selection: Object Finder hover preview and click-to-select (#4087) Hovering with Object Selection highlights the object a click selects, computed off the UI thread after a short rest and cached per document pixels; a click commits it via select.object {point} honouring add/subtract/intersect; dragging keeps the rectangle mode. Adds an 'Object Finder' options-bar toggle (default on, persisted) with translations in every catalog. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/segment/grabcut.rs | 144 +++++++++++ crates/algo/src/segment/tests.rs | 21 ++ crates/engine/src/lib.rs | 5 + crates/engine/src/smartselect_cmds.rs | 173 +++++++++++++- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/object_finder.rs | 331 ++++++++++++++++++++++++++ crates/ui-egui/src/retouch_ui.rs | 19 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/tool_reset.rs | 3 +- docs/ui-parity.md | 2 +- 30 files changed, 739 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/object_finder.rs diff --git a/crates/algo/src/segment/grabcut.rs b/crates/algo/src/segment/grabcut.rs index 5e80791ff1..ae1a586c12 100644 --- a/crates/algo/src/segment/grabcut.rs +++ b/crates/algo/src/segment/grabcut.rs @@ -116,3 +116,147 @@ pub fn object_select(sampler: &dyn Sampler, canvas: Rect, rect: Rect, work_px: u let low = super::clean_mask(&low, lw, lh, 0.1, 0.002); super::finish_region(sampler, window, step, &low, lw, lh, Some((&fg, &bg))) } + +/// Working-resolution budget (pixels) for [`object_at`]: small enough to run while the pointer +/// hovers. +pub const OBJECT_AT_WORK_PX: usize = 60_000; + +/// Object Selection's Object Finder (hover / click): the object under document point `(x, y)`. +/// +/// A heuristic, not a semantic segmenter (that is #2788): on a working-resolution copy of the +/// canvas the area around the point is grown over pixels of similar colour (CIELAB, bounded both +/// by the distance to the seed's running mean and by the local edge step), so it stops at the +/// object's outline. A region that grows over most of the canvas is the background: nothing is +/// found. Otherwise GrabCut refines it inside its padded bounding box (the grown area probable +/// foreground, the cursor definite foreground, outside the box definite background), the part +/// connected to the cursor is kept and the boundary is re-cut at full resolution, as the +/// rectangle mode does. +pub fn object_at(sampler: &dyn Sampler, canvas: Rect, x: f32, y: f32, work_px: usize) -> Option { + if canvas.width() < 4 || canvas.height() < 4 || !x.is_finite() || !y.is_finite() { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + if !canvas.contains(px, py) { + return None; + } + let step = super::scale_for(canvas, work_px.max(1024)); + let img = sampler.rgb_scaled(canvas, step); + let (w, h) = (img.w, img.h); + if w < 2 || h < 2 { + return None; + } + // Lightly smoothed, so noise and texture don't stop the growth; edges survive. + let lab: Vec<[f32; 3]> = box3(&box3(&img.px, w, h), w, h).iter().map(|c| super::slic::rgb_to_lab(*c)).collect(); + let sx = (((px - canvas.x0) as usize) / step).min(w - 1); + let sy = (((py - canvas.y0) as usize) / step).min(h - 1); + let grown = grow_similar(&lab, w, h, sx, sy); + let count = grown.iter().filter(|g| **g).count(); + // Background (or a flat image): no single object under the cursor. + if count * 10 > w * h * 6 || count < 4 { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0usize, 0usize); + for (i, g) in grown.iter().enumerate() { + if *g { + let (gx, gy) = (i % w, i / w); + (x0, y0, x1, y1) = (x0.min(gx), y0.min(gy), x1.max(gx + 1), y1.max(gy + 1)); + } + } + let pad = ((x1 - x0).max(y1 - y0) / 6).max(2); + let (bx0, by0, bx1, by1) = (x0.saturating_sub(pad), y0.saturating_sub(pad), (x1 + pad).min(w), (y1 + pad).min(h)); + let mut trimap = vec![BG; w * h]; + let mut any_bg = false; + for yy in 0..h { + for xx in 0..w { + let i = yy * w + xx; + let inside = xx >= bx0 && xx < bx1 && yy >= by0 && yy < by1; + trimap[i] = if grown[i] { + PR_FG + } else if inside { + PR_BG + } else { + any_bg = true; + BG + }; + } + } + if !any_bg { + // The box is the whole canvas: the frame is background. + for yy in 0..h { + for xx in 0..w { + if (xx == 0 || yy == 0 || xx + 1 == w || yy + 1 == h) && !(xx == sx && yy == sy) { + trimap[yy * w + xx] = BG; + } + } + } + } + trimap[sy * w + sx] = FG; + let (fg, bg) = grabcut(&img, &mut trimap, 5)?; + let low: Vec = trimap.iter().map(|t| is_fg(*t)).collect(); + let mut seeds = vec![false; w * h]; + seeds[sy * w + sx] = true; + let low = super::keep_seeded(&low, &seeds, w, h); + let low = super::clean_mask(&low, w, h, 0.1, 0.01); + if !low.iter().any(|v| *v) { + return None; + } + super::finish_region(sampler, canvas, step, &low, w, h, Some((&fg, &bg))) +} + +/// Region growing (4-connected) from `(sx, sy)` over pixels whose CIELAB colour is within a +/// distance of the seed area's running mean and whose step from the neighbour it was reached +/// from is small, so it stops at edges. +fn grow_similar(lab: &[[f32; 3]], w: usize, h: usize, sx: usize, sy: usize) -> Vec { + const MEAN_DIST: f32 = 32.0; + const EDGE_STEP: f32 = 10.0; + let d = |a: [f32; 3], b: [f32; 3]| ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + let mut out = vec![false; w * h]; + let Some(&seed) = lab.get(sy * w + sx) else { return out }; + let (mut sum, mut n) = ([seed[0] as f64, seed[1] as f64, seed[2] as f64], 1.0f64); + let mut stack = vec![(sx, sy)]; + out[sy * w + sx] = true; + while let Some((x, y)) = stack.pop() { + let c = lab[y * w + x]; + let mean = [(sum[0] / n) as f32, (sum[1] / n) as f32, (sum[2] / n) as f32]; + let nbrs = [(x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1)]; + for (nx, ny) in nbrs { + if nx >= w || ny >= h { + continue; + } + let i = ny * w + nx; + if out[i] { + continue; + } + let q = lab[i]; + if d(q, c) <= EDGE_STEP && d(q, mean) <= MEAN_DIST { + out[i] = true; + sum = [sum[0] + q[0] as f64, sum[1] + q[1] as f64, sum[2] + q[2] as f64]; + n += 1.0; + stack.push((nx, ny)); + } + } + } + out +} + +/// 3×3 box blur (clamped at the edges). +fn box3(px: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> { + let mut out = vec![[0.0f32; 3]; px.len()]; + for y in 0..h { + for x in 0..w { + let (mut acc, mut n) = ([0.0f32; 3], 0.0f32); + for yy in y.saturating_sub(1)..(y + 2).min(h) { + for xx in x.saturating_sub(1)..(x + 2).min(w) { + if let Some(p) = px.get(yy * w + xx) { + acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; + n += 1.0; + } + } + } + if let Some(o) = out.get_mut(y * w + x) { + *o = acc.map(|v| v / n.max(1.0)); + } + } + } + out +} diff --git a/crates/algo/src/segment/tests.rs b/crates/algo/src/segment/tests.rs index 4f741eb227..869f3c6717 100644 --- a/crates/algo/src/segment/tests.rs +++ b/crates/algo/src/segment/tests.rs @@ -659,3 +659,24 @@ fn dump_quick_scenes() { save("thin_line", &line); save("disc_scene", &disc_scene(600, 500, 300.0, 250.0, 180.0, 4).0); } + +#[test] +fn object_at_finds_the_disc_under_the_cursor() { + let (img, truth) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + let reg = grabcut::object_at(&s, canvas, 165.0, 118.0, 20_000).unwrap(); + let v = iou(®, &truth, 320, 240); + assert!(v > 0.9, "IoU {v}"); +} + +#[test] +fn object_at_on_the_background_or_off_canvas_finds_nothing() { + let (img, _) = disc_scene(320, 240, 160.0, 120.0, 70.0, 7); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, 320, 240); + assert!(grabcut::object_at(&s, canvas, 10.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, -5.0, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, f32::NAN, 10.0, 20_000).is_none()); + assert!(grabcut::object_at(&s, canvas, 400.0, 10.0, 0).is_none()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 45cf3418fe..1cc8eb692b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -432,6 +432,9 @@ pub struct Session { pub link_scan: Option, /// Select Subject's last result, reused while the pixels are unchanged (#2865). subject_cache: smartselect_cmds::SubjectCache, + /// Object Selection's Object Finder results (#4087), shared by the hover preview and the + /// click that commits it; valid while the pixels are unchanged. + pub object_finder: smartselect_cmds::ObjectFinderCache, /// A finished live stroke offered to the `paint.stroke` / `paint.pencil` that commits it /// ([`Session::offer_live_stroke`]). pub(crate) live_commit: Option, @@ -528,6 +531,7 @@ impl Session { if let Some(id) = self.docs.get(index).map(|d| d.doc.id) { self.cancel_jobs_on(id); self.subject_cache.forget_doc(id); + self.object_finder.forget_doc(id); } let d = self.docs.remove(index); self.active = if self.docs.is_empty() { @@ -701,6 +705,7 @@ impl Session { if damage.is_none_or(|d| !d.is_empty()) { // Free the mask early; a stale entry would only miss (it is keyed by pixels). self.subject_cache = Default::default(); + self.object_finder = Default::default(); } Ok(r) } diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 6c15c6be56..b04aae7a84 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -130,8 +130,124 @@ fn rect_param(p: &Value) -> Option { Some(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32)) } +fn point_param(p: &Value) -> Option<(f32, f32)> { + let a = p.get("point")?.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then_some((x, y)) +} + +/// The layer [`with_doc_sampler`] samples for `layer` / `all_layers`: `None` = the composite. +pub fn sampled_target(doc: &Document, layer: Option, all_layers: bool) -> Option { + layer.filter(|id| !all_layers && doc.layer(*id).and_then(Layer::surface).is_some()) +} + +/// Object Finder (#4087): the object Object Selection would select at document point `(x, y)`, +/// sampling `layer` (or the composite). Pure, so the UI can run it on a worker thread while the +/// pointer hovers; `select.object` with `"point"` commits the same result. +pub fn find_object(doc: &Document, layer: Option, all_layers: bool, x: f32, y: f32) -> Option { + with_doc_sampler(doc, layer, all_layers, |smp, doc| grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX)) +} + +/// Most objects remembered by [`ObjectFinderCache`]. +pub const OBJECT_FINDER_ENTRIES: usize = 12; + +/// Object Finder results for one document's pixels (#4087): hovering over an object already +/// found, or clicking it, answers at once. Keyed like [`SubjectCache`] (valid while the pixels +/// are the same, the document held weakly) plus the sampled layer; a lookup hits any cached +/// object that covers the point, so one computation serves the whole object. +#[derive(Debug, Default)] +pub struct ObjectFinderCache { + doc: std::sync::Weak, + doc_id: Option, + target: Option, + regions: Vec>, +} + +impl ObjectFinderCache { + fn valid(&self, doc: &Document, target: Option) -> bool { + self.doc_id == Some(doc.id) && self.target == target && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) + } + + /// A cached object of `doc` (sampling `target`) covering `(x, y)`. + pub fn lookup(&self, doc: &Document, target: Option, x: f32, y: f32) -> Option> { + if !x.is_finite() || !y.is_finite() || !self.valid(doc, target) { + return None; + } + let (px, py) = (x.floor() as i32, y.floor() as i32); + self.regions.iter().find(|r| r.at(px, py) >= 0.5).cloned() + } + + /// Remembers `region`, found in `doc` sampling `target`. A different document or pixels + /// start over; the oldest object is dropped beyond [`OBJECT_FINDER_ENTRIES`]. + pub fn insert(&mut self, doc: &std::sync::Arc, target: Option, region: std::sync::Arc) { + if !self.valid(doc, target) { + self.regions.clear(); + } + self.doc = std::sync::Arc::downgrade(doc); + self.doc_id = Some(doc.id); + self.target = target; + if self.regions.len() >= OBJECT_FINDER_ENTRIES { + self.regions.remove(0); + } + self.regions.push(region); + } + + /// Number of remembered objects. + pub fn len(&self) -> usize { + self.regions.len() + } + + /// Whether nothing is remembered. + pub fn is_empty(&self) -> bool { + self.regions.is_empty() + } + + /// Forget the results if they were computed from the document `id` (it is closing). + pub(crate) fn forget_doc(&mut self, id: photocraft_doc::DocId) { + if self.doc_id == Some(id) { + *self = Self::default(); + } + } + + /// Follows `doc` past a selection-only edit (same pixels), so the next lookup still hits. + fn follow(&mut self, doc: &std::sync::Arc) { + if self.doc_id == Some(doc.id) && self.doc.upgrade().is_some_and(|old| crate::layer_multi_cmds::same_pixels(&old, doc)) { + self.doc = std::sync::Arc::downgrade(doc); + } + } +} + +/// `select.object` with `"point"`: the Object Finder's object under the point (cached). +fn object_at_point(s: &mut Session, p: &Value, (x, y): (f32, f32)) -> Result { + let all_layers = b(p, "sampleAllLayers", false); + let d = s.active().ok_or(EngineError::NoDocument)?; + let target = sampled_target(&d.doc, d.active_layer, all_layers); + let region = match s.object_finder.lookup(&d.doc, target, x, y) { + Some(r) => Some(r), + None => { + let doc = std::sync::Arc::clone(&d.doc); + let found = find_object(&doc, d.active_layer, all_layers, x, y).map(std::sync::Arc::new); + if let Some(r) = &found { + s.object_finder.insert(&doc, target, std::sync::Arc::clone(r)); + } + found + } + }; + let out = apply(s, "Object Selection", region.as_deref(), mode(p, "replace"))?; + if let Some(d) = s.active() { + let doc = std::sync::Arc::clone(&d.doc); + s.object_finder.follow(&doc); + } + Ok(out) +} + fn object_selection(s: &mut Session, p: &Value) -> Result { - let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h]"))?; + if p.get("rect").is_none() + && let Some(pt) = point_param(p) + { + return object_at_point(s, p, pt); + } + let rect = rect_param(p).ok_or_else(|| bad("select.object", "needs \"rect\": [x, y, w, h] or \"point\": [x, y]"))?; let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| grabcut::object_select(smp, doc.bounds(), rect, 160_000))?; apply(s, "Object Selection", region.as_ref(), mode(p, "replace")) } @@ -373,7 +489,7 @@ pub fn specs() -> Vec { "select.object", "Object Selection", [], - r##"{"rect":[x,y,w,h],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false}"##, + r##"{"rect":[x,y,w,h] | "point":[x,y],"mode":"replace|add|subtract|intersect"="replace","sampleAllLayers":bool=false} (point: the Object Finder's object under the point, as hovering previews it)"##, has_doc, object_selection ), @@ -544,6 +660,59 @@ mod tests { assert!(cov(&s, 10, 10) >= 0.99); } + #[test] + fn object_selection_point_selects_the_object_under_it() { + for depth in [8, 16, 32] { + let (mut s, inside) = disc(depth); + let r = s.execute("select.object", json!({"point": [100, 80]})).unwrap(); + assert_eq!(r["changed"], true, "depth {depth}"); + let v = sel_iou(&s, 200, 160, &inside); + assert!(v > 0.9, "depth {depth}: IoU {v}"); + } + // The background is no object: nothing changes. + let (mut s, _) = disc(8); + let r = s.execute("select.object", json!({"point": [3, 3]})).unwrap(); + assert_eq!(r["changed"], false); + assert!(s.active().unwrap().doc.selection.is_none()); + assert!(s.execute("select.object", json!({"point": ["a", 1]})).is_err()); + } + + #[test] + fn object_selection_point_honours_the_modes() { + let (mut s, _) = disc(8); + s.execute("select.all", json!({})).unwrap(); + s.execute("select.object", json!({"point": [100, 80], "mode": "subtract"})).unwrap(); + assert!(cov(&s, 100, 80) < 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "add"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) >= 0.5); + s.execute("select.object", json!({"point": [100, 80], "mode": "intersect"})).unwrap(); + assert!(cov(&s, 100, 80) >= 0.5 && cov(&s, 3, 3) < 0.5); + } + + #[test] + fn object_finder_cache_serves_the_whole_object_until_pixels_change() { + let (mut s, _) = disc(8); + let d = s.active().unwrap(); + let doc = std::sync::Arc::clone(&d.doc); + let target = sampled_target(&doc, d.active_layer, false); + assert!(s.object_finder.lookup(&doc, target, 100.0, 80.0).is_none()); + let r = std::sync::Arc::new(find_object(&doc, d.active_layer, false, 100.0, 80.0).unwrap()); + s.object_finder.insert(&doc, target, std::sync::Arc::clone(&r)); + // Any point on the object hits; the background and another target miss. + assert!(std::sync::Arc::ptr_eq(&s.object_finder.lookup(&doc, target, 120.0, 90.0).unwrap(), &r)); + assert!(s.object_finder.lookup(&doc, target, 3.0, 3.0).is_none()); + assert!(s.object_finder.lookup(&doc, Some(LayerId(9999)), 100.0, 80.0).is_none()); + // The click reuses it, and a selection-only edit keeps it. + s.execute("select.object", json!({"point": [110, 70]})).unwrap(); + assert_eq!(s.object_finder.len(), 1); + s.execute("select.deselect", json!({})).unwrap(); + let d = s.active().unwrap(); + assert!(s.object_finder.lookup(&std::sync::Arc::clone(&d.doc), target, 100.0, 80.0).is_some()); + // A pixel edit drops it. + s.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + assert!(s.object_finder.is_empty()); + } + #[test] fn object_selection_disc() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 362eef2a30..bfcb0d1a7f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3438,6 +3438,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let resizing = crate::brush_resize::draw(app, &painter, &xf); draw_transform_controls(app, &painter, &xf); crate::move_hover::draw(app, &painter, &xf, idx, response.hover_pos()); + crate::object_finder::draw(app, &painter, &xf, idx, response.hover_pos()); crate::layer_pick_ui::show(app, &ctx); let shown = app.ui.views.get(idx).cloned(); crate::canvas_tool_menu::show(app, &ctx); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 89fa3da9f6..8c2bac34c7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3658,6 +3658,8 @@ navigator Zoom Zvětšení Light Blue Světle modrá Light Red Světle červená Medium Blue Středně modrá + Object Finder Hledání objektů + No object found there — drag a rectangle around it Nebyl tam nalezen žádný objekt — přetáhněte kolem něj obdélník Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fde840b1ed..3e5c38703a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2690,6 +2690,8 @@ curves Smooth Glätten Light Red Hellrot Medium Blue Mittelblau Light Gray Hellgrau + Object Finder Objektsucher + No object found there — drag a rectangle around it Dort wurde kein Objekt gefunden — ziehen Sie ein Rechteck darum Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a3da64e9dc..01582d5d71 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3748,6 +3748,8 @@ curves Smooth Εξομάλυνση Light Red Ανοιχτό κόκκινο Medium Blue Μεσαίο μπλε Light Gray Ανοιχτό γκρι + Object Finder Εύρεση αντικειμένων + No object found there — drag a rectangle around it Δεν βρέθηκε αντικείμενο εκεί — σύρετε ένα ορθογώνιο γύρω του Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 96ecc18d39..d3a61f70bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2731,6 +2731,8 @@ curves Smooth Suavizar Light Red Rojo claro Medium Blue Azul medio Light Gray Gris claro + Object Finder Buscador de objetos + No object found there — drag a rectangle around it No se encontró ningún objeto ahí; arrastra un rectángulo a su alrededor Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3f9ec9fb37..b1eb9c5e00 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3051,6 +3051,8 @@ curves Smooth Lisser Light Red Rouge clair Medium Blue Bleu moyen Light Gray Gris clair + Object Finder Outil de recherche d’objets + No object found there — drag a rectangle around it Aucun objet trouvé ici — tracez un rectangle autour Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f37ef4d335..626b84cd16 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3993,4 +3993,6 @@ cameraRaw Yellow Sárga Action playback stopped A művelet lejátszása leállt Step {step} is waiting for its dialog A(z) {step}. lépés a párbeszédablakára vár @plural {n} action|{n} actions {n} művelet + Object Finder Objektumkereső + No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 18d2c776f8..e29822449a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3038,6 +3038,8 @@ curves Smooth Haluskan Light Red Merah Terang Medium Blue Biru Sedang Light Gray Abu-abu Muda + Object Finder Pencari Objek + No object found there — drag a rectangle around it Tidak ada objek ditemukan di sana — seret persegi panjang di sekelilingnya Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index de47f7bcaa..1c6ba0ec77 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4014,6 +4014,8 @@ navigator Zoom Zoom {index} of {total} {index} di {total} {shortcut} assigned. {shortcut} assegnata. {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} è già usata da {commands} e verrà rimossa quando fai clic su OK. + Object Finder Ricerca oggetti + No object found there — drag a rectangle around it Nessun oggetto trovato lì: trascina un rettangolo intorno Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f2d3bd6694..761501e85c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3660,6 +3660,8 @@ curves Smooth スムーズ Light Red ライトレッド Medium Blue ミディアムブルー Light Gray ライトグレー + Object Finder オブジェクトファインダー + No object found there — drag a rectangle around it そこにオブジェクトが見つかりません — 周囲に矩形をドラッグしてください Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 84f30fc19c..b280416893 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3220,6 +3220,8 @@ curves Smooth 매끄럽게 Light Red 연한 빨강 Medium Blue 중간 파랑 Light Gray 연한 회색 + Object Finder 개체 찾기 + No object found there — drag a rectangle around it 그곳에서 개체를 찾지 못했습니다 — 주위에 사각형을 드래그하세요 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fde9f99743..cf570d4021 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3810,6 +3810,8 @@ curves Smooth Vloeiend maken Light Red Lichtrood Medium Blue Middelblauw Light Gray Lichtgrijs + Object Finder Objectzoeker + No object found there — drag a rectangle around it Daar is geen object gevonden — sleep er een rechthoek omheen Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 29dfcef28d..cc0b77df70 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3647,6 +3647,8 @@ curves Smooth Wygładź Light Red Jasnoczerwony Medium Blue Średnioniebieski Light Gray Jasnoszary + Object Finder Wyszukiwanie obiektów + No object found there — drag a rectangle around it Nie znaleziono tam obiektu — przeciągnij wokół niego prostokąt Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a68ba566ff..bbe1fd8e7f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3840,6 +3840,8 @@ layerLabel No Color Sem cor Light Red Vermelho-claro Medium Blue Azul médio Light Gray Cinza-claro + Object Finder Localizador de objetos + No object found there — drag a rectangle around it Nenhum objeto encontrado ali — arraste um retângulo ao redor dele Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6caf04cb9..3e4e0b0097 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2995,6 +2995,8 @@ curves Smooth Сгладить Light Red Светло-красный Medium Blue Средне-синий Light Gray Светло-серый + Object Finder Поиск объектов + No object found there — drag a rectangle around it Там не найден объект — обведите его прямоугольником Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d944622ec2..419c54c8ab 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3805,4 +3805,6 @@ cameraData Lens Lens Action playback stopped Eylem oynatma durduruldu Step {step} is waiting for its dialog {step}. adım iletişim kutusunu bekliyor @plural {n} action|{n} actions {n} eylem + Object Finder Nesne Bulucu + No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index bd93075fa2..c5c724c92c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3748,6 +3748,8 @@ curves Smooth Згладити Light Red Світло-червоний Medium Blue Середньо-синій Light Gray Світло-сірий + Object Finder Пошук об’єктів + No object found there — drag a rectangle around it Там не знайдено об’єкта — обведіть його прямокутником Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ea93bc326c..6872d8cf6b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3534,6 +3534,8 @@ curves Smooth Làm mượt Stroke Path… Đi nét đường dẫn… Apply mask to layer before removing? Áp dụng mặt nạ vào lớp trước khi xóa? Delete the vector mask? Xóa mặt nạ vectơ? + Object Finder Trình tìm đối tượng + No object found there — drag a rectangle around it Không tìm thấy đối tượng ở đó — hãy kéo một hình chữ nhật quanh nó Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9e66946f4f..beb1b0a5cb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2655,6 +2655,8 @@ curves Smooth 平滑 Light Red 浅红色 Medium Blue 中蓝色 Light Gray 浅灰色 + Object Finder 对象查找程序 + No object found there — drag a rectangle around it 那里未找到对象 — 请在其周围拖动矩形 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b7685958e3..c3772fc1c5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2640,6 +2640,8 @@ curves Smooth 平滑 Light Red 淺紅色 Medium Blue 中藍色 Light Gray 淺灰色 + Object Finder 物件尋找器 + No object found there — drag a rectangle around it 那裡找不到物件 — 請在其周圍拖曳矩形 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bc219f5ff8..cef61293f8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,7 @@ pub mod new_guide_ui; pub mod new_layer_ui; pub mod notices; mod numeric_expression; +pub mod object_finder; mod opacity_keys; pub mod outline; pub mod paint_mouse; @@ -511,6 +512,8 @@ pub struct PhotocraftApp { pub(crate) move_preview: Option, /// Move tool › Show Highlight on Rollover: the last hover answer (`move_hover`). pub(crate) move_hover: Option, + /// Object Selection › Object Finder: the hover preview (`object_finder`). + pub(crate) object_finder: Option, /// A blend mode hovered in the Layers panel, shown live (`blend_preview`). pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). @@ -739,6 +742,7 @@ impl PhotocraftApp { menu_cache: None, move_preview: None, move_hover: None, + object_finder: None, blend_preview: None, patch_preview: None, magnetic: Default::default(), diff --git a/crates/ui-egui/src/object_finder.rs b/crates/ui-egui/src/object_finder.rs new file mode 100644 index 0000000000..e219b0ddff --- /dev/null +++ b/crates/ui-egui/src/object_finder.rs @@ -0,0 +1,331 @@ +//! Object Selection › Object Finder (#4087): with the Object Selection tool active, resting the +//! pointer on the canvas highlights the object a click there would select, as Photoshop's Object +//! Finder does. A click (without dragging) commits that object (`select.object` with `"point"`; +//! ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode. +//! +//! The lookup is `photocraft_engine::smartselect_cmds::find_object`, run after the pointer has +//! been still for [`DEBOUNCE`] seconds on a worker thread (inline on the web), so the UI never +//! waits for it. Results go into the session's `object_finder` cache, which the click reuses and +//! which serves every point of an object already found; it is keyed by the document's pixels, +//! so an edit drops it. The preview itself is keyed by document revision, sampled layer and +//! Sample All Layers, and clears when the pointer leaves the canvas, the tool changes, a button +//! goes down or the option is off. Quality of the object found is out of scope here (#2788). + +use std::sync::Arc; + +use egui::{Color32, TextureOptions, pos2}; +use photocraft_algo::selection::Region; +use photocraft_doc::{DocId, Document, LayerId}; +use photocraft_engine::smartselect_cmds::{find_object, sampled_target}; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; +use crate::state::Tool; + +/// Seconds the pointer must rest before an object is looked up. +pub const DEBOUNCE: f64 = 0.12; +/// Longest side of the overlay texture (the overlay is a preview, not the selection). +const TEX_MAX: usize = 768; + +/// A lookup running off the UI thread. +struct Job { + at: [i32; 2], + doc: Arc, + rx: std::sync::mpsc::Receiver>, +} + +/// The hover state of the active document (`PhotocraftApp::object_finder`). +pub(crate) struct Finder { + doc: DocId, + revision: u64, + target: Option, + all_layers: bool, + /// The pointer pixel and when it got there. + at: [i32; 2], + since: f64, + job: Option, + /// Points already looked up that found nothing (the background). + misses: Vec<[i32; 2]>, + shown: Option>, + texture: Option<(usize, egui::TextureHandle)>, +} + +/// What [`update`] decided. +#[derive(Clone, Debug, Default)] +pub(crate) struct Update { + /// The object to highlight. + pub shown: Option>, + /// A lookup is pending or running: repaint soon to pick it up. + pub busy: bool, +} + +/// Whether the preview applies now (`pressed`: a pointer button is down). +fn active(app: &PhotocraftApp, pressed: bool) -> bool { + app.ui.tool == Tool::ObjectSelection + && app.ui.tool_options.object_finder + && !pressed + && app.drag.is_none() + && app.ui.transform.is_none() + && app.session.active().is_some() +} + +/// Advances the hover preview with the pointer at `at` (document px; `None` = off the canvas) +/// at time `now` (seconds). +pub(crate) fn update(app: &mut PhotocraftApp, at: Option<[f64; 2]>, pressed: bool, now: f64) -> Update { + let at = at.filter(|p| p[0].is_finite() && p[1].is_finite()); + let (Some(at), true) = (at, active(app, pressed)) else { + app.object_finder = None; + return Update::default(); + }; + let Some(st) = app.session.active() else { return Update::default() }; + let all_layers = app.ui.tool_options.sample_all_layers; + let target = sampled_target(&st.doc, st.active_layer, all_layers); + let (doc, revision, layer) = (st.doc.id, st.revision, st.active_layer); + let px = [at[0].floor() as i32, at[1].floor() as i32]; + let fresh = app.object_finder.as_ref().is_some_and(|f| f.doc == doc && f.revision == revision && f.target == target && f.all_layers == all_layers); + if !fresh { + app.object_finder = Some(Finder { doc, revision, target, all_layers, at: px, since: now, job: None, misses: Vec::new(), shown: None, texture: None }); + } + let doc_arc = Arc::clone(&st.doc); + let Some(f) = app.object_finder.as_mut() else { return Update::default() }; + if f.at != px { + f.at = px; + f.since = now; + } + // A finished lookup: remember it (only for the pixels it was computed from). + if let Some(job) = &f.job { + match job.rx.try_recv() { + Ok(found) => { + if let Some(r) = found { + if photocraft_engine::layer_multi_cmds::same_pixels(&job.doc, &doc_arc) { + app.session.object_finder.insert(&doc_arc, target, Arc::new(r)); + } + } else { + f.misses.push(job.at); + if f.misses.len() > 64 { + f.misses.remove(0); + } + } + f.job = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => f.job = None, + } + } + let inside = |r: &Region| r.at(px[0], px[1]) >= 0.5; + if !f.shown.as_deref().is_some_and(inside) { + f.shown = app.session.object_finder.lookup(&doc_arc, target, at[0] as f32, at[1] as f32); + } + let missed = f.misses.iter().any(|m| (m[0] - px[0]).abs() <= 2 && (m[1] - px[1]).abs() <= 2); + if f.shown.is_none() && f.job.is_none() && !missed && now - f.since >= DEBOUNCE && doc_arc.bounds().contains(px[0], px[1]) { + f.job = Some(spawn(Arc::clone(&doc_arc), layer, all_layers, px, at)); + } + let busy = f.job.is_some() || (f.shown.is_none() && !missed && now - f.since < DEBOUNCE); + Update { shown: f.shown.clone(), busy } +} + +fn spawn(doc: Arc, layer: Option, all_layers: bool, px: [i32; 2], at: [f64; 2]) -> Job { + let (tx, rx) = std::sync::mpsc::channel(); + let work = { + let doc = Arc::clone(&doc); + move || { + let _ = tx.send(find_object(&doc, layer, all_layers, at[0] as f32, at[1] as f32)); + } + }; + #[cfg(not(target_arch = "wasm32"))] + { + let spawned = std::thread::Builder::new().name("object-finder".into()).spawn(work); + if spawned.is_err() { + // No thread: the receiver reports Disconnected and the next rest retries. + } + } + #[cfg(target_arch = "wasm32")] + work(); + Job { at: px, doc, rx } +} + +/// Clears the preview (a tool switch or an edit made elsewhere is caught by [`update`] too). +pub(crate) fn clear(app: &mut PhotocraftApp) { + app.object_finder = None; +} + +/// The overlay colour: the theme accent, translucent (Photoshop tints the object it finds). +fn overlay(accent: Color32, cov: f32, edge: bool) -> Color32 { + let a = if edge { 0.95 } else { 0.42 * cov.clamp(0.0, 1.0) }; + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (a * 255.0).round() as u8) +} + +/// The overlay image of `r`: translucent fill with an opaque one-texel outline, at most +/// [`TEX_MAX`] texels on its longest side. +pub(crate) fn overlay_image(r: &Region, accent: Color32) -> egui::ColorImage { + let (w, h) = (r.bbox.width().max(1) as usize, r.bbox.height().max(1) as usize); + let step = w.max(h).div_ceil(TEX_MAX).max(1); + let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); + let cov = |tx: usize, ty: usize| r.at(r.bbox.x0 + (tx * step) as i32, r.bbox.y0 + (ty * step) as i32); + let mut px = Vec::with_capacity(tw * th); + for ty in 0..th { + for tx in 0..tw { + let c = cov(tx, ty); + let on = c >= 0.5; + let edge = on + && (tx == 0 + || ty == 0 + || tx + 1 == tw + || ty + 1 == th + || cov(tx - 1, ty) < 0.5 + || cov(tx + 1, ty) < 0.5 + || cov(tx, ty - 1) < 0.5 + || cov(tx, ty + 1) < 0.5); + px.push(if c <= 0.0 { Color32::TRANSPARENT } else { overlay(accent, c, edge) }); + } + } + egui::ColorImage::new([tw, th], px) +} + +/// Updates the preview for this frame and draws the highlight on the active document's canvas. +pub(crate) fn draw(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, idx: usize, hover: Option) { + if app.session.active_index() != Some(idx) { + return; + } + let ctx = painter.ctx().clone(); + let (pressed, now) = ctx.input(|i| (i.pointer.any_down(), i.time)); + let up = update(app, hover.map(|p| xf.to_doc(p)), pressed, now); + if up.busy { + ctx.request_repaint_after(std::time::Duration::from_millis(40)); + } + let Some(r) = up.shown else { return }; + let Some(f) = app.object_finder.as_mut() else { return }; + let key = Arc::as_ptr(&r) as usize; + if f.texture.as_ref().is_none_or(|(k, _)| *k != key) { + let accent = crate::theme::Tokens::get(&ctx).accent; + let tex = ctx.load_texture("object-finder", overlay_image(&r, accent), TextureOptions::LINEAR); + f.texture = Some((key, tex)); + } + let Some((_, tex)) = &f.texture else { return }; + let b = r.bbox; + let mut mesh = egui::Mesh::with_texture(tex.id()); + let corners = [(b.x0, b.y0, 0.0, 0.0), (b.x1, b.y0, 1.0, 0.0), (b.x1, b.y1, 1.0, 1.0), (b.x0, b.y1, 0.0, 1.0)]; + for (x, y, u, v) in corners { + mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(x as f32, y as f32), uv: pos2(u, v), color: Color32::WHITE }); + } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + painter.add(egui::Shape::mesh(mesh)); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// A 200 × 160 document: a red disc (centre 100, 80, radius 45) on blue. + pub(crate) fn disc_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.session + .edit("paint", |doc, a| { + let s = doc.layer_mut(a.unwrap()).unwrap().surface_mut().unwrap(); + s.fill_rect(photocraft_geom::Rect::new(0, 0, 200, 160), &[0.2, 0.4, 0.8, 1.0]); + for y in 0..160 { + for x in 0..200 { + let (dx, dy) = (x as f32 + 0.5 - 100.0, y as f32 + 0.5 - 80.0); + if dx * dx + dy * dy <= 45.0 * 45.0 { + s.fill_rect(photocraft_geom::Rect::new(x, y, x + 1, y + 1), &[0.9, 0.2, 0.1, 1.0]); + } + } + } + Ok(()) + }) + .unwrap(); + app.ui.tool = Tool::ObjectSelection; + app + } + + /// Hovers at `at` until the lookup settles (time advances past the debounce). + pub(crate) fn hover(app: &mut PhotocraftApp, at: [f64; 2]) -> Update { + let mut t = 100.0; + loop { + let u = update(app, Some(at), false, t); + if !u.busy || t > 130.0 { + return u; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + t += 0.05; + } + } + + #[test] + fn hovering_an_object_highlights_it_after_a_rest() { + let mut app = disc_app(); + // Not before the pointer has rested. + let u = update(&mut app, Some([100.0, 80.0]), false, 50.0); + assert!(u.shown.is_none() && u.busy); + let u = hover(&mut app, [100.0, 80.0]); + let r = u.shown.expect("the disc is highlighted"); + assert!(r.at(100, 80) >= 0.5 && r.at(60, 80) >= 0.5 && r.at(5, 5) < 0.5); + // Elsewhere on the same object: served from the cache, no new lookup. + let u = update(&mut app, Some([120.0, 90.0]), false, 200.0); + assert!(u.shown.is_some_and(|s| Arc::ptr_eq(&s, &r)) && !u.busy); + assert_eq!(app.session.object_finder.len(), 1); + // The background highlights nothing. + assert!(hover(&mut app, [4.0, 4.0]).shown.is_none()); + } + + #[test] + fn preview_clears_on_leave_tool_switch_button_option_and_edits() { + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, None, false, 300.0).shown.is_none()); + assert!(app.object_finder.is_none()); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + assert!(update(&mut app, Some([100.0, 80.0]), true, 300.0).shown.is_none()); + app.ui.tool = Tool::Lasso; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; + assert!(update(&mut app, Some([100.0, 80.0]), false, 300.0).shown.is_none()); + app.ui.tool_options.object_finder = true; + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + // A pixel edit: the cached object is dropped and the preview starts over. + app.session.execute("paint.pencil", json!({"points": [[5, 5], [30, 5]], "size": 4, "color": "#000000"})).unwrap(); + let u = update(&mut app, Some([100.0, 80.0]), false, 400.0); + assert!(u.shown.is_none() && u.busy); + } + + #[test] + fn click_commits_the_highlighted_object_with_the_modifiers() { + use crate::canvas::{ToolEvent, tool_event}; + let mut app = disc_app(); + assert!(hover(&mut app, [100.0, 80.0]).shown.is_some()); + let click = |app: &mut PhotocraftApp, m: egui::Modifiers| { + tool_event(app, ToolEvent::Down { x: 100.0, y: 80.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 100.0, y: 80.0 }, m); + }; + let cov = |app: &PhotocraftApp, x: i32, y: i32| app.session.active().unwrap().doc.selection.as_ref().map_or(0.0, |s| s.sample_channel(x, y, 0)); + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.object")); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 60, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + app.run("select.all", json!({})).unwrap(); + click(&mut app, egui::Modifiers::ALT); + assert!(cov(&app, 100, 80) < 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers::SHIFT); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) >= 0.5); + click(&mut app, egui::Modifiers { alt: true, shift: true, ..Default::default() }); + assert!(cov(&app, 100, 80) >= 0.5 && cov(&app, 5, 5) < 0.5); + // Off: a click keeps the Quick Selection fallback. + app.ui.tool_options.object_finder = false; + click(&mut app, egui::Modifiers::NONE); + assert_eq!(app.session.journal.last().map(|(id, _)| id.as_str()), Some("select.quick")); + } + + #[test] + fn overlay_image_tints_the_object_and_outlines_it() { + let r = Region { bbox: photocraft_geom::Rect::new(0, 0, 4, 4), mask: vec![255; 16] }; + let img = overlay_image(&r, Color32::from_rgb(0, 120, 255)); + assert_eq!(img.size, [4, 4]); + assert!(img.pixels[0].a() > 200, "edge"); + assert!(img.pixels[5].a() > 0 && img.pixels[5].a() < 200, "fill"); + let big = Region { bbox: photocraft_geom::Rect::new(0, 0, 3000, 10), mask: vec![255; 30_000] }; + assert!(overlay_image(&big, Color32::WHITE).size[0] <= TEX_MAX); + } +} diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index a9e1241e07..48beefbaaa 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -342,13 +342,28 @@ pub fn content_aware_move_params(app: &PhotocraftApp, offset: [i64; 2]) -> Value } /// Object Selection: the dragged rectangle. A click (or a sub-2 px nudge) cannot be a -/// rectangle: like Photoshop's click-to-select, it falls back to a Quick Selection dab at +/// rectangle: with Object Finder on it selects the object under the pointer, the one hovering +/// highlighted (#4087; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects, otherwise replaces). With it off it +/// falls back to a Quick Selection dab at /// the brush size instead of looking dead (⌥ subtracts, otherwise it adds). Larger drags /// run the rectangle GrabCut (⌥ subtracts, ⇧ adds, otherwise replaces). pub fn finish_object_selection(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) { let (x, y) = (start[0].min(end[0]), start[1].min(end[1])); let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs()); let sample_all = app.ui.tool_options.sample_all_layers; + if (w < 2.0 || h < 2.0) && app.ui.tool_options.object_finder { + // Object Finder (#4087): the object under the click, as hovering highlighted it. + let mode = match (mods.shift, mods.alt) { + (true, true) => "intersect", + (true, false) => "add", + (false, true) => "subtract", + (false, false) => "replace", + }; + let r = app.run("select.object", json!({"point": [end[0], end[1]], "mode": mode, "sampleAllLayers": sample_all})); + crate::object_finder::clear(app); + report_smart_result(app, r, tl!("No object found there — drag a rectangle around it")); + return; + } if w < 2.0 || h < 2.0 { let size = app.session.tools.brush.size; let mode = if mods.alt { "subtract" } else { "add" }; @@ -600,6 +615,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } } Tool::ObjectSelection => { + crate::widgets::checkbox(ui, &mut o.object_finder, tl!("Object Finder")); crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); opt(ui, tl!("Drag a rectangle around the object")); crate::widgets::vline(ui, 22.0); @@ -776,6 +792,7 @@ mod tests { // A click cannot be a rectangle: it selects at the cursor instead of looking dead. let mut app = app(); app.ui.tool = Tool::ObjectSelection; + app.ui.tool_options.object_finder = false; let m = egui::Modifiers::NONE; tool_event(&mut app, ToolEvent::Down { x: 50.0, y: 30.0, pressure: 1.0 }, m); tool_event(&mut app, ToolEvent::Up { x: 50.0, y: 30.0 }, m); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ff76da78c8..620adf8d23 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -508,6 +508,8 @@ pub struct ToolOptions { pub contiguous: bool, pub anti_alias: bool, pub sample_all_layers: bool, + /// Object Selection › Object Finder: hovering highlights the object a click selects (#4087). + pub object_finder: bool, /// Remove tool: the fill goes to a new layer above the target (Photoshop's Create a new layer). pub remove_new_layer: bool, /// Remove tool: each stroke is filled when released (Photoshop's default). Off, the strokes @@ -750,6 +752,7 @@ impl Default for ToolOptions { contiguous: true, anti_alias: true, sample_all_layers: false, + object_finder: true, remove_new_layer: false, remove_after_each_stroke: true, feather: 0.0, diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 646af4f7a3..8743fc1059 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -124,7 +124,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { Tool::Lasso | Tool::PolygonLasso => reset!(feather, anti_alias), Tool::MagneticLasso => reset!(feather, anti_alias, magnetic_width, magnetic_contrast, magnetic_frequency, magnetic_pressure), Tool::MagicWand => reset!(tolerance, anti_alias, contiguous, sample_all_layers), - Tool::QuickSelection | Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge), + Tool::QuickSelection => reset!(sample_all_layers, enhance_edge), + Tool::ObjectSelection => reset!(sample_all_layers, enhance_edge, object_finder), Tool::Crop => reset!( crop_ratio, crop_delete, diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70b503d0c8..a48db144ae 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -29,7 +29,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Move, artboard | Move, Artboard | | | Marquee | Rectangular, Elliptical, Single Row, Single Column | | | Lasso | Lasso, Polygonal, Magnetic | | -| Selection | Object Selection, Quick Selection, Magic Wand | | +| Selection | Object Selection, Quick Selection, Magic Wand | Object Selection's Object Finder (on by default in its options bar): resting the pointer highlights the object a click selects (`select.object` with `point`; ⇧ adds, ⌥ subtracts, ⇧⌥ intersects); dragging keeps the rectangle mode (#4087). Heuristic, not a learned segmenter (#2788). | | Crop and slice | Crop, Perspective Crop, Slice, Slice Select | | | Frame | Frame (frames are groups clipped by a rectangular or elliptical mask) | | | Eyedropper | Eyedropper, Color Sampler, Ruler, Note, Count | | From 94ea94f8899171b08a94052abcc3ded200c2b01f Mon Sep 17 00:00:00 2001 From: "cam." <33173989+xtellarvoid@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:14:16 +0700 Subject: [PATCH 255/523] feat(ui): add GRD import option to gradient map preset picker (#4337) * feat(ui): add GRD import option to gradient preset picker * Translate the GRD import strings into Vietnamese, Turkish and Hungarian Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/adjust_dialog.rs | 3 + crates/ui-egui/src/adjust_editors.rs | 18 +++- crates/ui-egui/src/adjust_editors_tests.rs | 96 ++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/preset_files_ui.rs | 17 ++++ 23 files changed, 189 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index a9235881ea..5f43348507 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -209,6 +209,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map Self { - Edit { changed, commit: changed } + Edit { changed, commit: changed, ..Default::default() } } fn of(r: &egui::Response) -> Self { - Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()) } + Edit { changed: r.changed(), commit: r.drag_stopped() || (r.changed() && !r.dragged()), ..Default::default() } } } @@ -1388,6 +1391,14 @@ fn gradient_map(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit { .icon(widgets::chevron_icon) .close_behavior(close) .show_ui(ui, |ui| { + let gear = crate::icons::button(ui, "settings", 24.0, false, tl!("Gradient Preset Options")); + egui::containers::menu::SubMenu::new().show(ui, &gear, |ui| { + if ui.button(tl!("Import .GRD")).clicked() { + e.import_gradients = true; + ui.close(); + } + }); + ui.separator(); for g in &cx.gradients { ui.label(RichText::new(&g.name).color(t.text_faint)); for p in &g.items { @@ -1617,6 +1628,9 @@ pub fn layer_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj crate::layer_picker::controls(app, ui, id); } let e = editor(ui, kind, &mut values, &cx); + if e.import_gradients { + crate::preset_files_ui::import_gradients_dialog(app); + } if e.changed { app.live_adjust = Some((id, values.clone())); } diff --git a/crates/ui-egui/src/adjust_editors_tests.rs b/crates/ui-egui/src/adjust_editors_tests.rs index 805bbbdd90..9c39088bef 100644 --- a/crates/ui-egui/src/adjust_editors_tests.rs +++ b/crates/ui-egui/src/adjust_editors_tests.rs @@ -592,6 +592,7 @@ fn gradient_map_picker_offers_the_gradient_library() { let mut h = app_harness("gradientMap", "rgb", 8); // A short library so every row fits the popup: Basics, Blues and a user-saved group. let mut lib: Vec<_> = gradients::builtin().into_iter().take(2).collect(); + lib[1].items.truncate(3); lib.push(Group::new("Mine", vec![gradients::GradientPreset::new("My Teal", &["#008080", "#ffee00"])])); h.state_mut().session.presets.gradients = lib; h.run_steps(2); @@ -611,6 +612,101 @@ fn gradient_map_picker_offers_the_gradient_library() { } } +fn import_gradients_from_picker(h: &mut Harness<'_, PhotocraftApp>) { + let preset = label_rect(h, "Preset:").unwrap(); + click(h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("Gradient Preset Options").click(); + h.run_steps(2); + h.get_by_label("Import .GRD").click(); + h.run_steps(3); +} + +#[test] +fn gradient_map_picker_imports_grd_without_editing_the_map() { + use photocraft_psd::grd::{GrdColor, GrdGradient, GrdStop}; + let mut h = app_harness("gradientMap", "rgb", 16); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let history = h.state().session.active().unwrap().history.past_len(); + let tool = h.state().session.presets.gradient.clone(); + let bytes = photocraft_psd::grd::write(&[GrdGradient { + name: "Imported Teal".into(), + stops: vec![ + GrdStop { location: 0.0, midpoint: 0.5, color: GrdColor::Rgb([0.0, 0.5, 0.5]) }, + GrdStop { location: 1.0, midpoint: 0.5, color: GrdColor::Rgb([1.0, 1.0, 0.0]) }, + ], + ..Default::default() + }]); + h.state_mut().services.file_dialog = Some(Box::new(move |request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { multiple: false, extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(Some(crate::FileDialogAnswer::Contents("My Gradients.GRD".into(), bytes.clone()))); + })); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before, "import does not edit the adjustment"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().session.presets.gradient, tool); + assert!(!h.state().ui.status_error); + assert!(h.state().ui.status.contains("Imported 1 gradient")); + let preset = label_rect(&h, "Preset:").unwrap(); + click(&mut h, Pos2::new(preset.right() + 60.0, preset.center().y)); + h.get_by_label("My Gradients"); + h.get_by_label("Imported Teal").click(); + h.run_steps(3); + assert_eq!(map_stops(&layer(&h).1), vec![(0.0, [0, 128, 128]), (1.0, [255, 255, 0])]); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&h), before); +} + +#[test] +fn gradient_map_picker_import_cancel_and_errors_preserve_state() { + use crate::FileDialogAnswer; + for answer in [ + None, + Some(FileDialogAnswer::Contents("broken.grd".into(), b"invalid".to_vec())), + Some(FileDialogAnswer::Contents("old.grd".into(), b"8BGR\0\x03".to_vec())), + Some(FileDialogAnswer::Contents( + "noise.grd".into(), + photocraft_psd::grd::write(&[photocraft_psd::grd::GrdGradient { noise: true, ..Default::default() }]), + )), + Some(FileDialogAnswer::Contents("wrong.abr".into(), Vec::new())), + Some(FileDialogAnswer::Paths(vec!["/nonexistent/photocraft-gradients.grd".into()])), + Some(FileDialogAnswer::Unavailable("dialog unavailable".into())), + ] { + let mut h = app_harness("gradientMap", "rgb", 8); + h.state_mut().session.presets.gradients.truncate(1); + let before = layer(&h); + let presets = h.state().session.presets.gradients.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + let error = answer.is_some(); + h.state_mut().services.file_dialog = Some(Box::new(move |_, _, reply| reply.send(answer.clone()))); + import_gradients_from_picker(&mut h); + assert_eq!(layer(&h), before); + assert_eq!(h.state().session.presets.gradients, presets); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + assert_eq!(h.state().ui.status_error, error); + if error { + assert!(!h.state().ui.status.is_empty()); + } + } +} + +#[test] +fn gradient_map_dialog_import_preserves_pending_settings() { + let mut h = app_harness("gradientMap", "rgb", 8); + let id = crate::adjust_dialog::open(h.state_mut(), "image.adjustments.gradientMap").unwrap(); + let fields = h.state_mut().ui.dialog_mut(id).unwrap().fields.clone(); + h.state_mut().services.file_dialog = Some(Box::new(|request, _, reply| { + assert!(matches!(request, crate::FileDialogRequest::Open { extensions: Some(ref exts), .. } if exts == &["grd"])); + reply.send(None); + })); + h.run_steps(3); + import_gradients_from_picker(&mut h); + assert_eq!(h.state_mut().ui.dialog_mut(id).unwrap().fields, fields); + assert!(!h.state().file_dialog_open()); + assert!(!h.state().ui.status_error); +} + /// Document point (x, y) on screen in the app's first view. fn doc_on_screen(h: &Harness<'_, PhotocraftApp>, x: f32, y: f32) -> Pos2 { let app = h.state(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8c2bac34c7..11438131d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1162,6 +1162,9 @@ Ignore All Ignorovat vše Image Size Velikost obrazu Import Brushes… Importovat štětce… + Gradient Preset Options Možnosti předvoleb přechodu + Import .GRD Importovat .GRD + Choose a gradient file (.grd). Vyberte soubor přechodů (.grd). Import brushes (.abr) Importovat štětce (.abr) Import Presets Importovat přednastavení Imported Importováno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 3e5c38703a..a9b676634b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1142,6 +1142,9 @@ Ignore All Alle ignorieren Image Size Bildgröße Import Brushes… Pinsel importieren… + Gradient Preset Options Verlaufsvorgabenoptionen + Import .GRD .GRD importieren + Choose a gradient file (.grd). Wählen Sie eine Verlaufsdatei (.grd). Import brushes (.abr) Pinsel (.abr) importieren Import Presets Vorgaben importieren Imported Importiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 01582d5d71..5c33da43ca 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -273,6 +273,9 @@ Filter for shape layers Φιλτράρισμα επιπέδων σχημάτων Filter for type layers Φιλτράρισμα επιπέδων κειμένου Import Brushes… Εισαγωγή πινέλων… + Gradient Preset Options Επιλογές προεπιλογών ντεγκραντέ + Import .GRD Εισαγωγή .GRD + Choose a gradient file (.grd). Επιλέξτε αρχείο ντεγκραντέ (.grd). Import brushes (.abr) Εισαγωγή πινέλων (.abr) Mask All Απόκρυψη όλων με μάσκα Mask Options Επιλογές μάσκας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d3a61f70bb..e7f2385d5a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1148,6 +1148,9 @@ Ignore All Ignorar todo Image Size Tamaño de imagen Import Brushes… Importar pinceles… + Gradient Preset Options Opciones de ajustes preestablecidos de degradado + Import .GRD Importar .GRD + Choose a gradient file (.grd). Seleccione un archivo de degradados (.grd). Import brushes (.abr) Importar pinceles (.abr) Import Presets Importar ajustes preestablecidos Imported Importado diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b1eb9c5e00..6076c41b5a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1130,6 +1130,9 @@ Ignore All Tout ignorer Image Size Taille de l'image Import Brushes… Importer des pinceaux… + Gradient Preset Options Options des paramètres prédéfinis de dégradé + Import .GRD Importer .GRD + Choose a gradient file (.grd). Choisissez un fichier de dégradés (.grd). Import brushes (.abr) Importer des pinceaux (.abr) Import Presets Importer des préréglages Imported Importé diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 626b84cd16..a7dd7723b7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3996,3 +3996,6 @@ cameraRaw Yellow Sárga Object Finder Objektumkereső No object found there — drag a rectangle around it Ott nem található objektum – húzz köré egy téglalapot Drag to pan · Scroll to zoom · Double-click to fit Húzás: mozgatás · Görgetés: nagyítás · Dupla kattintás: igazítás + Gradient Preset Options Színátmenet-előbeállítás beállításai + Import .GRD .GRD importálása + Choose a gradient file (.grd). Válasszon színátmenetfájlt (.grd). diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e29822449a..bb35e8cba7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1124,6 +1124,9 @@ Ignore All Abaikan Semua Image Size Ukuran Gambar Import Brushes… Impor Kuas… + Gradient Preset Options Opsi Preset Gradien + Import .GRD Impor .GRD + Choose a gradient file (.grd). Pilih file gradien (.grd). Import brushes (.abr) Impor kuas (.abr) Import Presets Impor Preset Imported Diimpor diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1c6ba0ec77..ca8e1cf717 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1147,6 +1147,9 @@ Ignore All Ignora tutto Image Size Dimensione immagine Import Brushes… Importa pennelli… + Gradient Preset Options Opzioni predefiniti sfumatura + Import .GRD Importa .GRD + Choose a gradient file (.grd). Scegli un file di sfumature (.grd). Import brushes (.abr) Importa pennelli (.abr) Import Presets Importa predefiniti Imported Importato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 761501e85c..7d0bf7dc47 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1149,6 +1149,9 @@ Ignore All すべて無視 Image Size 画像サイズ Import Brushes… ブラシを読み込み… + Gradient Preset Options グラデーションプリセットのオプション + Import .GRD .GRD を読み込み + Choose a gradient file (.grd). グラデーションファイル (.grd) を選択してください。 Import brushes (.abr) ブラシ (.abr) を読み込み Import Presets プリセットを読み込み Imported 読み込み済み diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b280416893..135a872867 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1118,6 +1118,9 @@ Ignore All 모두 무시 Image Size 이미지 크기 Import Brushes… 브러시 가져오기… + Gradient Preset Options 그레이디언트 사전 설정 옵션 + Import .GRD .GRD 가져오기 + Choose a gradient file (.grd). 그레이디언트 파일 (.grd)을 선택하세요. Import brushes (.abr) 브러시 가져오기(.abr) Import Presets 사전 설정 가져오기 Imported 가져옴 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index cf570d4021..6d8b52bf95 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1131,6 +1131,9 @@ Ignore All Alles negeren Image Size Afbeeldingsgrootte Import Brushes… Penselen importeren… + Gradient Preset Options Opties voor verloopvoorinstellingen + Import .GRD .GRD importeren + Choose a gradient file (.grd). Kies een verloopbestand (.grd). Import brushes (.abr) Penselen (.abr) importeren Import Presets Voorinstellingen importeren Imported Geïmporteerd diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cc0b77df70..7c89aa596d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1145,6 +1145,9 @@ Ignore All Ignoruj wszystko Image Size Rozmiar obrazu Import Brushes… Importuj pędzle… + Gradient Preset Options Opcje ustawień predefiniowanych gradientu + Import .GRD Importuj .GRD + Choose a gradient file (.grd). Wybierz plik gradientów (.grd). Import brushes (.abr) Importuj pędzle (.abr) Import Presets Importuj ustawienia wstępne Imported Zaimportowano diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bbe1fd8e7f..be6c0381f0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1127,6 +1127,9 @@ Ignore All Ignorar tudo Image Size Tamanho da imagem Import Brushes… Importar pincéis… + Gradient Preset Options Opções de predefinições de degradê + Import .GRD Importar .GRD + Choose a gradient file (.grd). Escolha um arquivo de degradês (.grd). Import brushes (.abr) Importar pincéis (.abr) Import Presets Importar predefinições Imported Importado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e4e0b0097..125155cadf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1018,6 +1018,9 @@ Rename Brush… Переименовать кисть… Delete Brush Удалить кисть Import Brushes… Импортировать кисти… + Gradient Preset Options Параметры наборов градиентов + Import .GRD Импортировать .GRD + Choose a gradient file (.grd). Выберите файл градиентов (.grd). Brush Name Имя кисти Brush Stroke Мазок кисти New Brush Preset… Новый набор параметров кисти… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 419c54c8ab..e4d4ec9aed 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3808,3 +3808,6 @@ cameraData Lens Lens Object Finder Nesne Bulucu No object found there — drag a rectangle around it Orada nesne bulunamadı — çevresine bir dikdörtgen sürükleyin Drag to pan · Scroll to zoom · Double-click to fit Kaydırmak için sürükleyin · Yakınlaştırmak için kaydırın · Sığdırmak için çift tıklayın + Gradient Preset Options Gradyan Ön Ayarı Seçenekleri + Import .GRD .GRD İçe Aktar + Choose a gradient file (.grd). Bir gradyan dosyası (.grd) seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c5c724c92c..83f1250125 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -996,6 +996,9 @@ Rename Brush… Перейменувати пензель… Delete Brush Видалити пензель Import Brushes… Імпортувати пензлі… + Gradient Preset Options Параметри наборів градієнтів + Import .GRD Імпортувати .GRD + Choose a gradient file (.grd). Виберіть файл градієнтів (.grd). Brush Name Назва пензля Brush Stroke Мазок пензля New Brush Preset… Новий набір пензля… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6872d8cf6b..718278b5d2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3560,3 +3560,6 @@ curves Smooth Làm mượt Action playback stopped Đã dừng phát hành động Step {step} is waiting for its dialog Bước {step} đang chờ hộp thoại của nó @plural {n} action|{n} actions {n} hành động + Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc + Import .GRD Nhập .GRD + Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index beb1b0a5cb..e2d5a3d6bf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1152,6 +1152,9 @@ Ignore All 全部忽略 Image Size 图像大小 Import Brushes… 导入画笔… + Gradient Preset Options 渐变预设选项 + Import .GRD 导入 .GRD + Choose a gradient file (.grd). 请选择渐变文件 (.grd)。 Import brushes (.abr) 导入画笔(.abr) Import Presets 导入预设 Imported 已导入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3772fc1c5..8543251b0c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1153,6 +1153,9 @@ Ignore All 全部略過 Image Size 影像尺寸 Import Brushes… 匯入筆刷… + Gradient Preset Options 漸層預設選項 + Import .GRD 匯入 .GRD + Choose a gradient file (.grd). 請選擇漸層檔案 (.grd)。 Import brushes (.abr) 匯入筆刷 (.abr) Import Presets 匯入預設集 Imported 已匯入 diff --git a/crates/ui-egui/src/preset_files_ui.rs b/crates/ui-egui/src/preset_files_ui.rs index e58961d9b5..26a84bd85f 100644 --- a/crates/ui-egui/src/preset_files_ui.rs +++ b/crates/ui-egui/src/preset_files_ui.rs @@ -24,6 +24,23 @@ pub fn is_preset_file(name: &str) -> bool { PRESET_EXTS.contains(&ext(name).as_str()) } +/// Import from a gradient picker through the same preset command as File › Open. +pub(crate) fn import_gradients_dialog(app: &mut PhotocraftApp) { + let result = app.pick_file_bytes_filtered(&["grd"], |app, name, bytes| { + if ext(&name) != "grd" { + return Err(tl!("Choose a gradient file (.grd).").into()); + } + open(app, &name, &bytes).ok_or_else(|| tl!("Choose a gradient file (.grd).").to_string())??; + Ok(Value::Null) + }); + if let Err(error) = result + && error != crate::file_dialog::CANCELLED + { + app.ui.status = error; + app.ui.status_error = true; + } +} + /// Command params for the file: a real path keeps the journal small; browser files arrive as bytes. fn source(name: &str, bytes: &[u8]) -> Value { #[cfg(not(target_arch = "wasm32"))] From 60c825513c7b6811b82f351f867dc46cb53c6f22 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:16:56 +0200 Subject: [PATCH 256/523] Type: every text edit respects Lock All (#4188) (#4189) #3132 refused type.edit on a Lock All layer, but type.insertText, edit.checkSpelling "change", Paste Lorem Ipsum (and the other type_extra_cmds::with_text edits) and Find and Replace Text "change" on a layer locked after the find still changed its text and recorded history. The check is now one pub(crate) helper, type_cmds::check_text_unlocked, called by every one of these paths with the same "...is locked" error as type.edit. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/edit_menu_cmds.rs | 1 + crates/engine/src/edit_menu_cmds/tests.rs | 21 +++++++++++++++++ crates/engine/src/type_cmds.rs | 18 +++++++++------ crates/engine/src/type_extra_cmds.rs | 5 ++++- crates/engine/src/type_extra_cmds/tests.rs | 15 +++++++++++++ crates/engine/src/type_spell_cmds.rs | 26 +++++++++++++++++++++- 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 4933ed93fc..734f7a1b52 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -894,6 +894,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { && let Some((id, a, b)) = cursor && text_of(&doc, id).is_some_and(|t| find_matches(&t, &find, case, whole).contains(&(a, b))) { + crate::type_cmds::check_text_unlocked(&doc, id)?; s.edit("Replace Text", |doc, _| { let snapshot = doc.clone(); if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 0a1bab4633..1bede87251 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -446,6 +446,27 @@ fn find_and_replace_across_type_layers() { assert!(s.execute("edit.findAndReplaceText", json!({"find": ""})).is_err()); } +#[test] +fn find_and_replace_change_rejects_a_layer_locked_after_the_find() { + // #4188: the found match's layer gets Lock All before Change; the text must stay. + let mut s = session(8); + let id = s.execute("type.create", json!({"text": "abc", "x": 2, "y": 12})).unwrap()["layer"].as_u64().unwrap(); + // A second, unlocked type layer keeps the command enabled. + s.execute("type.create", json!({"text": "zzz", "x": 2, "y": 40})).unwrap(); + let found = s.execute("edit.findAndReplaceText", json!({"find": "abc", "action": "find"})).unwrap(); + assert_eq!(found["found"]["layer"].as_u64(), Some(id)); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + let result = s.execute("edit.findAndReplaceText", json!({"find": "abc", "replace": "x", "action": "change"})); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + let text = match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Text(t) => t.text.clone(), + _ => String::new(), + }; + assert_eq!(text, "abc"); + assert_eq!(s.active().unwrap().history.past_len(), history); +} + #[test] fn find_in_the_active_layer_finds_nothing_when_it_is_not_type() { // #703: `allLayers: false` with a raster layer active leaves nothing to search. diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index 4a4cb347bf..a53b6baba4 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -466,17 +466,21 @@ pub fn refresh(doc: &Document, t: &mut TextLayer) { t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); } +/// Refuses to change a type layer under Lock All (its own or an enclosing group's), with +/// Photoshop's message. Every command that edits a type layer's text calls it first (#3132, #4188). +pub(crate) fn check_text_unlocked(doc: &Document, id: LayerId) -> Result<()> { + let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if doc.effective_locks(id).all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); + } + Ok(()) +} + /// Runs `f` on a type layer as one undo step. `f` may set an explicit layer name (`type.edit`'s /// `name`), which is applied in the same step; otherwise an auto-named layer follows its text. fn with_text_layer(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document, &mut Option) -> Result) -> Result { let id = layer_id(s, p)?; - { - let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; - let layer = doc.layer(id).ok_or(EngineError::NoLayer(id))?; - if doc.effective_locks(id).all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", layer.name))); - } - } + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; let (r, damage) = s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index e460a975b6..a11db9976b 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -14,7 +14,9 @@ use photocraft_text::render::PathEl; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets}; +use crate::type_cmds::{ + check_text_unlocked, format_targets, has_format_target, is_auto_named, layer_name, refresh, replace_text, style_paragraphs, style_range, with_text_targets, +}; use crate::{EngineError, Result, Session}; /// Photoshop's Paste Lorem Ipsum filler. @@ -67,6 +69,7 @@ fn has_defaults(s: &Session) -> std::result::Result<(), String> { /// Edits a type layer as one history step and re-renders it. fn with_text(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut TextLayer, &Document) -> Result) -> Result { let id = layer_param(s, p)?; + check_text_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?; s.edit(label, |doc, _| { let snapshot = doc.clone(); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 993ae41e8d..395128a440 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -282,3 +282,18 @@ fn rasterize_type_layer() { assert!(matches!(s.active().unwrap().doc.layer(id).unwrap().content, LayerContent::Raster(_))); assert!(!s.is_enabled("type.rasterizeTypeLayer")); } + +#[test] +fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. + let mut s = session(8); + let id = text_layer(&mut s, "Hi"); + s.execute("layer.setProps", json!({"layer": id.0, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for params in [json!({}), json!({"layer": id.0})] { + let result = s.execute("type.pasteLoremIpsum", params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{result:?}"); + assert_eq!(text(&s, id).text, "Hi"); + assert_eq!(s.active().unwrap().history.past_len(), history); + } +} diff --git a/crates/engine/src/type_spell_cmds.rs b/crates/engine/src/type_spell_cmds.rs index cbebb0664d..12a63a364c 100644 --- a/crates/engine/src/type_spell_cmds.rs +++ b/crates/engine/src/type_spell_cmds.rs @@ -16,7 +16,7 @@ use photocraft_text::spell::Dictionary; use serde_json::{Value, json}; use crate::commands::{CommandSpec, layer_param}; -use crate::type_cmds::{norm_text, refresh, replace_text}; +use crate::type_cmds::{check_text_unlocked, norm_text, refresh, replace_text}; use crate::{EngineError, Result, Session}; fn bad(cmd: &str, msg: impl Into) -> EngineError { @@ -94,6 +94,7 @@ fn change(s: &mut Session, p: &Value) -> Result { let to = p.get("replace").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`change` needs `replace`"))?.to_string(); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let (a, b) = (byte_at(t, start), byte_at(t, end.max(start))); if let Some(w) = p.get("word").and_then(Value::as_str) && &t[a..b] != w @@ -199,6 +200,7 @@ fn insert_text(s: &mut Session, p: &Value) -> Result { let new = norm_text(&new); let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); let t = text(&doc, id).ok_or_else(|| bad(cmd, format!("layer {} is not a type layer", id.0)))?; + check_text_unlocked(&doc, id)?; let n = t.chars().count(); let (a, b) = match (p.get("range").and_then(Value::as_array), p.get("at").and_then(Value::as_u64)) { (Some(r), _) if r.len() == 2 => { @@ -272,6 +274,28 @@ mod tests { text(&s.active().unwrap().doc, LayerId(id)).unwrap().to_string() } + #[test] + fn all_locked_layer_rejects_insert_text_and_spelling_change_without_history() { + // #4188: Lock All stopped type.edit (#3132) but not these two text edits. + let (mut s, id) = session("Teh"); + // A second, unlocked type layer keeps Check Spelling enabled. + s.execute("type.create", json!({"x": 10, "y": 90, "text": "zzz", "size": 12})).unwrap(); + s.execute("layer.setProps", json!({"layer": id, "locked": true})).unwrap(); + let history = s.active().unwrap().history.past_len(); + for (cmd, params) in [ + ("type.insertText", json!({"layer": id, "text": "b"})), + ("edit.checkSpelling", json!({"action": "change", "layer": id, "start": 0, "end": 3, "word": "Teh", "replace": "The"})), + ] { + let result = s.execute(cmd, params); + assert!(matches!(&result, Err(EngineError::Other(message)) if message.contains("locked")), "{cmd}: {result:?}"); + assert_eq!(text_of(&s, id), "Teh", "{cmd}"); + assert_eq!(s.active().unwrap().history.past_len(), history, "{cmd}"); + } + s.execute("layer.setProps", json!({"layer": id, "locked": false})).unwrap(); + s.execute("type.insertText", json!({"layer": id, "text": "b"})).unwrap(); + assert_eq!(text_of(&s, id), "Tehb"); + } + #[test] fn lists_changes_and_learns_words() { let (mut s, id) = session("Teh quick brwon fox, teh end. Photocraft"); From 71827a614cf8238d8f4e982c724e04f68b7b7afc Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:01 +0200 Subject: [PATCH 257/523] Color Sampler: keep the CMYK readout in range on 32-bit documents (#4191) (#4192) 32-bit samples can lie outside 0..1. The RGB readout clamped them, but device_cmyk did not, so a pixel at 1.5 read K:-50% and negative values gave C/M/Y above 100%. Clamp each channel to 0..1 (NaN reads as 0) before the conversion; this fixes both the Color Sampler and the Info panel. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/sampler_cmds.rs | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sampler_cmds.rs b/crates/engine/src/sampler_cmds.rs index 38c355071c..472ecb672a 100644 --- a/crates/engine/src/sampler_cmds.rs +++ b/crates/engine/src/sampler_cmds.rs @@ -24,8 +24,11 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> { s.active().map(|_| ()).ok_or_else(|| "no document open".into()) } -/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. +/// Naive device CMYK (no ICC profile), in percent, as the Info panel shows it. 32-bit samples can +/// lie outside 0..1, so each channel is clamped first (NaN reads as 0), like the RGB readout. pub fn device_cmyk(r: f32, g: f32, b: f32) -> [i32; 4] { + let unit = |v: f32| if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + let (r, g, b) = (unit(r), unit(g), unit(b)); let k = 1.0 - r.max(g).max(b); let f = |v: f32| if k >= 1.0 { 0.0 } else { (1.0 - v - k) / (1.0 - k) }; [f(r), f(g), f(b), k].map(|v| (v * 100.0).round() as i32) @@ -418,6 +421,36 @@ mod tests { assert_eq!(device_cmyk(0.5, 0.5, 0.5), [0, 0, 0, 50]); } + /// 32-bit samples can lie outside 0..1 (or be NaN); the CMYK readout clamps them like the RGB + /// readout does instead of reporting K:-50% or C:120% (#4191). + #[test] + fn device_cmyk_clamps_out_of_range_and_nan_samples() { + assert_eq!(device_cmyk(1.5, 1.5, 1.5), [0, 0, 0, 0]); + assert_eq!(device_cmyk(2.0, 0.0, 0.0), [0, 100, 100, 0]); + let in_range = |c: [i32; 4]| c.iter().all(|v| (0..=100).contains(v)); + for (r, g, b) in [(-0.2, 0.5, 0.5), (-1.0, -1.0, -1.0), (f32::NAN, 0.5, 0.5), (f32::NAN, f32::NAN, f32::NAN), (f32::INFINITY, f32::NEG_INFINITY, 0.5)] { + let c = device_cmyk(r, g, b); + assert!(in_range(c), "{r} {g} {b} -> {c:?}"); + } + assert_eq!(device_cmyk(f32::NAN, f32::NAN, f32::NAN), [0, 0, 0, 100]); + } + + #[test] + fn hdr_pixel_on_a_32_bit_document_reads_in_range() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "depth": 32, "background": "white"})).unwrap(); + s.edit("paint", |doc, _| { + let surf = doc.layers[0].surface_mut().unwrap(); + let hdr = photocraft_raster::from_rgba(&surf.format(), [2.0, 2.0, 2.0, 1.0]); + surf.fill_rect(Rect::new(3, 3, 4, 4), &hdr); + Ok(()) + }) + .unwrap(); + let r = s.execute("view.colorSamplers.add", json!({"x": 3, "y": 3})).unwrap(); + assert_eq!(r["point"]["rgb"], json!([255, 255, 255])); + assert_eq!(r["point"]["cmyk"], json!([0, 0, 0, 0])); + } + #[test] fn points_work_at_every_depth_and_mode() { for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("grayscale", 8), ("cmyk", 8), ("lab", 8)] { From 56869d12e3804c9e556c92a09964cfb925d04ab0 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:06 +0200 Subject: [PATCH 258/523] Layer Comps: the warning icon clears only its own comp (#4197) (#4198) The warning triangle on a comp row sent layerComp.updateWarnings {"clear":true}, which dropped the stale states of every comp. The command now takes an optional "comp" (id or name) that limits both the report and the clear to that comp; an unknown comp is an error and changes nothing. Without "comp" it still acts on every comp (Clear All Layer Comp Warnings). The row icon sends its own comp id. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/comps_cmds.rs | 17 ++++++++--- crates/engine/src/comps_cmds/tests.rs | 43 +++++++++++++++++++++++++++ crates/ui-egui/src/comps_ui.rs | 31 ++++++++++++++++++- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/comps_cmds.rs b/crates/engine/src/comps_cmds.rs index 58f92f84ea..fe5708387c 100644 --- a/crates/engine/src/comps_cmds.rs +++ b/crates/engine/src/comps_cmds.rs @@ -319,13 +319,21 @@ fn restore_last(s: &mut Session) -> Result { } /// Comps that refer to deleted layers (Photoshop's warning triangle). `"clear": true` drops -/// those stale states (Clear Layer Comp Warning) as one history step. +/// those stale states as one history step: from `"comp"` only when given (Clear Layer Comp +/// Warning), else from every comp (Clear All Layer Comp Warnings). `"comp"` also limits the +/// report to that comp; an unknown comp is an error and changes nothing. fn update_warnings(s: &mut Session, p: &Value) -> Result { + let cmd = "layerComp.updateWarnings"; let d = s.active().ok_or(EngineError::NoDocument)?; + let only = match p.get("comp") { + None | Some(Value::Null) => None, + Some(_) => Some(comp_param(&d.doc, p, cmd)?), + }; let warnings: Vec = d .doc .layer_comps .iter() + .filter(|c| only.is_none_or(|id| c.id == id)) .filter_map(|c| { let missing = c.missing_layers(&d.doc); (!missing.is_empty()).then(|| json!({"comp": c.id, "name": c.name, "missingLayers": missing.iter().map(|l| l.0).collect::>()})) @@ -333,9 +341,10 @@ fn update_warnings(s: &mut Session, p: &Value) -> Result { .collect(); let n = warnings.len(); if flag(p, "clear", false) && n > 0 { - s.edit("Clear Layer Comp Warnings", |doc, _| { + let label = if only.is_some() { "Clear Layer Comp Warning" } else { "Clear All Layer Comp Warnings" }; + s.edit(label, |doc, _| { let live: std::collections::HashSet = doc.walk().into_iter().map(|(_, _, l)| l.id).collect(); - for c in &mut doc.layer_comps { + for c in doc.layer_comps.iter_mut().filter(|c| only.is_none_or(|id| c.id == id)) { c.states.retain(|st| live.contains(&st.layer)); } Ok(()) @@ -434,7 +443,7 @@ pub fn specs() -> Vec { "layerComp.updateWarnings", "Layer Comp Warnings", &[], - r##"{"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, + r##"{"comp":id|name? (default: every comp; limits the report and the clear to that comp),"clear":bool=false (drop states of deleted layers)} → {warnings:[{comp,name,missingLayers}],count}"##, has_doc, update_warnings ), diff --git a/crates/engine/src/comps_cmds/tests.rs b/crates/engine/src/comps_cmds/tests.rs index 22ef1a4697..5458523bcf 100644 --- a/crates/engine/src/comps_cmds/tests.rs +++ b/crates/engine/src/comps_cmds/tests.rs @@ -304,3 +304,46 @@ fn unreachable_comp_positions_leave_the_layer_in_place() { assert_eq!(pos(&s, b), Some((31, 22))); } } + +#[test] +fn clearing_one_comps_warning_leaves_the_others() { + // Regression for #4197: the per-comp warning icon cleared every comp's warnings. + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap(); + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + // Reporting can be limited to one comp too. + let w = s.execute("layerComp.updateWarnings", json!({"comp": c2})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": c1})).unwrap(); + assert!(doc(&s).comp(c1 as u32).unwrap().missing_layers(doc(&s)).is_empty()); + assert_eq!(doc(&s).comp(c2 as u32).unwrap().missing_layers(doc(&s)), vec![a]); + let w = s.execute("layerComp.updateWarnings", json!({})).unwrap(); + assert_eq!(w["count"], 1); + assert_eq!(w["warnings"][0]["comp"], c2); + // Without "comp" every comp is cleared (Clear All Layer Comp Warnings). + s.execute("layerComp.updateWarnings", json!({"clear": true})).unwrap(); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 0); +} + +#[test] +fn clearing_warnings_of_an_unknown_comp_is_an_error() { + let (mut s, a, _) = session(8); + let c1 = s.execute("layerComp.new", json!({})).unwrap()["comp"].as_u64().unwrap(); + s.edit("del", |d, _| { + d.remove(a); + Ok(()) + }) + .unwrap(); + let before = doc(&s).layer_comps.clone(); + for bad in [json!(c1 + 99), json!("nope"), json!(-1), json!(1.5), json!(true), json!([c1])] { + assert!(s.execute("layerComp.updateWarnings", json!({"clear": true, "comp": bad})).is_err(), "{bad}"); + } + assert_eq!(doc(&s).layer_comps, before); + assert_eq!(s.execute("layerComp.updateWarnings", json!({})).unwrap()["count"], 1); +} diff --git a/crates/ui-egui/src/comps_ui.rs b/crates/ui-egui/src/comps_ui.rs index d6dc3e8d82..30304f6c82 100644 --- a/crates/ui-egui/src/comps_ui.rs +++ b/crates/ui-egui/src/comps_ui.rs @@ -18,6 +18,12 @@ pub fn target(app: &PhotocraftApp) -> Option { app.ui.layer_comp_selected.filter(|id| doc.comp(*id).is_some()).or(doc.last_applied_comp.filter(|id| doc.comp(*id).is_some())) } +/// The row's warning triangle: Clear Layer Comp Warning for that comp only (Photoshop keeps +/// Clear All Layer Comp Warnings as a separate command). +fn clear_warning_action(comp: u32) -> (&'static str, Value) { + ("layerComp.updateWarnings", json!({"clear": true, "comp": comp})) +} + fn run(app: &mut PhotocraftApp, id: &str, p: Value) { if let Err(e) = app.run(id, p) { app.ui.status = e; @@ -124,7 +130,7 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { )); if resp.clicked() { toggle_hit = true; - action = Some(("layerComp.updateWarnings", json!({"clear": true}))); + action = Some(clear_warning_action(c.id)); } } name_x += 18.0; @@ -185,3 +191,26 @@ pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_engine::Session; + + #[test] + fn warning_icon_clears_only_its_own_comp() { + // Regression for #4197. + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let layer = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let c1 = s.execute("layerComp.new", json!({"name": "One"})).unwrap()["comp"].as_u64().unwrap() as u32; + let c2 = s.execute("layerComp.new", json!({"name": "Two"})).unwrap()["comp"].as_u64().unwrap() as u32; + s.execute("layer.delete", json!({"layer": layer})).unwrap(); + let (id, p) = clear_warning_action(c1); + assert_eq!(p["comp"], c1); + s.execute(id, p).unwrap(); + let doc = &s.active().unwrap().doc; + assert!(doc.comp(c1).unwrap().missing_layers(doc).is_empty()); + assert_eq!(doc.comp(c2).unwrap().missing_layers(doc).iter().map(|l| l.0).collect::>(), vec![layer]); + } +} From 63a0e43e74628769c4341d6fa0dcf68b9a6377a4 Mon Sep 17 00:00:00 2001 From: JGF <44821497+bommerts@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:17:10 -0400 Subject: [PATCH 259/523] fix(type): key font menu caches on the shared engine's fonts (#4200) The font menu's preview samples, family list and localized names were cached on fonts::generation(), which any FontDb moves, and the previews also on the served catalog's generation. Other engines registering fonts (tests running in parallel) re-rendered every visible sample each pass, and families covering Arabic asked for a repaint, so Harness::run never settled: options_font_family_preserves_peer_faces_and_batches_layer_flags and the font picker tests failed intermittently with "exceeded max_steps". - photocraft-text: fonts::shared_generation(), moved only by the shared engine's FontDb (FontDb::mark_shared from new_shared). - ui-egui: families(), localized_families() and the preview cache key on it; previews no longer key on the served catalog (a served family's sample appears when its fonts register, which moves the generation). Co-authored-by: Claude Opus 5.5 --- crates/text/src/fonts.rs | 23 +++++++++++++++++++++++ crates/text/src/lib.rs | 4 +++- crates/text/src/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/font_preview.rs | 11 +++++++---- crates/ui-egui/src/type_tool.rs | 8 ++++---- crates/ui-egui/src/type_tool_tests.rs | 27 +++++++++++++++++++++++++++ 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 21fc1a92a4..433baee074 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -109,6 +109,16 @@ pub fn generation() -> u64 { GENERATION.load(Ordering::Relaxed) } +/// Bumped only by the [`FontDb`] of [`crate::shared`], so caches of the shared engine's fonts +/// aren't invalidated by unrelated databases (a document's own engine, a test's throwaway one). +static SHARED_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Changes whenever font data is registered in the shared engine's [`FontDb`] ([`crate::shared`]). +/// Font menus key their caches on this, not [`generation`]. +pub fn shared_generation() -> u64 { + SHARED_GENERATION.load(Ordering::Relaxed) +} + /// One face in the database (for font menus). #[derive(Clone, Debug, PartialEq)] pub struct FaceInfo { @@ -145,6 +155,8 @@ pub struct FontDb { missing_glyph_protection: bool, face_cache: HashMap>, face_aliases: HashMap<(String, usize), String>, + /// This is the shared engine's database: registrations also move [`shared_generation`]. + shared: bool, } impl Default for FontDb { @@ -165,6 +177,7 @@ impl FontDb { missing_glyph_protection: true, face_cache: HashMap::new(), face_aliases: HashMap::new(), + shared: false, }; for (_, bytes) in BUNDLED { db.register_static_font(bytes.as_slice()); @@ -246,9 +259,19 @@ impl FontDb { self.face_cache.clear(); self.refresh_generics(); GENERATION.fetch_add(1, Ordering::Relaxed); + if self.shared { + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } names } + /// Marks this as the shared engine's database ([`crate::shared`]) and moves + /// [`shared_generation`], since its fonts replace whatever caches saw before. + pub(crate) fn mark_shared(&mut self) { + self.shared = true; + SHARED_GENERATION.fetch_add(1, Ordering::Relaxed); + } + fn refresh_generics(&mut self) { let c = &mut self.fcx.collection; if let Some(inter) = c.family_id(DEFAULT_FAMILY) { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index da323b5a22..35e7dccbdf 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -147,7 +147,9 @@ fn uses_system_fonts() -> bool { } fn new_shared() -> std::sync::Mutex { - std::sync::Mutex::new(if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }) + let mut engine = if uses_system_fonts() { TextEngine::with_system_fonts() } else { TextEngine::new() }; + engine.fonts.mark_shared(); + std::sync::Mutex::new(engine) } /// A process-wide engine for callers without their own (bundled fonts only on the web; bundled diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ae3092ca46..d502385f07 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -47,6 +47,21 @@ fn registering_fonts_moves_the_generation() { assert!(fonts::generation() > g); } +/// Font menus cache on the shared engine's generation: other engines registering fonts (a test's +/// throwaway one, in parallel) must not invalidate them, or the menus re-render every frame. +#[test] +fn only_the_shared_database_moves_the_shared_generation() { + let _ = crate::shared(); + let g = fonts::shared_generation(); + TextEngine::new().fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert_eq!(fonts::shared_generation(), g, "an unrelated engine leaves it alone"); + let mut e = TextEngine::new(); + e.fonts.mark_shared(); + let g = fonts::shared_generation(); + e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); + assert!(fonts::shared_generation() > g); +} + #[test] fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() { let mut e = TextEngine::new(); diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index 1568abf655..74c334d682 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -49,9 +49,12 @@ pub(crate) fn sample(preview: FontPreview) -> Option<(f32, [f32; 2])> { Some((size, box_for(size))) } -/// Family, display scale, the font database generations (so a family installed or fetched -/// (served fonts) after its first miss gets a sample once it is available) and the point size. -type Key = (String, u32, (u64, u64), u32); +/// Family, display scale, the shared font database's generation (so a family installed or +/// fetched (served fonts) after its first miss gets a sample once it is available) and the point +/// size. Not the served catalog's generation: a listed family has no sample until its fonts are +/// registered, which moves the database's generation, and keying on the catalog re-rendered +/// every sample whenever it changed. +type Key = (String, u32, u64, u32); #[derive(Clone)] struct Samples { latin: TextureHandle, @@ -111,7 +114,7 @@ fn images(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32, si fn key(ctx: &egui::Context, family: &str, size: f32) -> Key { let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32; - (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation()), size as u32) + (family.to_owned(), scale, photocraft_text::fonts::shared_generation(), size as u32) } /// Width a row reserves for its samples: wider once a painted row showed the family covers diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 4bfa31227c..6a801b3385 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -985,11 +985,11 @@ fn draw_mask_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor } /// Font families: bundled, system, and those the host serves (`photocraft_text::served`, fetched -/// when picked). Cached until fonts are registered or the served catalog changes. +/// when picked). Cached until the shared engine registers fonts or the served catalog changes. pub fn families() -> Arc> { type Cached = Option<((u64, u64), Arc>)>; static FAMILIES: std::sync::Mutex = std::sync::Mutex::new(None); - let key = (photocraft_text::fonts::generation(), photocraft_text::served::generation()); + let key = (photocraft_text::fonts::shared_generation(), photocraft_text::served::generation()); let mut cached = FAMILIES.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key @@ -1027,11 +1027,11 @@ pub fn sync_font_names(ctx: &egui::Context, show_in_english: bool) { }); } -/// The fonts' localized family names, keyed by family. Cached until fonts are registered. +/// The fonts' localized family names, keyed by family. Cached until the shared engine registers fonts. fn localized_families() -> Arc> { type Cached = Option<(u64, Arc>)>; static LOCALIZED: std::sync::Mutex = std::sync::Mutex::new(None); - let key = photocraft_text::fonts::generation(); + let key = photocraft_text::fonts::shared_generation(); let mut cached = LOCALIZED.lock().unwrap_or_else(PoisonError::into_inner); if let Some((k, v)) = cached.as_ref() && *k == key diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index 25011745e2..046fd00f6f 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -1428,6 +1428,33 @@ mod multi_layer_formatting { assert_ne!(text(h.state(), ids[2]).antialias, AntiAlias::Sharp); } + /// Other engines registering fonts and the served catalog growing (tests running in parallel) + /// used to move the key of the font menu's preview cache, so its samples re-rendered and asked + /// for a repaint every pass and `Harness::run` never settled (a flaky `exceeded max_steps`). + /// Here both happen on every pass. + #[test] + fn the_font_menu_settles_while_other_engines_register_fonts() { + let (app, _) = fixture(); + let mut pass = 0u32; + let mut h = Harness::builder().with_size(vec2(1100.0, 800.0)).build_ui_state( + move |ui, app| { + photocraft_text::TextEngine::new(); + // The served catalog is process-wide: names that sort last. + photocraft_text::served::add_families([format!("zz Churn Test {pass:03}")]); + pass = pass.saturating_add(1); + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::super::options_bar(app, ui)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, Default::default()); + h.run_steps(4); + h.get_all_by_role(Role::ComboBox).next().unwrap().click(); + h.run(); + h.run(); + } + #[test] fn properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary() { let (mut app, ids) = fixture(); From e4f15eecdfbb8bc2add8a37c00a6c199c963c80d Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:14 +0200 Subject: [PATCH 260/523] Vector masks: validate feather like pixel masks (#4201) (#4202) layer.vectorMask.edit stored `feather` as f.max(0.0) as f32, so 1e39 became inf (hard edge, null in info, .pcraft save refused) and any huge value was accepted. It now takes the same finite 0..=1000 px range and error as layer.layerMask.edit, checked before the edit so a bad value leaves the mask and history untouched. The params doc says so. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/vector_cmds.rs | 18 +++++++++++++++--- crates/engine/src/vector_cmds/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index b732ff2629..e32a7e41fd 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1415,6 +1415,16 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layer.vectorMask.edit"; let id = layer_id(s, p)?; let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?; + // Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201). + let feather = p + .get("feather") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .map(|n| n as f32) + .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) + }) + .transpose()?; s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1430,8 +1440,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(d) = f64p(p, "density") { vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; } - if let Some(f) = f64p(p, "feather") { - vm.feather = f.max(0.0) as f32; + if let Some(f) = feather { + vm.feather = f; } if let Some(b) = p.get("invert").and_then(Value::as_bool) && b @@ -1821,7 +1831,9 @@ pub fn specs() -> Vec { "layer.vectorMask.edit", "Edit Vector Mask", [], - leak(format!(r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":px?,"invert":true?}}. {PATH_FORM}"##)), + leak(format!( + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + )), has_layer, vector_mask_edit ), diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 00cb58d8cb..11a4b511e0 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -701,6 +701,28 @@ fn vector_mask_commands_and_compositing() { assert!(s.execute("layer.vectorMask.revealAll", json!({})).is_err()); } +/// #4201: the vector mask's feather takes the pixel mask's range (finite 0..=1000 px); anything +/// else is refused before the edit, so no infinite feather can block a .pcraft save. +#[test] +fn vector_mask_edit_rejects_out_of_range_feather() { + let mut s = session(32, 32, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("layer.vectorMask.revealAll", json!({})).unwrap(); + let feather = |s: &Session| doc(s).layer(id).unwrap().vector_mask.as_ref().unwrap().feather; + let steps = s.active().unwrap().history.past_len(); + for bad in [json!(1e39), json!(-1), json!(1001), json!("x"), json!(null)] { + assert!(s.execute("layer.vectorMask.edit", json!({"feather": bad})).is_err(), "feather {bad}"); + assert_eq!(feather(&s), 0.0, "feather {bad}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "feather {bad}"); + } + let r = s.execute("layer.vectorMask.edit", json!({"feather": 12.5})).unwrap(); + assert_eq!(r["feather"], 12.5); + assert_eq!(feather(&s), 12.5); + s.execute("layer.vectorMask.edit", json!({"feather": 1000})).unwrap(); + assert_eq!(feather(&s), 1000.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); From 72c8f58e11dff989ea95d8960fb9947e5ae2d26e Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:38 +0200 Subject: [PATCH 261/523] Shapes: a rectangle with a negative size extends from its origin like the other shapes (#4203) (#4204) rounded_rect took the absolute width/height without moving the origin, so shape.create {"kind":"rect","rect":[50,10,-20,30]} covered x 50..70 while an ellipse or polygon with the same rect covered 30..50. Normalise (x, w) and (y, h) for negative sizes instead. The Shape tool drag already normalises its rect, so interactive drawing is unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/vector_cmds/tests.rs | 25 +++++++++++++++++++++++++ crates/vector/src/shapes.rs | 20 ++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 11a4b511e0..21207d3e96 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -796,6 +796,31 @@ fn shape_info_bounds_of_curved_shapes_are_exact() { } } +#[test] +fn shape_create_negative_size_rect_covers_the_ellipse_box() { + // Issue #4203: a rectangle with a negative width spanned x 50..70 while the ellipse and + // polygon with the same `rect` spanned 30..50. + for depth in [8, 16, 32] { + let mut s = session(100, 60, depth); + let ell = s.execute("shape.create", json!({"kind": "ellipse", "rect": [50, 10, -20, 30], "fill": "#0000ff"})).unwrap()["bounds"].clone(); + assert_eq!(ell, json!([30, 10, 20, 30]), "{depth}-bit"); + for params in [ + json!({"kind": "rect", "rect": [50, 10, -20, 30], "fill": "#0000ff"}), + json!({"kind": "roundedRect", "rect": [50, 10, -20, 30], "radii": 5, "fill": "#0000ff"}), + json!({"kind": "rect", "rect": [50, 40, -20, -30], "fill": "#0000ff"}), + ] { + let r = s.execute("shape.create", params.clone()).unwrap(); + assert_eq!(r["bounds"], ell, "{params} at {depth}-bit"); + let sh = shape(&s, r["layer"].as_u64().unwrap()); + let c = sh.cache.as_ref().unwrap(); + let fmt = c.format(); + let alpha = |x: i32, y: i32| photocraft_raster::to_rgba(&fmt, &c.read_region(photocraft_doc::Rect::from_xywh(x, y, 1, 1)))[3]; + assert!(alpha(40, 25) > 0.99, "{params} at {depth}-bit"); + assert!(alpha(60, 25) < 0.01, "{params} at {depth}-bit"); + } + } +} + /// Two overlapping 40×40 rectangle shape layers ("Rectangle 1" black below, "Rectangle 2" grey /// above, overlapping by 20×20) with both selected, like the Layers panel in #2400. fn two_selected_rects(depth: u32) -> (Session, u64, u64) { diff --git a/crates/vector/src/shapes.rs b/crates/vector/src/shapes.rs index a459ba816e..b154a47325 100644 --- a/crates/vector/src/shapes.rs +++ b/crates/vector/src/shapes.rs @@ -29,9 +29,11 @@ pub fn clamp_radii(w: f64, h: f64, radii: [f64; 4]) -> [f64; 4] { r.map(|v| v * f) } -/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. +/// Rectangle with corner radii `[top-left, top-right, bottom-right, bottom-left]`. A negative +/// width or height extends from the origin to the left or up, like [`ellipse`] and [`polygon`]. pub fn rounded_rect(x: f64, y: f64, w: f64, h: f64, radii: [f64; 4]) -> Path { - let (w, h) = (w.abs(), h.abs()); + let (x, w) = if w < 0.0 { (x + w, -w) } else { (x, w) }; + let (y, h) = if h < 0.0 { (y + h, -h) } else { (y, h) }; let [tl, tr, br, bl] = clamp_radii(w, h, radii); if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 { return rect(x, y, w, h); @@ -143,6 +145,20 @@ mod tests { assert_eq!(clamp_radii(10.0, 10.0, [10.0; 4]), [5.0; 4]); } + #[test] + fn negative_size_rect_extends_from_its_origin_like_the_ellipse() { + // Issue #4203: a negative width/height drew the rectangle on the far side of its origin. + let want = ellipse(50.0, 10.0, -20.0, 30.0).control_bounds().unwrap(); + let want_neg_h = ellipse(50.0, 40.0, -20.0, -30.0).control_bounds().unwrap(); + for radii in [[0.0; 4], [5.0; 4]] { + let b = rounded_rect(50.0, 10.0, -20.0, 30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want, "radii {radii:?}"); + assert_eq!(b, (30.0, 10.0, 50.0, 40.0), "radii {radii:?}"); + let b = rounded_rect(50.0, 40.0, -20.0, -30.0, radii).control_bounds().unwrap(); + assert_eq!(b, want_neg_h, "radii {radii:?}"); + } + } + #[test] fn star_vertices() { assert_eq!(polygon(0.0, 0.0, 10.0, 10.0, 5, 0.5).subpaths[0].knots.len(), 10); From a0e9ee213d86396601075e222988cfdb565ccb7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:41 +0200 Subject: [PATCH 262/523] Cryptomatte: decode the .blue/.alpha rank of every channel set (#4208) (#4209) Each Cryptomatte RGBA set stores two (ID, coverage) ranks, (red, green) and (blue, alpha), but only red/green were read, so every second rank was lost and mattes got holes at edges and overlaps. Suffixes were also matched case-sensitively, missing Blender's .R/.G/.B/.A. Stream channels now carry a rank (0 red/green, 1 blue/alpha), suffixes match in any case, and decode stores each channel at slot*2+rank (bounds-checked), still sorted by coverage. The allocation check counts four channels per set. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/codecs/src/codecs/exr_cryptomatte.rs | 36 +++++++++------ crates/codecs/tests/exr_cryptomatte.rs | 50 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/codecs/src/codecs/exr_cryptomatte.rs b/crates/codecs/src/codecs/exr_cryptomatte.rs index 2fabd0c6c5..5f13f98621 100644 --- a/crates/codecs/src/codecs/exr_cryptomatte.rs +++ b/crates/codecs/src/codecs/exr_cryptomatte.rs @@ -1,7 +1,8 @@ //! Cryptomatte (specification 1.2): per-pixel object/material IDs with coverage in EXR //! channels, as written by Arnold, V-Ray, Redshift, Mantra/Karma and Cycles. Layers are //! announced by `cryptomatte//name|hash|conversion|manifest` header attributes; the -//! samples live in `NN.red` (ID) / `NN.green` (coverage) channel pairs. +//! samples live in `NN` RGBA channel sets, each holding two (ID, coverage) ranks: +//! `.red`/`.green` (rank 2·NN) and `.blue`/`.alpha` (rank 2·NN+1). //! //! IDs are the float *values*: MurmurHash3 x86-32 (seed 0) of the object name, its //! exponent fixed away from 0/255 so no ID is denormal, infinite or NaN, read as an f32 @@ -20,21 +21,26 @@ use crate::options::Limits; const F: Format = Format::OpenExr; /// A Cryptomatte stream channel (`crypto_asset00.red`) splits into its stream name -/// (`crypto_asset00`), the layer prefix without digits (`crypto_asset`), the stream index, -/// and whether it is the coverage (green) channel instead of the ID (red). +/// (`crypto_asset00`), the stream index, whether it is a coverage channel (green/alpha) +/// instead of an ID (red/blue), and its rank within the set (0 for red/green, 1 for +/// blue/alpha). pub(crate) struct StreamChannel { pub stream: String, pub index: u32, pub coverage: bool, + pub rank: usize, } /// `NN` where NN is one or more digits (the reference readers match `\d+`), with a -/// `.red`/`.r` (ID) or `.green`/`.g` (coverage) suffix. +/// `.red`/`.r` or `.blue`/`.b` (ID) or `.green`/`.g` or `.alpha`/`.a` (coverage) suffix, +/// in any case (Blender writes `.R`/`.G`/`.B`/`.A`). pub(crate) fn split_stream(channel: &str) -> Option { let (stream, suffix) = channel.rsplit_once('.')?; - let coverage = match suffix { - "red" | "r" => false, - "green" | "g" => true, + let (coverage, rank) = match suffix.to_ascii_lowercase().as_str() { + "red" | "r" => (false, 0), + "green" | "g" => (true, 0), + "blue" | "b" => (false, 1), + "alpha" | "a" => (true, 1), _ => return None, }; let digits = stream.chars().rev().take_while(|c| c.is_ascii_digit()).count(); @@ -43,7 +49,7 @@ pub(crate) fn split_stream(channel: &str) -> Option { } let split = stream.len() - digits; let index = stream[split..].parse().ok()?; - Some(StreamChannel { stream: stream.to_string(), index, coverage }) + Some(StreamChannel { stream: stream.to_string(), index, coverage, rank }) } /// MurmurHash3, x86-32 bit variant, seed 0 (public-domain algorithm, Austin Appleby). @@ -210,14 +216,17 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< let npx = w.checked_mul(h).ok_or_else(|| CodecError::malformed(F, "the layer is too large"))?; let too_large = || CodecError::malformed(F, "the layer is too large"); let (w32, h32) = (u32::try_from(w).map_err(|_| too_large())?, u32::try_from(h).map_err(|_| too_large())?); - // Each pixel also gets a pair list, so count its header with the channel samples. - let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(8).saturating_add(std::mem::size_of::>() as u64); + // Each pixel also gets a pair list, so count its header with the channel samples (four + // f32 channels per set: two ranks of ID and coverage). + let per_pixel = (layer.channels.len().max(1) as u64).saturating_mul(16).saturating_add(std::mem::size_of::>() as u64); limits.check_bytes(w32, h32, per_pixel)?; let ex_layer = super::exr::read_layer(bytes, layer.part)?; - let mut ids: Vec> = vec![Vec::new(); layer.channels.len()]; - let mut coverages: Vec> = vec![Vec::new(); layer.channels.len()]; + // Two (ID, coverage) ranks per set: rank `slot * 2 + stream.rank`. + let ranks = layer.channels.len().saturating_mul(2); + let mut ids: Vec> = vec![Vec::new(); ranks]; + let mut coverages: Vec> = vec![Vec::new(); ranks]; for c in &ex_layer.channel_data.list { let Some(stream) = split_stream(&c.name.to_string()) else { continue }; let Some(slot) = layer.channels.iter().position(|s| *s == stream.stream) else { continue }; @@ -226,7 +235,8 @@ pub(crate) fn decode(bytes: &[u8], layer_name: &str, limits: &Limits) -> Result< return Err(CodecError::malformed(F, "a Cryptomatte channel is smaller than its layer")); } let bucket = if stream.coverage { &mut coverages } else { &mut ids }; - bucket[slot] = values; + let Some(dst) = slot.checked_mul(2).and_then(|r| r.checked_add(stream.rank)).and_then(|r| bucket.get_mut(r)) else { continue }; + *dst = values; } let mut pixels = vec![Vec::new(); npx]; diff --git a/crates/codecs/tests/exr_cryptomatte.rs b/crates/codecs/tests/exr_cryptomatte.rs index f5f76c3adb..28ad71e191 100644 --- a/crates/codecs/tests/exr_cryptomatte.rs +++ b/crates/codecs/tests/exr_cryptomatte.rs @@ -164,6 +164,56 @@ fn short_channel_suffixes_decode_too() { assert_eq!(buf.at(3, 3), &[(cube, 1.0)]); } +/// A 4x4 layer `crypto` of one RGBA set `crypto00.///` with the given suffixes: +/// rank 0 (red/green) holds `cube` at 0.6 everywhere, rank 1 (blue/alpha) holds `sphere` +/// at 0.4 everywhere, the second (ID, coverage) pair the specification stores per set. +fn cryptomatte_file_rgba(suffixes: [&str; 4]) -> Vec { + let (w, h) = (4usize, 4usize); + let [r, g, b, a] = suffixes; + let mk = |s: &str, v: f32| AnyChannel::new(format!("crypto00.{s}").as_str(), FlatSamples::F32(vec![v; w * h])); + let list = vec![mk(r, cryptomatte_id("cube")), mk(g, 0.6), mk(b, cryptomatte_id("sphere")), mk(a, 0.4)]; + let key = cryptomatte_key("crypto"); + let mut attrs = LayerAttributes::named("crypto"); + attrs.other.insert(text(&format!("cryptomatte/{key}/name")), AttributeValue::Text(text("crypto"))); + let layer = Layer::new( + (w, h), + attrs, + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = exr::image::Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + let mut out = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut out).expect("synthetic cryptomatte exr writes"); + out.into_inner() +} + +#[test] +fn blue_alpha_rank_of_a_set_decodes_too() { + // Each RGBA set holds two ranks: (red, green) and (blue, alpha). Both must come back. + let bytes = cryptomatte_file_rgba(["red", "green", "blue", "alpha"]); + let found = cryptomatte_layers(&bytes, &Limits::default()).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].channels, ["crypto00"], "one set, listed once"); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + for y in 0..4 { + for x in 0..4 { + assert_eq!(buf.at(x, y), &[(cube, 0.6), (sphere, 0.4)], "({x}, {y})"); + } + } +} + +#[test] +fn uppercase_channel_suffixes_decode_both_ranks() { + // Blender and others write `.R/.G/.B/.A`; suffixes match regardless of case. + for suffixes in [["R", "G", "B", "A"], ["Red", "Green", "Blue", "Alpha"], ["r", "g", "b", "a"]] { + let bytes = cryptomatte_file_rgba(suffixes); + let buf = decode_cryptomatte(&bytes, "crypto", &Limits::default()).unwrap(); + let (cube, sphere) = (cryptomatte_id("cube"), cryptomatte_id("sphere")); + assert_eq!(buf.at(2, 1), &[(cube, 0.6), (sphere, 0.4)], "{suffixes:?}"); + } +} + #[test] fn unknown_layer_name_is_unsupported() { let bytes = cryptomatte_file("crypto_asset"); From 8029b061af2e8be76975fb7828cd0ef19b2c4c85 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:45 +0200 Subject: [PATCH 263/523] EXR: an unnamed part can be opened by the name the part list gives it (#4210) (#4211) file.exrParts lists a part without a name attribute as "part ", but file.openExrParts only matched parts whose name attribute equalled the requested name, so "part/part 0" failed with "no flat part named part 0". One helper now gives the display name for the listing, the lookup and the layer name; a named part still wins over an unnamed one with the same fallback name. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/exr_cmds.rs | 40 +++++++++++++++++++++++++++++++++++ crates/io/src/exr_parts.rs | 20 ++++++++++++++---- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/exr_cmds.rs b/crates/engine/src/exr_cmds.rs index c0a3310f9f..908942d4fd 100644 --- a/crates/engine/src/exr_cmds.rs +++ b/crates/engine/src/exr_cmds.rs @@ -210,6 +210,46 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + /// A single-part EXR with no part name (the usual render output) plus an AOV group. + fn write_unnamed_grouped(path: &std::path::Path) { + use exr::prelude::*; + let (w, h) = (4usize, 4usize); + let mk = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let list = vec![mk("R", 0.7), mk("G", 0.7), mk("B", 0.7), mk("diffuse.R", 0.3), mk("diffuse.G", 0.3), mk("diffuse.B", 0.3)]; + let layer = Layer::new( + (w, h), + LayerAttributes::default(), + Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }, + AnyChannels::sort(list.into_iter().collect()), + ); + let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]); + image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture"); + } + + #[test] + fn an_unnamed_part_opens_by_the_name_the_listing_gives_it() { + // #4210: the listing names an unnamed part "part 0"; feeding that name back must open it. + let dir = std::env::temp_dir().join(format!("pcraft-exr-unnamed-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("unnamed.exr"); + write_unnamed_grouped(&path); + let info = entries_json(path.to_str().unwrap()).unwrap(); + let part = info.as_array().unwrap().iter().find(|e| e["kind"] == "part").expect("a part entry"); + let name = part["name"].as_str().unwrap(); + assert_eq!(name, "part 0", "{info}"); + let key = format!("part/{name}"); + let mut s = session(); + s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), key: true})).unwrap(); + let doc = &s.active().unwrap().doc; + assert_eq!(doc.layers.len(), 1); + assert_eq!(doc.layers[0].name, "part 0"); + if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content { + let px = surface.pixel(0, 0); + assert!((px[0] - 0.7).abs() < 1e-4, "{:?}", px); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() { let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id())); diff --git a/crates/io/src/exr_parts.rs b/crates/io/src/exr_parts.rs index 93b8e85f36..5474d00310 100644 --- a/crates/io/src/exr_parts.rs +++ b/crates/io/src/exr_parts.rs @@ -98,6 +98,12 @@ fn channel_base_split(name: &str) -> Option<(&str, &str)> { (!prefix.is_empty()).then_some((prefix, base)) } +/// The name a part is listed, selected and imported as: its `name` attribute, or +/// `part ` when it has none (a single-part render usually carries no name). +fn part_display_name(part: &codecs::ExrPartInfo) -> String { + part.name.clone().unwrap_or_else(|| format!("part {}", part.index)) +} + /// Lists the entries of a multi-part or channel-grouped EXR for a chooser: flat parts in /// file order, then the channel groups, then the Cryptomatte layers as information. A file /// whose parts are all deep is refused (the deep decoder composites those). @@ -113,7 +119,7 @@ pub fn entries(bytes: &[u8]) -> Result, IoError> { } out.push(Entry { kind: EntryKind::Part, - name: part.name.clone().unwrap_or_else(|| format!("part {}", part.index)), + name: part_display_name(part), view: part.view.clone(), width: part.width, height: part.height, @@ -186,8 +192,14 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result| view.as_ref().is_none_or(|v| sel.view.eq_ignore_ascii_case("both") || v.eq_ignore_ascii_case(&sel.view)); - let part_by_name = - |want: &str| info.iter().find(|p| !p.deep && p.name.as_deref() == Some(want)).ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))); + // A named part wins over an unnamed one listed under the same fallback name ("part 0"). + let part_by_name = |want: &str| { + let flat = || info.iter().filter(|p| !p.deep); + flat() + .find(|p| p.name.as_deref() == Some(want)) + .or_else(|| flat().find(|p| p.name.is_none() && part_display_name(p) == want)) + .ok_or_else(|| IoError::Unsupported(format!("no flat part named {want}"))) + }; let mut chosen_parts: Vec<&codecs::ExrPartInfo> = Vec::new(); for want in &sel.parts { let p = part_by_name(want)?; @@ -235,7 +247,7 @@ pub fn import(name: &str, bytes: &[u8], sel: &Selection) -> Result Date: Tue, 13 Oct 2026 03:17:49 +0200 Subject: [PATCH 264/523] Bitmap mode: validate the halftone screen angle (#4214) (#4215) Image > Mode > Bitmap (halftone) passed its angle through unchecked: 1e39 became inf as f32, the screen rotation turned it into NaN and an almost white bitmap was recorded in history; 500 was accepted although the params doc (and Photoshop's Halftone Screen dialog) says -180..180. The angle must now be a finite number in -180..=180 and the frequency a finite number (still clamped to 1..999 lines/inch); otherwise the command returns BadParams before touching the document. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/engine/src/mode_cmds.rs | 17 +++++++++++++-- crates/engine/src/mode_cmds/tests.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index 319a6e5334..fe22a192c7 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -27,6 +27,14 @@ fn num(p: &Value, key: &str, default: f32) -> f32 { p.get(key).and_then(Value::as_f64).map_or(default, |v| v as f32) } +/// A finite number param, `default` when absent or null; anything else is a bad-params error. +fn finite_num(p: &Value, cmd: &str, key: &str, default: f64) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number"))), + } +} + fn str_or<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str { p.get(key).and_then(Value::as_str).unwrap_or(default) } @@ -388,9 +396,14 @@ fn bitmap(s: &mut Session, p: &Value) -> Result { "pattern" => BitmapMethod::Pattern, "diffusion" => BitmapMethod::Diffusion, "halftone" => { - let freq = num(p, "frequency", 53.0).clamp(1.0, 999.0); + // Photoshop's Halftone Screen: frequency 1..999 lines/inch, angle -180..180 degrees. + let freq = finite_num(p, CMD, "frequency", 53.0)?.clamp(1.0, 999.0) as f32; + let angle = finite_num(p, CMD, "angle", 45.0)?; + if !(-180.0..=180.0).contains(&angle) { + return Err(bad(CMD, format!("angle {angle} is outside -180..180"))); + } let shape = HalftoneShape::from_id(str_or(p, "shape", "round")).ok_or_else(|| bad(CMD, "shape: round|ellipse|line|square|diamond|cross"))?; - BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: num(p, "angle", 45.0), shape } + BitmapMethod::Halftone { cell: (dpi / freq).max(2.0), angle: angle as f32, shape } } m => { return Err(bad(CMD, format!("method `{m}` (threshold|pattern|diffusion|halftone)"))); diff --git a/crates/engine/src/mode_cmds/tests.rs b/crates/engine/src/mode_cmds/tests.rs index 8ac4e6d332..d6d020699e 100644 --- a/crates/engine/src/mode_cmds/tests.rs +++ b/crates/engine/src/mode_cmds/tests.rs @@ -291,6 +291,38 @@ fn bitmap_methods_need_grayscale() { } } +#[test] +fn bitmap_halftone_rejects_bad_angles_and_frequencies_without_editing_the_document() { + // Photoshop's Halftone Screen takes an angle in -180..180 degrees (#4214): 1e39 used to become + // inf, then NaN in the screen rotation, and an almost white bitmap landed in history. + let mut s = session(16, 16, 8, "grayscale"); + let before = s.active().unwrap().history.past_len(); + let bad = [ + ("angle", json!(1e39)), + ("angle", json!(500)), + ("angle", json!(-181)), + ("angle", json!(180.01)), + ("angle", json!("x")), + ("frequency", json!("x")), + ("frequency", json!(true)), + ]; + for (key, value) in bad { + let mut p = json!({"method": "halftone"}); + p[key] = value.clone(); + assert!(s.execute("image.mode.bitmap", p).is_err(), "{key}: {value}"); + assert_eq!(s.active().unwrap().history.past_len(), before, "{key}: {value}"); + assert_eq!(doc(&s).mode, ColorMode::Grayscale, "{key}: {value}"); + } + for angle in [-180.0, 30.0, 180.0] { + s.execute("image.mode.bitmap", json!({"method": "halftone", "angle": angle})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap, "{angle}"); + s.execute("edit.undo", json!({})).unwrap(); + } + // Frequency stays clamped to the documented 1..999 lines/inch. + s.execute("image.mode.bitmap", json!({"method": "halftone", "frequency": 1e39})).unwrap(); + assert_eq!(doc(&s).mode, ColorMode::Bitmap); +} + #[test] fn duotone_inks_and_display() { let mut s = session(16, 8, 8, "gray"); From 95663451c06e846d87d122766193ed3329db3dfd Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 13 Oct 2026 03:17:54 +0200 Subject: [PATCH 265/523] Find Edges and Solarize: treat CMYK values as ink (#4222) (#4223) CMYK samples are ink (1 = full ink), but both filters assumed light polarity. Find Edges wrote 1 - edge, so flat CMYK areas became 100% ink (black) instead of white; Solarize inverted channels above 50%, i.e. the shadows. Both now use fxutil::subtractive(ctx), like Trace Contour: Find Edges writes the edge strength as ink, Solarize inverts channels below 50% ink. Alpha stays untouched. RGB, Grayscale, Lab and other modes are unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- crates/algo/src/lib.rs | 2 +- crates/algo/src/stylize.rs | 18 ++++++++++++++++-- crates/algo/src/tests.rs | 24 ++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index fdbe6dd72b..cdd73c0091 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -740,7 +740,7 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec stylize::mosaic(src, out, ctx, *cell_size), FilterParams::Emboss { angle, height, amount } => stylize::emboss(src, out, ctx, *angle, *height, *amount), FilterParams::FindEdges => stylize::find_edges(src, out, ctx), - FilterParams::Solarize => stylize::per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }), + FilterParams::Solarize => stylize::solarize(src, out, ctx), FilterParams::Invert => stylize::per_pixel(src, out, ctx, |v| 1.0 - v), FilterParams::Desaturate => stylize::desaturate(src, out, ctx), FilterParams::Twirl { angle } => distort::twirl(src, out, ctx, *angle), diff --git a/crates/algo/src/stylize.rs b/crates/algo/src/stylize.rs index 2a88c6ed6e..750923d1a9 100644 --- a/crates/algo/src/stylize.rs +++ b/crates/algo/src/stylize.rs @@ -5,6 +5,7 @@ use photocraft_geom::Rect; use photocraft_raster::{from_rgba, to_rgba}; use crate::Ctx; +use crate::fxutil::subtractive; use crate::image::Image; fn fmt(ctx: &Ctx) -> PixelFormat { @@ -23,6 +24,16 @@ fn set_gray(ctx: &Ctx, px: &mut [f32], g: f32) { px.copy_from_slice(&v[..px.len()]); } +/// Solarize: inverts the highlights. CMYK samples are ink, so there the +/// channels below 50% ink (the light ones) invert. +pub(crate) fn solarize(src: &Image, out: Rect, ctx: &Ctx) -> Vec { + if subtractive(ctx) { + per_pixel(src, out, ctx, |v| if v < 0.5 { 1.0 - v } else { v }) + } else { + per_pixel(src, out, ctx, |v| if v > 0.5 { 1.0 - v } else { v }) + } +} + /// Colour channels through `f` (alpha untouched). pub(crate) fn per_pixel(src: &Image, out: Rect, ctx: &Ctx, f: impl Fn(f32) -> f32) -> Vec { let n = src.ch; @@ -167,10 +178,12 @@ pub(crate) fn emboss(src: &Image, out: Rect, ctx: &Ctx, angle: f32, height: f32, res } -/// Find Edges: Sobel magnitude per colour channel, dark edges on white. +/// Find Edges: Sobel magnitude per colour channel, dark edges on white +/// (CMYK samples are ink, so there the edge strength is the ink itself). pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let n = src.ch; let cc = if ctx.alpha { n - 1 } else { n }; + let sub = subtractive(ctx); let mut res = src.crop(out); let w = out.width() as usize; for (i, px) in res.chunks_exact_mut(n).enumerate() { @@ -179,7 +192,8 @@ pub(crate) fn find_edges(src: &Image, out: Rect, ctx: &Ctx) -> Vec { let g = |dx: i32, dy: i32| src.get(x + dx, y + dy, c); let gx = (g(1, -1) + 2.0 * g(1, 0) + g(1, 1)) - (g(-1, -1) + 2.0 * g(-1, 0) + g(-1, 1)); let gy = (g(-1, 1) + 2.0 * g(0, 1) + g(1, 1)) - (g(-1, -1) + 2.0 * g(0, -1) + g(1, -1)); - *v = (1.0 - (gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + let e = ((gx * gx + gy * gy).sqrt() / 4.0).clamp(0.0, 1.0); + *v = if sub { e } else { 1.0 - e }; } } res diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index 763cc0bcee..4ef09470fb 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -367,6 +367,30 @@ fn stylize_basics() { assert!((d[0] - d[1]).abs() < 1e-6 && (d[1] - d[2]).abs() < 1e-6); } +/// CMYK samples are ink (1 = full ink): Find Edges leaves flat areas white (no ink, not 100% ink) +/// and Solarize inverts the channels below 50% ink, as Photoshop does (#4222). Alpha is untouched. +#[test] +fn find_edges_and_solarize_treat_cmyk_as_ink() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let f = PixelFormat::new(ColorMode::Cmyk, depth, true); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.1, 0.1, 0.1, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..4].iter().all(|v| v.abs() < 1e-6) && (e[4] - 1.0).abs() < 1e-6, "{depth:?} flat CMYK -> white: {e:?}"); + let mut s = Surface::new(f); + s.fill_rect(R, &[0.1, 0.2, 0.8, 0.9, 0.6]); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + let want = [0.9, 0.8, 0.8, 0.9, 0.6]; + assert!(so.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{depth:?} solarize CMYK: {so:?}"); + } + // RGB keeps the light-polarity behaviour: flat -> white, channels above 50% invert. + let s = flat(SampleType::U8, R, [0.1, 0.2, 0.8, 1.0]); + let e = run(&s, &FilterParams::FindEdges).pixel(20, 15); + assert!(e[..3].iter().all(|v| (v - 1.0).abs() < 1e-6), "{e:?}"); + let so = run(&s, &FilterParams::Solarize).pixel(3, 3); + assert!(so.iter().zip([0.1, 0.2, 0.2, 1.0]).all(|(a, b)| (a - b).abs() < 0.01), "{so:?}"); +} + #[test] fn distortions_are_identity_at_zero() { let s = pattern(SampleType::U8, R); From ab828712712f638c84de877cd5a89877f6c10fbf Mon Sep 17 00:00:00 2001 From: Alon Date: Tue, 13 Oct 2026 04:17:57 +0300 Subject: [PATCH 266/523] fix(compose): paint interior effects over the layer's blend mode (#3996) (#4231) Co-authored-by: Claude Opus 5.5 (1M context) --- crates/compose/src/advanced_tests.rs | 20 ++++++++++++++++++++ crates/compose/src/effects.rs | 23 +++++++++++++++++++---- crates/gpu/src/plan.rs | 18 ++++++++++++++++-- crates/gpu/tests/parity.rs | 1 + 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 0ca3bdadb9..9a8723c41d 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -246,6 +246,26 @@ fn blend_interior_effects_as_group_puts_overlays_under_fill_opacity() { } } +#[test] +fn interior_effects_blend_over_the_layer_mode_unless_grouped() { + // #3996: #888888 under a black Color-mode layer with a red Overlay colour overlay. The + // layer's mode applies to its own pixels (grey stays grey), then the overlay blends onto + // that result and tints it; as a group the overlay of black is black, so grey remains. + let grey = 136.0 / 255.0; + let lift = 1.0 - 2.0 * (1.0 - grey); + for depth in DEPTHS { + let mut d = doc(depth, false); + d.layers.push(solid(&d, "grey", full(&d), [grey, grey, grey, 1.0])); + let mut l = solid(&d, "black", full(&d), [0.0, 0.0, 0.0, 1.0]); + l.blend = BlendMode::Color; + l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Overlay, 1.0), color: Color::rgb(1.0, 0.0, 0.0) }]; + d.layers.push(l); + check(&d, 2, 2, [1.0, lift, lift, 1.0], "default: overlay over the Color blend"); + d.layers[1].advanced.blend_interior = true; + check(&d, 2, 2, [grey, grey, grey, 1.0], "as group: overlay inside the Color blend"); + } +} + #[test] fn transparency_shapes_off_spreads_effects_over_the_whole_layer() { for depth in DEPTHS { diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 05bd1f10da..33af1a08b6 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -4,8 +4,9 @@ //! drop shadows, outer glows, outer bevel, then the layer itself (content at //! fill opacity) with pattern/gradient/colour overlays, satin, inner glows, //! inner shadows, strokes and the inner bevel on top. Exterior effects blend -//! straight into the backdrop with their own modes; the layer and its -//! interior effects blend with the layer's mode. Emboss and pillow emboss then +//! straight into the backdrop with their own modes; the layer blends with its +//! mode and the interior effects paint over the result, or blend with it as a +//! group (Blend Interior Effects as Group, [`premerges_layer`]). Emboss and pillow emboss then //! shade the composited result (inside and outside halves). Layer opacity //! applies to the whole stack; fill opacity only to the layer's own pixels. //! @@ -1791,6 +1792,14 @@ fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) { } } +/// Blend Interior Effects as Group off (Photoshop's default): the layer's mode applies to its +/// own pixels only, and the interior effects paint over the blended result. The layer is merged +/// into the backdrop before they paint, so an Overlay colour overlay on a black Color-mode layer +/// tints the grey beneath it (#3996). Normal-like modes give the same result either way. +pub fn premerges_layer(layer: &Layer) -> bool { + !layer.advanced.blend_interior && !matches!(layer.blend, BlendMode::Normal | BlendMode::PassThrough | BlendMode::Dissolve) +} + /// Composites `content` (the layer's own pixels over `big`, alpha already /// masked, clipped layers applied) plus its effects into `backdrop`. pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) { @@ -1889,6 +1898,13 @@ pub(crate) fn composite_with_effects_prepared( } }; let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() }; + let gamma = crate::text_gamma(layer); + let premerge = premerges_layer(layer); + if premerge { + for (lp, wp) in lay.px.iter_mut().zip(&work.px) { + *lp = psblend::composite_gamma(layer.blend, *wp, *lp, 1.0, gamma); + } + } let rel = |m: Map| -> Map { let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect(); Map { w: m.w, h: m.h, v } @@ -2051,8 +2067,7 @@ pub(crate) fn composite_with_effects_prepared( } // Layer (with interior effects) onto the exterior result, in the layer's mode. - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; - let gamma = crate::text_gamma(layer); + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; for (wp, lp) in work.px.iter_mut().zip(&lay.px) { if lp[3] > 0.0 { *wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma); diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs index fb135dc135..52c36f14d9 100644 --- a/crates/gpu/src/plan.rs +++ b/crates/gpu/src/plan.rs @@ -9,7 +9,8 @@ //! effect maps (shadow / glow / satin / bevel coverage, stroke bands) are built once per layer //! state by [`crate::fx`] and sampled here; exterior effects paint into a copy of the backdrop, //! the layer at fill opacity takes the interior effects, and the two merge with the layer's mode -//! and opacity. Effect passes are clipped to the layer's effect region and the result is copied +//! (or the layer merges first and the effects paint over it: `effects::premerges_layer`) and +//! opacity. Effect passes are clipped to the layer's effect region and the result is copied //! back into the backdrop in place, so a small text layer costs only its own pixels. use photocraft_color::BlendMode; @@ -1095,6 +1096,19 @@ impl<'a> Planner<'a> { } else { init(self, lay_src, None, INIT_INSIDE, layer.fill_opacity) }; + // Blend Interior Effects as Group off: the layer merges in its mode first and the + // interior effects paint over the result (`effects::premerges_layer`). + let premerge = photocraft_compose::effects::premerges_layer(layer); + if premerge { + let mut p = Pass::new(Kernel::Blend, 0); + p.a = Some(self.retain(w)); + p.b = Some(l); + p.mode = layer.blend; + p.flags = gamma_flag(layer); + p.extra[3] = photocraft_compose::text_gamma(layer); + p.clip = Some(clip); + l = self.emit(p); + } for &(_, e) in &rev { if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e && let Some(pat) = photocraft_doc::pattern::find(self.cx.patterns, id, name).filter(|p| p.is_paintable()) @@ -1257,7 +1271,7 @@ impl<'a> Planner<'a> { } } - let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; + let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend }; let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev .iter() .filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None }) diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 47837a8b65..2bf8bd82b3 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1461,6 +1461,7 @@ fn layer_effects_opacity_fill_blend_and_off_canvas() { (0.6, 0.35, BlendMode::Multiply), (1.0, 0.5, BlendMode::Screen), (0.8, 1.0, BlendMode::Dissolve), + (1.0, 1.0, BlendMode::Color), ] { let mut d = fx_doc(96, 80, SampleType::U8); let mut l = blob("fx", d.pixel_format(), 46.0, 40.0, 22.0, [0.2, 0.6, 0.9]); From 97d06b240b36f88794d09f6c455ac2f7c6c9438d Mon Sep 17 00:00:00 2001 From: Sir Starch Date: Tue, 13 Oct 2026 03:24:09 +0200 Subject: [PATCH 267/523] feat(ui): add hamburger menu to the Brush Settings panel (#4272) (#4358) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/brush_panel.rs | 101 ++++++++++++++++++++++++ crates/ui-egui/src/brush_panel_tests.rs | 90 +++++++++++++++++++++ crates/ui-egui/src/dock.rs | 4 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panel_docking.rs | 4 + crates/ui-egui/src/widgets.rs | 19 +++++ 24 files changed, 275 insertions(+) diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index 4cadb6b212..6ea83d10e9 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -409,6 +409,92 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) { commit_gesture(app, ui.ctx(), &before, &b); } +/// Clear brush controls: turns off every section except Smoothing and resets their options +/// to the defaults (Photoshop), keeping locked sections intact. +pub fn clear_brush_controls(current: &BrushSettings) -> BrushSettings { + let mut b = current.clone(); + let d = BrushSettings::default(); + let l = ¤t.locks; + if !l.shape_dynamics { + b.shape_dynamics = d.shape_dynamics; + } + if !l.scattering { + b.scattering = d.scattering; + } + if !l.texture { + b.texture = d.texture; + } + if !l.dual_brush { + b.dual_brush = d.dual_brush; + } + if !l.color_dynamics { + b.color_dynamics = d.color_dynamics; + } + if !l.transfer { + b.transfer = d.transfer; + } + if !l.pose { + b.pose = d.pose; + } + if !l.noise { + b.noise = d.noise; + } + if !l.wet_edges { + b.wet_edges = d.wet_edges; + } + if !l.build_up { + b.build_up = d.build_up; + b.build_up_rate = d.build_up_rate; + } + if !l.protect_texture { + b.protect_texture = d.protect_texture; + } + b +} + +/// Copy the current brush's Texture settings to other painting tools that have Texture enabled. +pub fn copy_texture_to_other_tools(app: &mut PhotocraftApp) { + let texture = app.session.tools.brush.texture.clone(); + for (_, brush) in &mut app.ui.tool_brushes { + if brush.texture.enabled { + brush.texture = texture.clone(); + } + } +} + +/// The Brush Settings panel menu actions (hamburger menu). +pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if ui.button(tl!("New Brush Preset…")).clicked() { + let name = new_preset_name(&app.session.tools.presets); + run_or_status(app, "brush.presets.save", json!({ "name": name })); + if app.session.tools.presets.iter().any(|p| p.name == name) { + app.ui.brushes_panel.renaming = Some(Renaming { group: false, name: name.clone(), folder: Vec::new(), text: String::new() }); + app.ui.brush_picker_list.renaming = Some(Renaming { group: false, name, folder: Vec::new(), text: String::new() }); + } + ui.close(); + } + ui.separator(); + if ui.button(tl!("Clear Brush Controls")).clicked() { + let before = app.session.tools.brush.clone(); + let cleared = clear_brush_controls(&before); + commit(app, &before, &cleared); + ui.close(); + } + let has_locks = app.session.tools.brush.locks != photocraft_paint::brush::SectionLocks::default(); + if ui.add_enabled(has_locks, egui::Button::new(tl!("Reset All Locked Settings"))).clicked() { + let before = app.session.tools.brush.clone(); + let mut after = before.clone(); + after.locks = photocraft_paint::brush::SectionLocks::default(); + commit(app, &before, &after); + ui.close(); + } + let has_texture = app.session.tools.brush.texture.enabled; + if ui.add_enabled(has_texture, egui::Button::new(tl!("Copy Texture to Other Tools"))).clicked() { + copy_texture_to_other_tools(app); + ui.close(); + } +} + pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if !app.ui.panels.brush_settings { return; @@ -442,6 +528,21 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) { if icons::button(ui, "x", 20.0, false, tl!("Close")).clicked() { open = false; } + let menu_btn = widgets::panel_menu_button(ui); + egui::Popup::menu(&menu_btn).show(|ui| { + widgets::style_spectrum_popup_menu(ui); + ui.set_min_width(180.0); + panel_menu(app, ui); + ui.separator(); + if ui.button(tl!("Close")).clicked() { + open = false; + ui.close(); + } + if ui.button(tl!("Close Tab Group")).clicked() { + open = false; + ui.close(); + } + }); }); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 6a2338295c..2e48b2abb9 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1176,6 +1176,96 @@ fn hand_tool_offers_scale_buttons_and_requests_the_right_view() { assert!(!h.state().ui.views[0].fit_pending); } +#[test] +fn brush_settings_hamburger_menu_actions() { + use egui_kittest::kittest::Queryable; + + // 1. Menu opens and offers Photoshop actions + let mut h = harness(0, 0); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + + assert!(h.query_all_by_label("New Brush Preset…").count() > 0); + assert!(h.query_all_by_label("Clear Brush Controls").count() > 0); + assert!(h.query_all_by_label("Reset All Locked Settings").count() > 0); + assert!(h.query_all_by_label("Copy Texture to Other Tools").count() > 0); + assert!(h.query_all_by_label("Close").count() > 0); + assert!(h.query_all_by_label("Close Tab Group").count() > 0); + + // 2. New Brush Preset… saves a preset + let count0 = h.state().session.tools.presets.len(); + h.get_by_label("New Brush Preset…").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.presets.len(), count0 + 1); + assert_eq!(last_journal(h.state()).unwrap().0, "brush.presets.save"); + + // 3. Clear Brush Controls resets dynamics, keeps locked sections and smoothing + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.size = 45.0; + h.state_mut().session.tools.brush.scattering.enabled = true; + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.locks.texture = true; + h.state_mut().session.tools.brush.smoothing.amount = 0.5; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Clear Brush Controls").click(); + h.run_steps(2); + + let b = &h.state().session.tools.brush; + assert!(!b.scattering.enabled, "unlocked scattering cleared"); + assert!(b.texture.enabled, "locked texture preserved"); + assert_eq!(b.smoothing.amount, 0.5, "smoothing preserved"); + assert_eq!(b.size, 45.0, "tip size preserved"); + + // 4. Reset All Locked Settings clears all locks + assert!(b.locks.texture); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Reset All Locked Settings").click(); + h.run_steps(2); + assert_eq!(h.state().session.tools.brush.locks, photocraft_paint::brush::SectionLocks::default()); + + // 5. Copy Texture to Other Tools copies texture to other tools with texture enabled + let mut h = harness(0, 0); + h.state_mut().session.tools.brush.texture.enabled = true; + h.state_mut().session.tools.brush.texture.scale = 2.5; + let mut pencil_brush = BrushSettings::default(); + pencil_brush.texture.enabled = true; + pencil_brush.texture.scale = 1.0; + h.state_mut().ui.tool_brushes = vec![(crate::state::Tool::Pencil, pencil_brush)]; + h.run_steps(1); + + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Copy Texture to Other Tools").click(); + h.run_steps(2); + + let pencil = h.state().ui.tool_brushes.iter().find(|(t, _)| *t == crate::state::Tool::Pencil).unwrap(); + assert_eq!(pencil.1.texture.scale, 2.5); + + // 6. Close closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + let close_buttons: Vec<_> = h.query_all_by_label("Close").collect(); + assert!(close_buttons.len() >= 2); + close_buttons.last().unwrap().click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); + + // 7. Close Tab Group closes the window + let mut h = harness(0, 0); + assert!(h.state().ui.panels.brush_settings); + h.get_by_label("Panel menu").click(); + h.run_steps(2); + h.get_by_label("Close Tab Group").click(); + h.run_steps(2); + assert!(!h.state().ui.panels.brush_settings); +} + #[test] fn brushes_button_at_top_of_brush_settings_opens_brushes_panel() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 4bd043da66..0aab2769ea 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -601,6 +601,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + if tabs.get(sel) == Some(&"Brush Settings") { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } if tabs.get(sel) == Some(&"Actions") { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 11438131d7..16c1d9d2d8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3666,6 +3666,9 @@ navigator Zoom Zvětšení Remove after each stroke Odstranit po každém tahu Apply the removal ({key}) Použít odstranění ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tahy k odstranění: {n} (Enter použije, Esc zruší) + Clear Brush Controls Obnovit ovládací prvky stopy + Reset All Locked Settings Obnovit všechna zamknutá nastavení + Copy Texture to Other Tools Kopírovat texturu do jiných nástrojů # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Načíst akce… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a9b676634b..25d19ff714 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2698,6 +2698,9 @@ curves Smooth Glätten Remove after each stroke Nach jedem Strich entfernen Apply the removal ({key}) Entfernen anwenden ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Striche zum Entfernen: {n} (Eingabe wendet an, Esc bricht ab) + Clear Brush Controls Pinselsteuerungen löschen + Reset All Locked Settings Alle gesperrten Einstellungen zurücksetzen + Copy Texture to Other Tools Struktur auf andere Werkzeuge kopieren # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Aktionen laden… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5c33da43ca..c840fbcb2c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3756,6 +3756,9 @@ curves Smooth Εξομάλυνση Remove after each stroke Αφαίρεση μετά από κάθε πινελιά Apply the removal ({key}) Εφαρμογή της αφαίρεσης ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Πινελιές προς αφαίρεση: {n} (Enter εφαρμόζει, Esc ακυρώνει) + Clear Brush Controls Απαλοιφή στοιχείων ελέγχου πινέλου + Reset All Locked Settings Επαναφορά όλων των κλειδωμένων ρυθμίσεων + Copy Texture to Other Tools Αντιγραφή υφής σε άλλα εργαλεία # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Φόρτωση ενεργειών… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e7f2385d5a..71f4d655e1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2739,6 +2739,9 @@ curves Smooth Suavizar Remove after each stroke Eliminar después de cada trazo Apply the removal ({key}) Aplicar la eliminación ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Trazos por eliminar: {n} (Intro aplica, Esc cancela) + Clear Brush Controls Borrar controles de pincel + Reset All Locked Settings Restaurar todos los ajustes bloqueados + Copy Texture to Other Tools Copiar textura en otras herramientas # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Cargar acciones… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6076c41b5a..7d9c500698 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3059,6 +3059,9 @@ curves Smooth Lisser Remove after each stroke Supprimer après chaque trait Apply the removal ({key}) Appliquer la suppression ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traits à supprimer : {n} (Entrée applique, Échap annule) + Clear Brush Controls Effacer les commandes de pinceau + Reset All Locked Settings Réinitialiser tous les paramètres verrouillés + Copy Texture to Other Tools Copier la texture dans d'autres outils # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Charger des actions… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a7dd7723b7..56e203fde0 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3972,6 +3972,9 @@ cameraRaw Yellow Sárga Transform On Drop Átalakítás elengedéskor Vanishing Point is not active Az Enyészpont nem aktív Vanishing Point: click four corners to create a plane Enyészpont: sík létrehozásához kattints a négy sarokra + Clear Brush Controls Ecsetvezérlők törlése + Reset All Locked Settings Összes zárolt beállítás visszaállítása + Copy Texture to Other Tools Textúra másolása más eszközökre # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Műveletek betöltése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bb35e8cba7..618687ff4b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3046,6 +3046,9 @@ curves Smooth Haluskan Remove after each stroke Hapus setelah setiap goresan Apply the removal ({key}) Terapkan penghapusan ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Goresan untuk dihapus: {n} (Enter menerapkan, Esc membatalkan) + Clear Brush Controls Hapus Kontrol Kuas + Reset All Locked Settings Atur Ulang Semua Pengaturan Terkunci + Copy Texture to Other Tools Salin Tekstur ke Alat Lain # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Muat Tindakan… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ca8e1cf717..5aa105c5fa 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4022,6 +4022,9 @@ navigator Zoom Zoom Remove after each stroke Rimuovi dopo ogni tratto Apply the removal ({key}) Applica la rimozione ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Tratti da rimuovere: {n} (Invio applica, Esc annulla) + Clear Brush Controls Cancella controlli pennello + Reset All Locked Settings Ripristina tutte le impostazioni bloccate + Copy Texture to Other Tools Copia texture su altri strumenti # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carica azioni… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7d0bf7dc47..eebc97e3ef 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3668,6 +3668,9 @@ curves Smooth スムーズ Remove after each stroke ストロークごとに削除 Apply the removal ({key}) 削除を適用 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 削除するストローク: {n}(Enter で適用、Esc で取り消し) + Clear Brush Controls ブラシのコントロールを消去 + Reset All Locked Settings ロックされた設定をすべて初期化 + Copy Texture to Other Tools テクスチャを他のツールにコピー # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… アクションを読み込み… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 135a872867..0e31d48937 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3228,6 +3228,9 @@ curves Smooth 매끄럽게 Remove after each stroke 획마다 제거 Apply the removal ({key}) 제거 적용 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 제거할 획: {n} (Enter 적용, Esc 취소) + Clear Brush Controls 브러시 제어 지우기 + Reset All Locked Settings 모든 잠긴 설정 재설정 + Copy Texture to Other Tools 다른 도구로 텍스처 복사 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 액션 불러오기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 6d8b52bf95..270ec89350 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3818,6 +3818,9 @@ curves Smooth Vloeiend maken Remove after each stroke Verwijderen na elke streek Apply the removal ({key}) Verwijdering toepassen ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Streken om te verwijderen: {n} (Enter past toe, Esc annuleert) + Clear Brush Controls Penseelbesturing wissen + Reset All Locked Settings Alle vergrendelde instellingen opnieuw instellen + Copy Texture to Other Tools Structuur kopiëren naar andere gereedschappen # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Handelingen laden… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7c89aa596d..39c1928ca4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3655,6 +3655,9 @@ curves Smooth Wygładź Remove after each stroke Usuwaj po każdym pociągnięciu Apply the removal ({key}) Zastosuj usuwanie ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Pociągnięcia do usunięcia: {n} (Enter stosuje, Esc anuluje) + Clear Brush Controls Wyczyść elementy sterujące pędzlem + Reset All Locked Settings Resetuj wszystkie zablokowane ustawienia + Copy Texture to Other Tools Kopiuj teksturę do innych narzędzi # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Wczytaj operacje… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index be6c0381f0..17247be9bf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3848,6 +3848,9 @@ layerLabel No Color Sem cor Remove after each stroke Remover após cada traço Apply the removal ({key}) Aplicar a remoção ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Traços a remover: {n} (Enter aplica, Esc cancela) + Clear Brush Controls Limpar controles de pincel + Reset All Locked Settings Redefinir todas as configurações bloqueadas + Copy Texture to Other Tools Copiar textura para outras ferramentas # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Carregar ações… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 125155cadf..0cd3f56b1c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3003,6 +3003,9 @@ curves Smooth Сгладить Remove after each stroke Удалять после каждого штриха Apply the removal ({key}) Применить удаление ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихов к удалению: {n} (Enter применяет, Esc отменяет) + Clear Brush Controls Очистить параметры кисти + Reset All Locked Settings Сбросить все заблокированные параметры + Copy Texture to Other Tools Скопировать текстуру в другие инструменты # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Загрузить операции… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e4d4ec9aed..1f69babad5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3784,6 +3784,9 @@ cameraData Lens Lens @plural {n} step|{n} steps {n} adım @plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+{n} fırça ön ayarı uyarısı daha) @plural {what} with {n} warning|{what} with {n} warnings {what}, {n} uyarı ile + Clear Brush Controls Fırça Denetimlerini Temizle + Reset All Locked Settings Tüm Kilitli Ayarları Sıfırla + Copy Texture to Other Tools Dokuyu Diğer Araçlara Kopyala # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Eylemleri Yükle… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 83f1250125..253ca6b82f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3756,6 +3756,9 @@ curves Smooth Згладити Remove after each stroke Видаляти після кожного штриха Apply the removal ({key}) Застосувати видалення ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Штрихів для видалення: {n} (Enter застосовує, Esc скасовує) + Clear Brush Controls Очистити параметри пензля + Reset All Locked Settings Скинути всі заблоковані параметри + Copy Texture to Other Tools Скопіювати текстуру в інші інструменти # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Завантажити операції… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 718278b5d2..1d0e15b6e2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3539,6 +3539,9 @@ curves Smooth Làm mượt Remove after each stroke Xóa sau mỗi nét vẽ Apply the removal ({key}) Áp dụng việc xóa ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) Nét vẽ cần xóa: {n} (Enter áp dụng, Esc hủy) + Clear Brush Controls Xóa điều khiển cọ + Reset All Locked Settings Đặt lại tất cả cài đặt đã khóa + Copy Texture to Other Tools Sao chép họa tiết sang các công cụ khác # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… Nạp hành động… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2d5a3d6bf..7b1862545c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2663,6 +2663,9 @@ curves Smooth 平滑 Remove after each stroke 每次描边后移除 Apply the removal ({key}) 应用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描边:{n}(Enter 应用,Esc 取消) + Clear Brush Controls 清除画笔控件 + Reset All Locked Settings 复位所有锁定的设置 + Copy Texture to Other Tools 将纹理复制到其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 载入动作… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8543251b0c..abcc5cb71b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2648,6 +2648,9 @@ curves Smooth 平滑 Remove after each stroke 每次描邊後移除 Apply the removal ({key}) 套用移除 ({key}) Strokes to remove: {n} (Enter applies, Esc cancels) 待移除的描邊:{n}(Enter 套用,Esc 取消) + Clear Brush Controls 清除筆刷控制項 + Reset All Locked Settings 重設所有鎖定的設定 + Copy Texture to Other Tools 複製紋理至其他工具 # Actions panel: Load/Save Actions (.atn) and kept Photoshop steps Load Actions… 載入動作… diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 4aff73c01e..7a1cb4df12 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -843,6 +843,10 @@ fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui, panel: &str) { crate::swatches_ui::panel_menu(app, ui); ui.separator(); } + "brushSettings" | "brush_settings" => { + crate::brush_panel::panel_menu(app, ui); + ui.separator(); + } "actions" => { crate::actions::panel_menu(app, ui); ui.separator(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8d57c51e75..1bb5ee601c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -220,6 +220,25 @@ fn pro_panel( CardResponse::new(strip_resp, mresp, tabs_out) } +/// The panel menu button (hamburger in Pro, ellipsis in Studio), matching the dock and cards. +pub fn panel_menu_button(ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let size = vec2(20.0, 20.0); + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + let tint = crate::icons::button_chrome(ui, rect, false, resp.hovered()); + if t.pro { + for k in 0..3 { + let y = rect.center().y - 3.5 + k as f32 * 3.5; + ui.painter().line_segment([pos2(rect.center().x - 5.0, y), pos2(rect.center().x + 5.0, y)], Stroke::new(1.0, tint)); + } + } else { + crate::icons::paint(ui, rect, "ellipsis", 14.0, tint); + } + let tip = tl!("Panel menu"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.on_hover_text(tip) +} + pub fn pill_tab(ui: &mut Ui, label: &str, selected: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); From 37ee4f5a204c2b9e7cbdfba37da79cb6af7425b4 Mon Sep 17 00:00:00 2001 From: Persi <69083413+persi-man@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:45 +0200 Subject: [PATCH 268/523] Simplify the raster stale-block filter for clippy's nonminimal_bool (#4314) Clippy 1.96 flags the `retain` predicate that strips stale content blocks from pixel layers on PSD export (`clippy::nonminimal_bool`), which breaks `cargo clippy -- -D warnings` on current stable. Rewrite it as a single negated disjunction. The four terms, their order and the short-circuiting are unchanged, so the same blocks are kept and dropped. --- crates/io/src/psd_export.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 38db080873..a17e783366 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -608,7 +608,7 @@ impl Ex { // pixel layers, so it stays. A vector mask (`vmsk`/`vsms`, `vogk`) stays. LayerContent::Raster(_) => { let masked = l.vector_mask.is_some(); - raw.retain(|(k, _)| !RASTER_STALE_KEYS.contains(&k) && !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd" && !(k == b"vstk" && masked)); + raw.retain(|(k, _)| !(RASTER_STALE_KEYS.contains(&k) || adjust_map::ADJUSTMENT_KEYS.contains(&k) || k == b"CgEd" || (k == b"vstk" && masked))); } LayerContent::Group(_) => {} } From 1412ac26e2d0257543caed8765a1522a4e94e768 Mon Sep 17 00:00:00 2001 From: Reynold Hu Date: Tue, 13 Oct 2026 09:26:47 +0800 Subject: [PATCH 269/523] Save for Web and Export As remember their settings across restarts (FILE-215-18) (#4184) * engine: remember the last Save for Web settings in the preferences * engine: test that a complete export remembers its settings for the next launch * ui: seed the Save for Web dialog from the remembered preferences * ui: Export As opens with the settings it was used with last * scorecard: FILE-215-18 done, the export dialogs remember their settings * docs: scorecard, File compatibility 7 done of 27 * Regenerate the scorecard Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/web_cmds.rs | 17 ++++++ crates/engine/src/web_cmds/tests.rs | 17 ++++++ crates/ui-egui/src/export_dialog.rs | 91 +++++++++++++++++++++++++++++ crates/ui-egui/src/file_ui.rs | 26 ++++++++- docs/scorecard.md | 2 +- scorecard/file_compat.toml | 2 +- 6 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/web_cmds.rs b/crates/engine/src/web_cmds.rs index cccddbf9f3..727e355902 100644 --- a/crates/engine/src/web_cmds.rs +++ b/crates/engine/src/web_cmds.rs @@ -837,6 +837,21 @@ pub fn save_for_web_with_writer(s: &mut Session, p: &Value, write: &mut dyn FnMu save_for_web_impl(s, p, write, false) } +/// Settings to seed the Save for Web dialog from on the next launch: `file_menu` is session +/// state, so a complete export also writes the preferences' remembered dialog settings (like +/// Fill, Liquify and the filter dialogs). The destination is per export, never remembered. +fn remember_web_settings(s: &mut Session, cmd: &str, settings: &Value) { + let mut m = settings.clone(); + if let Some(o) = m.as_object_mut() { + for k in ["path", "dir", "trustedSliceHtml"] { + o.remove(k); + } + } + s.prefs.edit(|p| { + p.dialogs.insert(cmd.to_string(), m); + }); +} + fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>, native_paths: bool) -> Result { let cmd = "file.export.saveForWebLegacy"; if let Some(authorize) = s.authorize { @@ -883,6 +898,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u if let Some(path) = single.filter(|_| dir.is_none() && (all.len() == 1 || p.get("slices").is_none())) { let o = optimize(&buf.px, bw, wdoc.bounds(), &st, icc, xmp.as_deref(), dpi, false)?; write(path, &o.bytes)?; + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); return Ok(json!({"files": [path], "bytes": o.bytes.len(), "width": o.width, "height": o.height, "colors": o.colors})); @@ -973,6 +989,7 @@ fn save_for_web_impl(s: &mut Session, p: &Value, write: &mut dyn FnMut(&str, &[u write(&web_derived_path(&dir, &format!("{base}.html"), native_paths)?, page.as_bytes())?; html_path = Some(hp); } + remember_web_settings(s, cmd, &last_web); s.file_menu.last_web = Some(last_web); crate::automate_cmds::fire_event(s, "export"); Ok(json!({"files": files, "html": html_path, "bytes": total, "slices": chosen.len(), "width": wdoc.size.width, "height": wdoc.size.height})) diff --git a/crates/engine/src/web_cmds/tests.rs b/crates/engine/src/web_cmds/tests.rs index 787560f425..9550cd849c 100644 --- a/crates/engine/src/web_cmds/tests.rs +++ b/crates/engine/src/web_cmds/tests.rs @@ -47,6 +47,23 @@ fn estimates_preserve_last_successful_export_settings() { assert_eq!(s.file_menu.last_web.as_ref(), Some(&saved), "preview estimates must not replace the settings from the last successful export"); } +/// The next launch's dialog seeds from the preferences: `file_menu` is not saved, so a complete +/// export remembers its settings in `prefs.dialogs` too, as Fill and the filter dialogs do. +#[test] +fn a_complete_export_remembers_its_settings_in_the_preferences() { + let dir = tmp("remember_settings"); + let mut s = session(8); + s.execute("file.export.saveForWebLegacy", json!({"format": "jpeg", "quality": 42, "path": format!("{dir}/out.jpg")})).unwrap(); + let saved = s.prefs().dialogs.get("file.export.saveForWebLegacy").cloned().expect("remembered"); + assert_eq!(saved["format"], "jpeg"); + assert_eq!(saved["quality"], 42); + assert!(saved.get("path").is_none(), "the destination is per export, not remembered"); + + // Preview estimates remember nothing, like they don't replace `file_menu`. + s.execute("file.export.saveForWebLegacy", json!({"format": "png24", "width": 16, "height": 12})).unwrap(); + assert_eq!(s.prefs().dialogs.get("file.export.saveForWebLegacy").unwrap()["format"], "jpeg"); +} + #[test] fn platform_writer_receives_resized_image_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/ui-egui/src/export_dialog.rs b/crates/ui-egui/src/export_dialog.rs index cd520d5c30..79abeaadbf 100644 --- a/crates/ui-egui/src/export_dialog.rs +++ b/crates/ui-egui/src/export_dialog.rs @@ -13,6 +13,46 @@ use crate::{ExportSettings, PhotocraftApp}; const FORMATS: [(&str, &str); 5] = [("png", "PNG"), ("jpg", "JPG"), ("webp", "WebP"), ("tif", "TIFF"), ("tga", "TGA")]; +/// Preferences ▸ remembered dialog settings: Export As writes the settings it was used with +/// last under its command id and seeds the next dialog from them, as Fill, Liquify and the +/// filter dialogs do. +pub(crate) const COMMAND: &str = "file.export.exportAs"; + +/// The dialog is seeded from the settings it was used with last (see [`COMMAND`]); a value that +/// no longer matches a control (a hand-edited preferences file) is left at its default. +fn seed_remembered(app: &PhotocraftApp, f: &mut Map) { + let Some(Value::Object(saved)) = app.session.prefs().dialogs.get(COMMAND) else { return }; + if let Some(v) = saved.get("format").and_then(Value::as_str).filter(|v| FORMATS.iter().any(|(k, _)| k == v)) { + f.insert("format".into(), json!(v)); + } + if let Some(v) = saved.get("quality").and_then(Value::as_f64).filter(|v| (1.0..=100.0).contains(v)) { + f.insert("quality".into(), json!(v)); + } + if let Some(v) = saved.get("scale").and_then(Value::as_f64).filter(|v| (1.0..=1000.0).contains(v)) { + f.insert("scale".into(), json!(v)); + } + for key in ["lossless", "transparency"] { + if let Some(v) = saved.get(key).and_then(Value::as_bool) { + f.insert(key.into(), json!(v)); + } + } + if let Some(v) = saved.get("metadata").and_then(Value::as_str).filter(|v| ["none", "all"].contains(v)) { + f.insert("metadata".into(), json!(v)); + } + if let Some(v) = saved.get("tgaBits").and_then(Value::as_str).filter(|v| ["auto", "24", "32"].contains(v)) { + f.insert("tgaBits".into(), json!(v)); + } +} + +/// Record the settings for the next Export As (see [`seed_remembered`]). +fn remember(app: &mut PhotocraftApp, f: &Map) { + const KEYS: [&str; 7] = ["format", "quality", "lossless", "transparency", "scale", "metadata", "tgaBits"]; + let saved: Map = KEYS.iter().filter_map(|k| f.get(*k).map(|v| ((*k).to_string(), v.clone()))).collect(); + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), Value::Object(saved)); + }); +} + pub fn open(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; let mut f = Map::new(); @@ -27,6 +67,7 @@ pub fn open(app: &mut PhotocraftApp) -> Result { f.insert("tgaBits".into(), json!("auto")); f.insert("__w".into(), json!(st.doc.size.width)); f.insert("__h".into(), json!(st.doc.size.height)); + seed_remembered(app, &mut f); Ok(app.ui.open_dialog(DialogKind::Command, f)) } @@ -355,6 +396,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map) -> Result { + remember(app, f); if let Some(layers) = f.get("__layers").and_then(Value::as_array) { let ids: Vec<_> = layers.iter().filter_map(Value::as_u64).map(photocraft_doc::LayerId).collect(); return confirm_layers(app, f, &ids); @@ -452,6 +494,55 @@ fn quick_export(app: &mut PhotocraftApp, p: Value) -> Result { mod tests { use super::*; + /// Export As reopens with the settings it was used with last (Preferences ▸ remembered + /// dialog settings), and a value no control accepts keeps its default. + #[test] + fn export_as_opens_with_the_settings_used_last() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 20, "height": 10})).unwrap(); + app.session.prefs.edit(|p| { + p.dialogs.insert( + COMMAND.into(), + json!({"format": "jpg", "quality": 88, "scale": 37, "transparency": false, "metadata": "all", "tgaBits": "24", "lossless": true}), + ); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "jpg"); + assert_eq!(f["quality"], json!(88.0)); + assert_eq!(f["scale"], json!(37.0)); + assert_eq!(f["transparency"], false); + assert_eq!(f["metadata"], "all"); + assert_eq!(f["tgaBits"], "24"); + + app.session.prefs.edit(|p| { + p.dialogs.insert(COMMAND.into(), json!({"format": "nope", "scale": 0, "metadata": "sometimes"})); + }); + let id = open(&mut app).unwrap(); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png"); + assert_eq!(f["scale"], 100); + assert_eq!(f["metadata"], "none"); + } + + /// Only the dialog's own settings are remembered: not the destination, the document size or + /// the layer the dialog was opened with. + #[test] + fn remembering_keeps_only_the_dialog_settings() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let mut f = Map::new(); + f.insert("format".into(), json!("webp")); + f.insert("scale".into(), json!(50)); + f.insert("path".into(), json!("/tmp/x.webp")); + f.insert("__w".into(), json!(1200)); + f.insert("__layer".into(), json!(3)); + remember(&mut app, &f); + let saved = app.session.prefs().dialogs.get(COMMAND).cloned().unwrap(); + assert_eq!(saved["format"], "webp"); + assert_eq!(saved["scale"], 50); + assert!(saved.get("path").is_none() && saved.get("__w").is_none() && saved.get("__layer").is_none()); + } + #[test] fn export_document_scales_height_when_rounded_width_is_unchanged() { use photocraft_doc::{Color, ColorMode, SampleType, Size}; diff --git a/crates/ui-egui/src/file_ui.rs b/crates/ui-egui/src/file_ui.rs index e3884796f8..d7e2252e71 100644 --- a/crates/ui-egui/src/file_ui.rs +++ b/crates/ui-egui/src/file_ui.rs @@ -192,8 +192,16 @@ pub fn open_web(app: &mut PhotocraftApp) -> u64 { f.insert("__command".into(), json!(WEB_COMMAND)); f.insert("__label".into(), json!("Save for Web (Legacy)")); f.insert("__view".into(), json!("2up")); - // Remember encoding choices, but each document starts at its own full size. - let last = app.session.file_menu.last_web.clone().unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); + // Remember encoding choices, but each document starts at its own full size. This session's + // last export lives in `file_menu`; the last one before a restart comes from the + // preferences' remembered dialog settings (`web_cmds::remember_web_settings`). + let last = app + .session + .file_menu + .last_web + .clone() + .or_else(|| app.session.prefs().dialogs.get(WEB_COMMAND).cloned()) + .unwrap_or_else(|| json!({"format": "jpeg", "quality": 60})); if let Value::Object(m) = last { for (k, v) in m { if !matches!(k.as_str(), "path" | "dir" | "width" | "height" | "percent" | "colorTable" | "colorTableEdits") { @@ -937,6 +945,20 @@ mod tests { assert!(!d.fields.contains_key("path")); } + /// A fresh session's dialog seeds from the preferences: the last export before a restart is + /// remembered there (`web_cmds::remember_web_settings`), as `file_menu` is not saved. + #[test] + fn the_save_for_web_dialog_seeds_from_the_remembered_preferences() { + let (mut app, _) = app(); + app.session.prefs.edit(|p| { + p.dialogs.insert(WEB_COMMAND.into(), json!({"format": "png8", "colors": 64})); + }); + let id = open_web(&mut app); + let f = &app.ui.dialog_mut(id).unwrap().fields; + assert_eq!(f["format"], "png8"); + assert_eq!(f["colors"], 64); + } + #[test] fn save_for_web_starts_at_the_current_document_size() { let (mut app, _) = app(); diff --git a/docs/scorecard.md b/docs/scorecard.md index d2ee641b1e..651d68309f 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -115,7 +115,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | FILE-215-15 | Layer Style contour picker, curve editor, presets, .shc import/export | partial | [#2912](https://github.com/storytold/photocraft/issues/2912) | Layer Style dialog: contour thumbnail + preset picker (12 standard shapes, own control points) and Anti-aliased on Drop/Inner Shadow, Outer/Inner Glow, Satin, Bevel Gloss Contour and Contour element; Contour Editor (add/drag/delete, Corner, Input/Output, Preset, Load…/Save… .shc per Adobe's public spec, psd shc.rs); corner points render (compose contour_curve_lut) and map to PSD Cnty. Missing: a user contour library (New…, picker Load/Save/Replace Contours), Anti-aliased doesn't change the render yet, .shc not yet checked against a Photoshop-written file | | FILE-215-16 | Advanced Blending (knockout, blend as group, mask hides effects) end to end | partial | [#215](https://github.com/storytold/photocraft/issues/215) | doc advanced.rs + .pcraft + PSD/layered TIFF (knko infx clbl tsly lmgm vmgm), CPU compose advanced.rs (GPU falls back), blendingOptions command + dialog; 7 psd-tools knockout oracles now pass; knockout-isolated-groups (0.45 % AA edges), advanced-blending, blend-and-clipping still differ | | FILE-215-17 | CMYK/RGB documents preview spot inks; spot plates export (TIFF extra samples, PDF) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | compose multichannel.rs:1 spot preview for Multichannel only | -| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults | +| FILE-215-18 | Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling | partial | [#215](https://github.com/storytold/photocraft/issues/215) | Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling | | FILE-215-19 | Save As JPEG opens a JPEG Options dialog (quality 0-12, format options) | missing | [#215](https://github.com/storytold/photocraft/issues/215) | ExportSettings has jpeg_quality 1-100 only (lib.rs:118) | | FILE-215-20 | Artboard tool with handles and + buttons, Duplicate Artboard beside, vector PDF pages | partial | [#215](https://github.com/storytold/photocraft/issues/215) | artboard_tool.rs: Artboard tool draws, selects, moves and resizes boards by their handles (⇧ keeps proportions), its + buttons add a board or (⌥) a copy beside the selected one (#3033); Duplicate Artboard places the copy beside (#1584); raster PDF pages only | | FILE-215-21 | Local libraries of colours, patterns, images and layers, as linked items or copies | missing | [#215](https://github.com/storytold/photocraft/issues/215) | Only preset libraries (preset_store.rs) | diff --git a/scorecard/file_compat.toml b/scorecard/file_compat.toml index 3c93a9d5b3..780bee4f16 100644 --- a/scorecard/file_compat.toml +++ b/scorecard/file_compat.toml @@ -129,7 +129,7 @@ id = "FILE-215-18" target = "Save for Web per-slice settings remembered; Export As remembers and offers nearest scaling" status = "partial" issue = 215 -note = "file_ui.rs:188 last_web in-session only; export_dialog.rs:16 fixed defaults" +note = "Save for Web settings survive a restart through the remembered dialog settings in prefs.dialogs (web_cmds::remember_web_settings stores them on a complete export, never the destination; file_ui.rs open_web seeds from the session's last export, else the preferences). export_dialog seeds Export As the same way (seed_remembered: format, quality, transparency, scale, metadata, TGA bits) and confirm remembers them as used. Missing: Export As nearest-neighbour scaling" [[item]] id = "FILE-215-19" From ab5a6c5ed20b3200dea832c81a46bf74386458e9 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:26:49 +0700 Subject: [PATCH 270/523] fix(layer-picker): include text bounds in right-click layer list (#4288) --- crates/engine/src/pick_cmds.rs | 14 +++++++++----- crates/ui-egui/src/layer_pick_ui.rs | 6 +++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/pick_cmds.rs b/crates/engine/src/pick_cmds.rs index d3bee1c6ac..c40e15e9b8 100644 --- a/crates/engine/src/pick_cmds.rs +++ b/crates/engine/src/pick_cmds.rs @@ -92,9 +92,9 @@ fn pick(s: &mut Session, p: &Value) -> Result { let x = p.get("x").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `x`".into() })?.floor() as i32; let y = p.get("y").and_then(Value::as_f64).ok_or_else(|| EngineError::BadParams { cmd: "layer.pickAt".into(), msg: "missing `y`".into() })?.floor() as i32; let doc = s.active().ok_or(EngineError::NoDocument)?.doc.clone(); - // The right-click list names the layers with pixels under the pointer. + // The right-click list includes type layers between glyphs, like Auto-Select. if p.get("list").and_then(Value::as_bool).unwrap_or(false) { - let hits = layers_hit(&doc, x, y, false); + let hits = layers_hit(&doc, x, y, true); let names: Vec = hits.iter().filter_map(|id| doc.layer(*id)).map(|l| json!({"layer": l.id.0, "name": l.name})).collect(); return Ok(json!({ "layers": names })); } @@ -190,7 +190,7 @@ pub fn specs() -> Vec { label: "Auto-Select Layer", menu: &[], shortcut: None, - params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return every layer with pixels there, topmost first)} → {layer} | {layers}"##, + params: r##"{"x":px,"y":px,"target":"layer|group"="layer","select":bool=true,"mode":"replace|toggle|add"="replace","list":bool=false (return visible layers at the point, including text bounds, topmost first)} → {layer} | {layers}"##, enabled: has_doc, journal: false, run: pick, @@ -372,9 +372,10 @@ mod tests { assert_eq!(pick(&mut s, 12, 15), t.0, "on a glyph"); assert_eq!(pick(&mut s, 17, 15), t.0, "between the glyphs: the type layer, not the one under it"); assert_eq!(pick(&mut s, 17, 20), bg.0, "below the text bounds"); - // The right-click list still names only the layers with pixels under the pointer. + // The context menu also reaches this type layer between the glyphs (#4259). let list = s.execute("layer.pickAt", json!({"x": 17, "y": 15, "list": true})).unwrap(); - assert_eq!(list["layers"].as_array().unwrap().len(), 1); + assert_eq!(list["layers"][0]["layer"], t.0); + assert_eq!(list["layers"][1]["layer"], bg.0); } #[test] @@ -398,6 +399,9 @@ mod tests { let Some(x) = gap else { return }; assert_eq!(layers_at(&doc, x, y), vec![bg], "no type pixels at the gap"); assert_eq!(s.execute("layer.pickAt", json!({"x": x, "y": y, "select": false})).unwrap()["layer"], t); + let menu = s.execute("layer.pickAt", json!({"x": x, "y": y, "list": true})).unwrap(); + assert_eq!(menu["layers"][0]["layer"], t, "type layer stays above its background in the menu"); + assert_eq!(menu["layers"][1]["layer"], bg.0); } #[test] diff --git a/crates/ui-egui/src/layer_pick_ui.rs b/crates/ui-egui/src/layer_pick_ui.rs index 654751b953..b75fe2b47b 100644 --- a/crates/ui-egui/src/layer_pick_ui.rs +++ b/crates/ui-egui/src/layer_pick_ui.rs @@ -2,8 +2,8 @@ //! with any tool, lists the layers with visible pixels under the pointer, topmost first, as //! Photoshop does. Choosing one selects it. //! -//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, layer masks, hidden -//! layers and hidden groups skipped), and choosing runs `layer.select`, so the menu is the same +//! The hit test is `layer.pickAt` with `"list": true` (non-transparent pixels, text bounds, +//! layer masks, hidden layers and hidden groups), and choosing runs `layer.select`, so the menu is the same //! two commands an agent would run. Painting tools keep their plain right-click (the Brush //! Preset picker, #268); the ⌘/Ctrl variant reaches the layer list from them too. @@ -27,7 +27,7 @@ pub fn is_gesture(tool: Tool, mods: Modifiers) -> bool { tool == Tool::Move || mods.command } -/// The layers with pixels at document point (x, y), topmost first. +/// The layers at document point (x, y), topmost first, including text between glyphs. pub fn layers_at(app: &mut PhotocraftApp, x: f64, y: f64) -> Vec<(u64, String)> { if !(x.is_finite() && y.is_finite()) { return Vec::new(); From b79d0a0bcf61eac9b4b9c935777baa32b4ff1fe6 Mon Sep 17 00:00:00 2001 From: MrCloudy2 <123890706+MrCloudy2@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:53 +0200 Subject: [PATCH 271/523] feat(gradients): Photoshop's interpolation method for Gradient Overlay, gradient Stroke and the Gradient tool (#4292) layer.layerStyle.gradientOverlay, layer.layerStyle.stroke (gradient) and paint.gradient take "method" (classic|perceptual|linear|smooth) and "smoothness" (0..100, default 100). The stops are baked with the same model PSD import uses (io::gradient_bake, now public as bake_gradient and GradientMethod), so the compositors' linear interpolation reproduces Photoshop's curve. Without a method the stops stay as they were. Measured in Photoshop 27.11 (max level error vs main -> this change): Gradient Overlay linear/reflected styles, any angle/scale/reverse 12 -> 0-1, radial/diamond 16 -> 4-5; gradient Stroke 12 -> 2-4; Gradient tool black -> white Classic 13-14 -> 2-3; red -> blue Classic 12 -> 1, Perceptual 83 -> 2, Linear 64 -> 4, Smooth 83 -> 3. A 3-stop Classic gradient improves only 14 -> 10 (the multi-stop smoothness spline is a separate follow-up). Co-authored-by: Claude Opus 5.5 --- crates/engine/src/layer_style.rs | 45 +++++++++++++++++++++++++++++--- crates/engine/src/paint_cmds.rs | 19 +++++++++++++- crates/io/src/gradient_bake.rs | 15 +++++++++-- crates/io/src/lib.rs | 1 + 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index b7fc8b7b01..fbbc949e20 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -52,9 +52,21 @@ fn color(p: &Value, k: &str, d: [f32; 3]) -> Color { let c = color_of(p, k).map_or(d, |c| c.to_rgb()); Color::rgb(c[0], c[1], c[2]) } +/// The stops of a two-colour gradient, baked with Photoshop's interpolation when `method` names +/// one (`classic`, `perceptual`, `linear`, `smooth`; `smoothness` 0..=100, default 100 as in +/// Photoshop): the compositors interpolate stops linearly, so the curve becomes dense stops, as on +/// PSD import. Photoshop 27.11's Classic Gradient Overlay eases (t + smoothstep(t)) / 2 between the +/// colours: a straight ramp was up to 12 levels off. +fn gradient_stops(p: &Value, from: Color, to: Color) -> Vec<(f32, Color)> { + let stops = vec![(0.0, from), (1.0, to)]; + match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) => photocraft_io::bake_gradient(stops, &[0.5], (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m), + None => stops, + } +} fn gradient(p: &Value) -> Gradient { Gradient { - stops: vec![(0.0, color(p, "from", [0.0; 3])), (1.0, color(p, "to", [1.0; 3]))], + stops: gradient_stops(p, color(p, "from", [0.0; 3]), color(p, "to", [1.0; 3])), style: gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear")), angle: f(p, "angle", 90.0), scale: f(p, "scale", 100.0) / 100.0, @@ -546,6 +558,13 @@ fn fresh_effect(kind: &str) -> Option { /// Edits an existing gradient in place: only the end colours and geometry. /// Intermediate stops, opacity stops, alignment and offset survive a round trip. fn overlay_gradient(g: &mut Gradient, p: &Value) { + // A method rebuilds the gradient from its end colours (the stops between are the old curve). + if p.get("method").and_then(Value::as_str).is_some_and(|m| photocraft_io::GradientMethod::from_name(m).is_some()) { + let ends = (g.stops.first().map(|s| s.1), g.stops.last().map(|s| s.1)); + let from = color_of(p, "from").or(ends.0).unwrap_or(Color::BLACK); + let to = color_of(p, "to").or(ends.1).unwrap_or(Color::WHITE); + g.stops = gradient_stops(p, from, to); + } if let Some(c) = color_of(p, "from") && let Some(first) = g.stops.first_mut() { @@ -983,13 +1002,13 @@ pub fn specs() -> Vec { style_cmd!( "stroke", "Stroke…", - r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"size":px=3,"position":"outside|inside|center","color":"#rrggbb","from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"?,"smoothness":0..100=100,"style":str,"angle":deg,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!("colorOverlay", "Color Overlay…", r##"{"color":"#rrggbb","opacity":0..100=100,"blend":str,"add":bool}"##), style_cmd!( "gradientOverlay", "Gradient Overlay…", - r##"{"from":"#rrggbb","to":"#rrggbb","style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## + r##"{"from":"#rrggbb","to":"#rrggbb","method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"style":"linear|radial|angle|reflected|diamond","angle":deg=90,"scale":10..150=100,"reverse":bool,"opacity":0..100,"blend":str,"add":bool}"## ), style_cmd!( "patternOverlay", @@ -1342,6 +1361,26 @@ mod tests { assert!((at(&lut("Cove - Deep"), 0.0) - 0.26).abs() < 0.02, "Cove - Deep starts above zero"); } + #[test] + fn gradient_overlay_method_matches_photoshop() { + // Photoshop 27.11, Classic Gradient Overlay #000000 → #ffffff (Smoothness 100 %): 0.037 at + // t = 1/16 and 0.5 in the middle; straight stops would give 0.0625. + let mut s = session(); + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "method": "classic"})).unwrap(); + let Effect::GradientOverlay { gradient, .. } = &effects(&s)[0] else { panic!() }; + let at = |t: f32| { + let w = gradient.stops.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).unwrap(); + let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-6); + w[0].1.to_rgb()[0] + (w[1].1.to_rgb()[0] - w[0].1.to_rgb()[0]) * u + }; + assert!((at(1.0 / 16.0) - 0.037).abs() < 0.004, "{}", at(1.0 / 16.0)); + assert!((at(0.5) - 0.5).abs() < 0.004, "{}", at(0.5)); + // Without a method the two stops stay as they are. + s.execute("layer.layerStyle.gradientOverlay", json!({"from": "#000000", "to": "#ffffff", "add": true})).unwrap(); + let plain = effects(&s).iter().filter(|e| matches!(e, Effect::GradientOverlay { gradient, .. } if gradient.stops.len() == 2)).count(); + assert_eq!(plain, 1, "the added overlay keeps its two stops"); + } + #[test] fn contour_presets_and_custom_curves_apply_in_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/paint_cmds.rs b/crates/engine/src/paint_cmds.rs index 4dc39f1517..497d7f4fb1 100644 --- a/crates/engine/src/paint_cmds.rs +++ b/crates/engine/src/paint_cmds.rs @@ -123,6 +123,23 @@ fn gradient(s: &mut Session, p: &Value) -> Result { gradients::apply_opacity(cs, &gradients::transparency_param(p, "paint.gradient")?.unwrap_or_default()) } }; + // Photoshop's interpolation (`method`, with `smoothness` 0..=100, default 100) baked into dense + // stops, as for Gradient Overlay: Photoshop 27.11's Classic black → white is 0.037 at t = 1/16 + // where straight stops give 0.0625. + let stops = match p.get("method").and_then(Value::as_str).and_then(photocraft_io::GradientMethod::from_name) { + Some(m) if stops.len() >= 2 => { + let colors = stops.iter().map(|(t, c)| (*t, photocraft_color::Color::rgba(c[0], c[1], c[2], c[3]))).collect(); + let mids = vec![0.5; stops.len() - 1]; + photocraft_io::bake_gradient(colors, &mids, (f(p, "smoothness", 100.0) / 100.0).clamp(0.0, 1.0), m) + .into_iter() + .map(|(t, c)| { + let rgb = c.to_rgb(); + (t, [rgb[0], rgb[1], rgb[2], c.alpha]) + }) + .collect() + } + _ => stops, + }; let reverse = b(p, "reverse", false); let opacity = opacity(p, "paint.gradient")?; let blend = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(photocraft_color::BlendMode::Normal); @@ -155,7 +172,7 @@ pub fn specs() -> Vec { label: "Gradient", menu: &[], shortcut: None, - params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, + params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","colors":["#rrggbb",…]? (evenly spaced),"gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient, see gradient.presets.select),"method":"classic|perceptual|linear|smooth"? (Photoshop's interpolation; omitted: straight stops),"smoothness":0..100=100,"reverse":bool=false,"dither":bool=true,"opacity":1..100=100,"mode":"normal|multiply|…"="normal","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##, enabled: crate::commands::has_paintable, run: gradient, journal: true, diff --git a/crates/io/src/gradient_bake.rs b/crates/io/src/gradient_bake.rs index c9265a9102..e452ae9e1f 100644 --- a/crates/io/src/gradient_bake.rs +++ b/crates/io/src/gradient_bake.rs @@ -14,8 +14,9 @@ use photocraft_color::{Color, ColorMode}; +/// Photoshop's gradient interpolation methods. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Method { +pub enum Method { Classic, Perceptual, Linear, @@ -23,6 +24,16 @@ pub(crate) enum Method { } impl Method { + /// The method a command names (`classic`, `perceptual`, `linear`, `smooth`); `None` otherwise. + pub fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "classic" => Some(Method::Classic), + "perceptual" => Some(Method::Perceptual), + "linear" => Some(Method::Linear), + "smooth" => Some(Method::Smooth), + _ => None, + } + } pub(crate) fn from_code(code: Option<&[u8]>) -> Method { match code { Some(b"Perc") => Method::Perceptual, @@ -140,7 +151,7 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32, m: Method) /// Dense linear stops reproducing Photoshop's interpolation. Returns the input unchanged when it /// already interpolates linearly in sRGB. -pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { +pub fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> { let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3); // Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the // compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab). diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 0a1577714d..9852a48516 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -42,6 +42,7 @@ pub mod effects_map; pub mod exr_parts; mod flat; mod gradient_bake; +pub use gradient_bake::{Method as GradientMethod, bake as bake_gradient}; mod kra; pub mod linked; mod multichannel_map; From 96f4256ced260f0466f09f8d11eb84d3209db8d0 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:26:59 +0200 Subject: [PATCH 272/523] fix(type): the Type tool's canvas pick skips text in hidden groups (#4320) (#4332) --- crates/doc/src/lib.rs | 5 ++ crates/engine/src/type_caret_cmds.rs | 4 +- crates/ui-egui/src/type_tool.rs | 85 +++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e2a46e4079..a5c5381ee1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1047,6 +1047,11 @@ impl Document { (1..=path.len()).filter_map(|n| self.layer_at(path.get(..n)?)).fold(Locks::default(), |a, l| a.union(l.locks)) } + /// Whether the layer at `path` is shown: it and every group around it are visible. + pub fn shown_at(&self, path: &[usize]) -> bool { + (1..=path.len()).all(|n| path.get(..n).and_then(|p| self.layer_at(p)).is_some_and(|l| l.visible)) + } + /// [`Self::locks_at`] for the layer `id`. pub fn effective_locks(&self, id: LayerId) -> Locks { self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p)) diff --git a/crates/engine/src/type_caret_cmds.rs b/crates/engine/src/type_caret_cmds.rs index 528472cc25..fbc9322c62 100644 --- a/crates/engine/src/type_caret_cmds.rs +++ b/crates/engine/src/type_caret_cmds.rs @@ -106,9 +106,7 @@ fn topmost(doc: &Document, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(path, _, layer)| { - matches!(layer.content, LayerContent::Text(_)) && (1..=path.len()).all(|n| doc.layer_at(&path[..n]).is_some_and(|ancestor| ancestor.visible)) - }) + .filter(|(path, _, layer)| matches!(layer.content, LayerContent::Text(_)) && doc.shown_at(path)) .map(|(_, _, layer)| layer.id) .collect(); ids.reverse(); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 6a801b3385..291fdde78a 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -52,7 +52,8 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) { (p.x as f32, p.y as f32) } -/// Topmost visible type layer whose laid-out text contains the document point. +/// Topmost shown type layer (it and every group around it visible, #4320) whose laid-out text +/// contains the document point. fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let doc = app.session.active()?.doc.clone(); let slop = 6.0 / app.point_zoom().max(0.01); @@ -61,7 +62,7 @@ fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option { let mut ids: Vec = doc .walk() .into_iter() - .filter(|(_, _, l)| (l.visible || editing == Some(l.id)) && matches!(l.content, LayerContent::Text(_))) + .filter(|(path, _, l)| matches!(l.content, LayerContent::Text(_)) && (editing == Some(l.id) || doc.shown_at(path))) .map(|(_, _, l)| l.id) .collect(); ids.reverse(); // walk() is bottom-up; hit the topmost first @@ -2227,6 +2228,86 @@ mod tests { current_text(app, LayerId(ed.layer)).unwrap() } + /// A committed type layer reading `s` at the same spot as the others (stacked variants, #4320). + fn stacked_variant(app: &mut PhotocraftApp, s: &str) -> LayerId { + app.ui.text_edit = None; + pointer_up(app, [50.0, 100.0], [50.0, 100.0]); + insert(app, s); + commit(app); + app.session.active().unwrap().active_layer.unwrap() + } + + fn hide(app: &mut PhotocraftApp, id: LayerId) { + app.run("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap(); + } + + /// Wraps `id` in a new group and returns the group. + fn group_of(app: &mut PhotocraftApp, id: LayerId) -> LayerId { + app.run("layer.select", json!({"layer": id.0, "mode": "replace"})).unwrap(); + app.run("layer.groupLayers", json!({})).unwrap(); + let doc = &app.session.active().unwrap().doc; + let path = doc.path_of(id).unwrap(); + doc.layer_at(&path[..path.len() - 1]).unwrap().id + } + + /// Three type variants at one spot, bottom to top: A, B, C. + fn variants() -> (PhotocraftApp, [LayerId; 3]) { + let mut app = app(); + let ids = ["Variant A", "Variant B", "Variant C"].map(|s| stacked_variant(&mut app, s)); + (app, ids) + } + + const ON_TEXT: (f64, f64) = (60.0, 95.0); + + /// #4320: variants hidden through their groups above the shown one are not picked. + #[test] + fn type_click_skips_text_in_hidden_groups_above_the_shown_variant() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + assert!(pointer_down(&mut app, ON_TEXT.0, ON_TEXT.1, egui::Modifiers::NONE)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0), "the click edits the shown variant"); + } + + #[test] + fn type_click_skips_text_in_a_nested_hidden_group() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + let inner = group_of(&mut app, c); + let outer = group_of(&mut app, inner); + hide(&mut app, outer); + assert!(app.session.active().unwrap().doc.layer(c).unwrap().visible, "only the outer group is hidden"); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a)); + } + + #[test] + fn type_click_skips_own_hidden_variants_above_and_below() { + let (mut app, [a, b, c]) = variants(); + hide(&mut app, b); + hide(&mut app, c); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(a), "hidden variants above"); + let (mut app, [a, b, c]) = variants(); + hide(&mut app, a); + hide(&mut app, b); + assert_eq!(hit_layer(&mut app, ON_TEXT.0, ON_TEXT.1), Some(c), "hidden variants below"); + } + + /// The Move tool's double-click on type (#1132) shares the pick. + #[test] + fn move_double_click_edits_the_shown_variant_not_one_in_a_hidden_group() { + let (mut app, [a, b, c]) = variants(); + for id in [b, c] { + let g = group_of(&mut app, id); + hide(&mut app, g); + } + app.ui.tool = crate::state::Tool::Move; + assert!(edit_at(&mut app, ON_TEXT.0, ON_TEXT.1)); + assert_eq!(app.ui.text_edit.as_ref().map(|e| e.layer), Some(a.0)); + } + /// Photoshop: a click on existing type edits it; a ⇧-click there starts a new type layer. #[test] fn shift_click_on_existing_type_starts_new_text() { From f6db7a8115269f756398ec1d05965828e7559512 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:27:03 +0200 Subject: [PATCH 273/523] Smart Objects Warp: mesh knots span the contents, not the skewed bounding frame (#4113) (#4341) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/smart_cmds.rs | 14 ++++ crates/engine/src/smart_cmds/tests.rs | 24 +++++++ crates/engine/src/warp_cmds.rs | 94 +++++++++++++++++++++++++-- 3 files changed, 127 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index e632251982..635c5bb088 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -387,6 +387,20 @@ fn place(name: &str, bytes: &[u8], fmt: PixelFormat, sm: &SmartObject) -> Result }) } +/// The box of a smart object's source in source pixels (what its warp's `us`/`vs` span), or `None` +/// when the source can't be read. +pub(crate) fn source_box(doc: &Document, sm: &SmartObject) -> Option<[f64; 4]> { + let (name, bytes) = source_bytes(&doc.metadata, &sm.source)?; + let fmt = doc.pixel_format(); + let img = match sm.stack_mode { + Some(mode) => stack_image(&name, &bytes, fmt, mode), + None => source_image(&name, &bytes, fmt), + } + .ok()?; + let r = img.bounds; + Some([f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]) +} + /// Renders a smart object from its source. `Ok(None)` when the source is unavailable (a missing /// linked file, or a PSD placed layer without embedded data): callers keep the existing cache. pub fn render(doc: &Document, sm: &SmartObject) -> Result> { diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index 90bb325a2c..aa88fe3fed 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -622,6 +622,30 @@ fn warp_on_a_distorted_smart_object_follows_the_corners() { assert_eq!(after.perspective, before); } +/// A `mesh` whose points are exactly the placement leaves a sheared smart object unchanged: its +/// knots span the contents, not the bounding frame of the warp box seen from the contents (#4113). +#[test] +fn identity_mesh_on_a_skewed_smart_object_changes_nothing() { + let mut s = session(8); + paint(&mut s); + let id = convert(&mut s); + let r = active_smart(&s).cache.unwrap().content_bounds(); + let (x0, y0, x1, y1) = (f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)); + let quad = [[x0, y0], [x1, y0 + 10.0], [x1, y1 + 10.0], [x0, y1]]; + s.execute("edit.transform", json!({"layer": id, "rect": [x0, y0, x1, y1], "quad": quad})).unwrap(); + let before = flat(&s); + let sm = active_smart(&s); + let (h, b) = (placement(&sm), crate::smart_cmds::source_box(&s.active().unwrap().doc, &sm).unwrap()); + let place = |u: f64, v: f64| { + let (x, y) = h.apply(b[0] + u * (b[2] - b[0]), b[1] + v * (b[3] - b[1])); + [x, y] + }; + let mesh = photocraft_geom::warp::BezierMesh::fit(&place, vec![0.0, 1.0], vec![0.0, 0.25, 1.0]); + let rect = [x0, y0, x1, y1 + 10.0]; + s.execute("layer.smartObjects.warp", json!({"layer": id, "style": "custom", "rect": rect, "mesh": mesh})).unwrap(); + assert!(mean_diff(&before, &flat(&s)) < 0.01, "mean diff {}", mean_diff(&before, &flat(&s))); +} + fn convert_to_layers(s: &mut Session) -> Value { s.execute("layer.smartObjects.convertToLayers", json!({})).unwrap() } diff --git a/crates/engine/src/warp_cmds.rs b/crates/engine/src/warp_cmds.rs index e64c93279d..5142b0a940 100644 --- a/crates/engine/src/warp_cmds.rs +++ b/crates/engine/src/warp_cmds.rs @@ -112,7 +112,7 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result { /// A Background is turned into a normal layer by the caller first /// ([`crate::extra_cmds::background_to_layer`]). -fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> { +fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp, frames: Option<&Frames>) -> Result<()> { let locks = l.locks.union(group); if locks.position || locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); @@ -124,7 +124,7 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - warp_layer(None, locks, c, w, rect, interp)?; + warp_layer(None, locks, c, w, rect, interp, frames)?; } } LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())), @@ -138,7 +138,14 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, let sb = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); let src_warp = match w.style { WarpStyle::None => None, - WarpStyle::Custom => Some(Warp::custom(w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)), sb)), + WarpStyle::Custom => { + let mesh = w.to_mesh(1, 1).map_points(|p| hom_apply(&inv, p)); + // A `mesh` parameter's knots span the contents, not the warp box's image (#4113). + match frames.and_then(|f| f.get(&l.id)).and_then(|&c| content_frame_warp(&mesh, c, sb)) { + Some(cw) => Some(cw), + None => Some(Warp::custom(mesh, sb)), + } + } _ => Some(Warp { bounds: sb, mesh: None, ..w.clone() }), }; sm.warp = src_warp.filter(|w| !w.is_identity()); @@ -191,6 +198,75 @@ fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, Ok(()) } +/// Source boxes of the smart objects under a warped layer, by layer id. +type Frames = std::collections::HashMap; + +fn source_frames(doc: &Document, l: &Layer, out: &mut Frames) { + match &l.content { + LayerContent::Group(g) => g.children.iter().for_each(|c| source_frames(doc, c, out)), + LayerContent::Smart(sm) => { + if let Some(b) = crate::smart_cmds::source_box(doc, sm) { + out.insert(l.id, b); + } + } + _ => {} + } +} + +/// Rescales `knots` (spanning the box starting at `c0`, `cs` long) to span the frame `t0`, `ts` +/// (which contains it). Returns the knots and whether the frame extends past the box at the start / end. +fn reframe_knots(knots: &[f64], c0: f64, cs: f64, t0: f64, ts: f64) -> (Vec, bool, bool) { + let pre = (c0 - t0) / ts > 1e-9; + let post = (t0 + ts - (c0 + cs)) / ts > 1e-9; + let mut out: Vec = Vec::with_capacity(knots.len() + 2); + if pre { + out.push(0.0); + } + out.extend(knots.iter().map(|k| (c0 + k * cs - t0) / ts)); + if post { + out.push(1.0); + } + // Keep the ends exactly 0 and 1. + if let Some(f) = out.first_mut() { + *f = 0.0; + } + if let Some(l) = out.last_mut() { + *l = 1.0; + } + (out, pre, post) +} + +/// `items` with three more copies of the first / last one at the front (`pre`) / back (`post`). +fn pad(items: &[T], pre: bool, post: bool) -> Option> { + let mut out = Vec::with_capacity(items.len() + 6); + if pre { + out.extend(std::iter::repeat_n(items.first()?.clone(), 3)); + } + out.extend_from_slice(items); + if post { + out.extend(std::iter::repeat_n(items.last()?.clone(), 3)); + } + Some(out) +} + +/// A stored smart-object warp from `mesh` (points in source space) whose knots span the source +/// box `c`: the knots are re-expressed over the frame `hull(c, b)` (`b` = the warp box's image in +/// source space), with a degenerate patch padding every side where the frame extends past `c`. +/// The surface over `c` is exactly the mesh's (#4113). +fn content_frame_warp(mesh: &BezierMesh, c: [f64; 4], b: [f64; 4]) -> Option { + let t = [c[0].min(b[0]), c[1].min(b[1]), c[2].max(b[2]), c[3].max(b[3])]; + let (cw, ch) = (c[2] - c[0], c[3] - c[1]); + if !(cw > 0.0 && ch > 0.0 && t.iter().all(|v| v.is_finite())) { + return None; + } + let (us, left, right) = reframe_knots(&mesh.us, c[0], cw, t[0], t[2] - t[0]); + let (vs, top, bottom) = reframe_knots(&mesh.vs, c[1], ch, t[1], t[3] - t[1]); + let rows: Vec> = mesh.points.chunks(mesh.nx()).map(|r| pad(r, left, right)).collect::>()?; + let rows = pad(&rows, top, bottom)?; + let m = BezierMesh { us, vs, points: rows.into_iter().flatten().collect() }; + m.is_valid().then(|| Warp::custom(m, t)) +} + /// Largest warp frame side: far above any real layer, far below what would exhaust memory. const MAX_WARP_SIDE: u32 = 100_000; @@ -270,13 +346,21 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result { return Ok(json!({"layer": id.0, "changed": false})); } let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); + let content_knots = p.get("mesh").is_some() && p.get("warp").is_none(); s.edit("Warp", |doc, _| { let sel = doc.selection.clone(); + let frames = content_knots.then(|| { + let mut f = Frames::new(); + if let Some(l) = doc.layer(id) { + source_frames(doc, l, &mut f); + } + f + }); let is_group = doc.layer(id).is_some_and(Layer::is_group); let group = crate::transform_cmds::group_locks(doc, id); crate::extra_cmds::background_to_layer(doc, id); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?; + warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp, frames.as_ref())?; let snapshot = doc.clone(); if let Some(l) = doc.layer_mut(id) { crate::transform_cmds::refresh_text(&snapshot, l); @@ -462,7 +546,7 @@ fn grid(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; + const P: &str = r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (warp box; default = layer content ∩ selection),"style":"custom|none|arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist","bend":%=50,"hDistort":%,"vDistort":%,"vertical":bool,"mesh":{"us":[0,…,1],"vs":[0,…,1],"points":[[x,y]…]} ((3c+1)×(3r+1) control points, row-major, document px; on a smart object `us`/`vs` span the source contents, so points equal to the placement are an identity warp even when it is skewed),"grid":[cols,rows]?,"warp":{full warp object}?,"interpolation":"bicubic|bilinear|nearest"} — on a type layer a style (or none) sets its Warp Text and the type stays editable"##; const S: &str = r##"{"warp":{…}? (the warp being edited; returned split),"rect":[x0,y0,x1,y1]?,"at":[x,y]? (document point; default = middle of the patch)} — without `warp`, edits the active smart object's warp. A 1×1 custom mesh is divided at the thirds first (the guides drawn on the default grid), so splitWarpCrosswise on an identity custom warp returns a 4×4 mesh (knots at 0, 1/3, 1/2, 2/3 and 1), not a 2×2. A preset is fitted as one patch and split without that step."##; const G: &str = r##"{"warp":{…}? (the warp being edited; returned with the new mesh),"rect":[x0,y0,x1,y1]?,"size":n|"default"|"3"|"4"|"5" (square patches, 1..=64; 1 and "default" are the single-patch grid)} — without `warp`/`style`/`mesh`, edits the active smart object's warp. The surface is kept and the new lines are real splits at i/n."##; vec![ From dd258cccf9edaf4bcf6084b0bddfe1f84414c95b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:27:07 +0200 Subject: [PATCH 274/523] Type: faux bold keeps antialiased edges at small sizes (#4130) (#4360) * Type: faux bold keeps antialiased edges at small sizes (#4130) Faux bold drew each glyph at 8 offsets into one signed-area coverage buffer, so edge pixels from the copies summed and saturated to full coverage (hard, pixelated stems, about 2.2x the ink at 12 px). Each offset now gets its own buffer, merged with max. Co-Authored-By: Claude Opus 5.5 * Type: faux bold ring radius tuned so stems keep their weight (#4130) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/text/src/render.rs | 57 ++++++++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 35 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index acd9d51132..44165461aa 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -14,8 +14,8 @@ use crate::warp::Warp; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; -/// Faux-bold dilation radius as a fraction of the font size. -pub const FAUX_BOLD_RADIUS: f32 = 0.018; +/// Faux-bold ring radius as a fraction of the font size (tuned so stem weight stays close to the old summed-coverage look). +pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; @@ -53,6 +53,30 @@ impl LineSink for WarpSink<'_, S> { /// when `color` is `None`) into `sink`, through `transform` (text space → sink space), bending /// the outlines with `warp` first (in text space). fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>) { + draw_pass(layout, transform, sink, only_color, warp, Pass::All); +} + +/// Which part of the layout [`draw_pass`] emits. Faux bold draws each glyph at several offsets; +/// summing those into one coverage buffer saturates the antialiased edge pixels (hard, pixelated +/// stems), so the rasteriser draws every offset into its own buffer and merges them with `max`. +#[derive(Clone, Copy, PartialEq)] +enum Pass { + /// Everything, all offsets (bounds, outlines). + All, + /// Non-bold glyphs and decorations. + Plain, + /// Bold glyphs at one offset (index into [`BOLD_OFFSETS`]). + Bold(usize), +} + +const BOLD_OFFSETS: [(f64, f64); 8] = [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)]; + +fn is_bold(layout: &TextLayout, g: &PlacedGlyph) -> bool { + let face_bold = layout.faces.get(g.face as usize).is_some_and(|f| f.embolden); + layout.styles.get(g.style as usize).is_some_and(|s| s.faux_bold) || face_bold +} + +fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_color: Option<&Color>, warp: Option<&Warp>, pass: Pass) { for g in &layout.glyphs { let st = &layout.styles[g.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -69,8 +93,17 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c let glyph = glyph_xform(layout, g, face.skew_deg); let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; - let offsets: &[(f64, f64)] = - if bold { &[(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (0.7, 0.7), (-0.7, -0.7), (0.7, -0.7), (-0.7, 0.7)] } else { &[(0.0, 0.0)] }; + let single = [(0.0, 0.0)]; + let offsets: &[(f64, f64)] = match (pass, bold) { + (Pass::Plain, true) => continue, + (Pass::Bold(_), false) => continue, + (Pass::Bold(k), true) => match BOLD_OFFSETS.get(k..=k) { + Some(o) => o, + None => continue, + }, + (Pass::All, true) => &BOLD_OFFSETS, + (_, false) => &single, + }; for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); @@ -91,6 +124,9 @@ fn draw(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, only_c } } } + if matches!(pass, Pass::Bold(_)) { + return; + } for d in &layout.decorations { let st = &layout.styles[d.style as usize]; if only_color.is_some_and(|c| c != &st.color) { @@ -199,8 +235,17 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } for c in &colors { let mut cov = Coverage::new(w, h); - draw(layout, &xf, &mut cov, Some(c), warp); - let cov = cov.finish(); + draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); + let mut cov = cov.finish(); + if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + for k in 0..BOLD_OFFSETS.len() { + let mut bc = Coverage::new(w, h); + draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); + for (a, b) in cov.iter_mut().zip(bc.finish()) { + *a = a.max(b); + } + } + } let comps = color_in(&format, c); let a = c.alpha.clamp(0.0, 1.0); for (i, &cv) in cov.iter().enumerate() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d502385f07..c21d5c2682 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -396,6 +396,41 @@ fn styles_change_pixels() { assert!(hrect.width() as f32 > prect.width() as f32 * 1.7); } +/// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one +/// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the +/// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. +#[test] +fn faux_bold_keeps_antialiased_edges_at_small_sizes() { + let mut e = TextEngine::new(); + let base = CharStyle { size_pt: 12.0, ..Default::default() }; + let stats = |e: &mut TextEngine, s: CharStyle| { + let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 20.0), ..styled("Bold Test", s) }, 72.0, PixelFormat::RGBA8); + let (w, h) = (r.rect.width() as usize, r.rect.height() as usize); + let mut partial = 0usize; + for y in 0..h { + for x in 0..w { + let a = alpha_sum( + &r.surface, + photocraft_geom::Rect::new(r.rect.x0 + x as i32, r.rect.y0 + y as i32, r.rect.x0 + x as i32 + 1, r.rect.y0 + y as i32 + 1), + ); + if a > 0.05 && a < 0.95 { + partial += 1; + } + } + } + (alpha_sum(&r.surface, r.rect), partial) + }; + let (plain_ink, plain_partial) = stats(&mut e, base.clone()); + let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base }); + assert!(bold_ink > plain_ink, "bold must add ink"); + // Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this + // text at 12 px (plain 138.0). The antialiased version must stay close to that weight. + const MAIN_BOLD_INK: f64 = 282.7; + assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}"); + assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}"); +} + /// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling. #[test] fn faux_bold_scales_with_glyph_scaling() { From 8157d1a8517b381384aa63397c431ff4b0018c15 Mon Sep 17 00:00:00 2001 From: Fabio Lussi Date: Tue, 13 Oct 2026 03:27:12 +0200 Subject: [PATCH 275/523] Free Transform: refuse outputs over a pixel budget instead of building them (#4293) A large scale (e.g. 200x on a thin column) made the warp fill every tile of a result up to 2^21 px on a side, enough to exhaust memory. Free Transform now prices each warp before building anything and refuses with an allocation-style error ("...; the document was not changed") once a result would exceed MAX_TRANSFORM_PIXELS (2^30, the same bound as Puppet Warp's MAX_WARP_PIXELS). Only the result's size counts, so moving content far off the canvas still works, and nothing is clipped. The check covers the layer, a smart object's cache and filter mask, linked masks, a lone mask/channel target and the selection outline, and runs over the whole layer tree before any layer is changed. Transform Selection gets the same check. The transform box now stays open when a commit is refused. Part of #3773 Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 44 ++++-- crates/engine/src/allocation.rs | 2 +- crates/engine/src/select_extra_cmds.rs | 1 + crates/engine/src/transform_cmds.rs | 190 ++++++++++++++++++++++++- crates/ui-egui/src/transform_tool.rs | 63 ++++++-- 5 files changed, 267 insertions(+), 33 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 1480ec2b3e..2584a877df 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -192,6 +192,30 @@ pub fn quad_to_rect(q: &[[f64; 2]; 4], w: f64, h: f64) -> Option { Homography::rect_to_quad([0.0, 0.0, w, h], *q)?.inverse() } +/// The destination rectangle [`warp_surface`] fills for `src_rect` under `h`: the warped corners +/// plus a pixel for filter support, each side clamped to ±2²⁰. `None` when a corner maps to +/// infinity (the result is then empty). Lets a caller price a warp before allocating it. +pub fn warp_output_bounds(src_rect: Rect, h: &Homography) -> Option { + if src_rect.is_empty() || h.inverse().is_none() { + return None; + } + let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)] + .map(|(x, y)| h.apply(x as f64, y as f64)); + corner_bounds(&corners) +} + +fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { + if !corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()) { + return None; + } + let lim = (1 << 20) as f64; + let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-lim) as i32 - 1; + let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim) as i32 + 1; + Some(Rect::new(bx0, by0, bx1, by1)) +} + fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option) -> Result { let mut fmt = src.format(); let converted; @@ -234,21 +258,13 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]); let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0)); let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs())); - let finite = corners.iter().all(|c| c.0.is_finite() && c.1.is_finite()); - let lim = 1 << 20; - let bounds = || { - let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let by0 = corners.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1; - let bx1 = corners.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - let by1 = corners.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil().min(lim as f64) as i32 + 1; - Rect::new(bx0, by0, bx1, by1) - }; - let dst = match clip { + let bounds = corner_bounds(&corners); + let dst = match (clip, bounds) { // The warped corners only bound the result while the whole source is in front of the horizon. - Some(c) if finite && side.is_some() => bounds().intersect(&c), - Some(c) => c, - None if finite => bounds(), - None => return Ok(out), + (Some(c), Some(b)) if side.is_some() => b.intersect(&c), + (Some(c), _) => c, + (None, Some(b)) => b, + (None, None) => return Ok(out), }; if dst.is_empty() { return Ok(out); diff --git a/crates/engine/src/allocation.rs b/crates/engine/src/allocation.rs index 322e3ebd22..7c29ecc8ee 100644 --- a/crates/engine/src/allocation.rs +++ b/crates/engine/src/allocation.rs @@ -48,7 +48,7 @@ pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft Ok(out) } -fn error(purpose: &str, detail: &str) -> EngineError { +pub(crate) fn error(purpose: &str, detail: &str) -> EngineError { EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed")) } diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index e2c9e70591..5feae1a75f 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -195,6 +195,7 @@ fn transform_selection(s: &mut Session, p: &Value) -> Result { } let selected = s.edit("Transform Selection", |doc, _| { if let Some(sel) = &doc.selection { + crate::transform_cmds::check_budget(sel.content_bounds(), &h, "selection")?; doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, interp)?).filter(|s| !s.content_bounds().is_empty()); } Ok(doc.selection.is_some()) diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 44926b09f3..a09366e6fd 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,6 +1,6 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_surface}; +use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; @@ -234,9 +234,81 @@ pub fn locked_for_transform(doc: &Document, id: LayerId) -> bool { false } +/// The largest result (px) a single Free Transform warp may build: 2³⁰, the bound Puppet Warp uses +/// too ([`photocraft_algo::puppet::MAX_WARP_PIXELS`]). Only the size of the result counts, not +/// where it lands, so moving a layer far off the canvas stays allowed. A scale past it is refused +/// rather than clipped (#3773): the warp would otherwise fill every tile of a result up to 2²¹ px +/// on a side before anything could fail. +pub const MAX_TRANSFORM_PIXELS: u64 = 1 << 30; + +/// Tests use a far lower budget, so proving a refusal never comes near allocating the real one. +#[cfg(not(test))] +const BUDGET: u64 = MAX_TRANSFORM_PIXELS; +#[cfg(test)] +const BUDGET: u64 = 1 << 20; + +/// Refuse a warp of the content inside `src` by `h` whose result would exceed the pixel budget, +/// before anything is built. `what` names the surface in the message. +pub(crate) fn check_budget(src: Rect, h: &Homography, what: &str) -> Result<()> { + let Some(b) = warp_output_bounds(src, h) else { return Ok(()) }; + let (w, ht) = (u64::from(b.width()), u64::from(b.height())); + if w.saturating_mul(ht) > BUDGET { + let detail = format!("the transformed {what} would be {w} x {ht} px, more than the {BUDGET} px limit"); + return Err(crate::allocation::error("transforming pixels", &detail)); + } + Ok(()) +} + +/// [`check_budget`] for every warp [`transform_layer`] would make of `l` (for a group, of every +/// layer inside it), so a transform that doesn't fit is refused before any layer is built and a +/// group that partly fits isn't half-transformed. +fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> Result<()> { + if h.is_identity() { + return Ok(()); + } + // An explicit stack rather than recursion: documents can nest deeply. + let mut stack = vec![(l, doc_sel)]; + while let Some((l, sel)) = stack.pop() { + match &l.content { + LayerContent::Group(g) => stack.extend(g.children.iter().map(|c| (c, None))), + LayerContent::Text(_) | LayerContent::Shape(_) => {} + LayerContent::Smart(sm) => { + if let Some(c) = &sm.cache { + check_budget(c.content_bounds(), h, "smart object")?; + } + if let Some(m) = &sm.filter_mask { + check_budget(m.surface.content_bounds(), h, "smart filter mask")?; + } + } + _ => { + if let Some(surf) = l.surface() { + check_budget(surf.content_bounds(), h, &format!("layer \"{}\"", l.name))?; + } + } + } + if let Some(m) = &l.mask + && m.linked + { + // With a selection only its region of the mask moves (`warp_gray_selected`). + let src = match sel.filter(|_| !moves_whole(l)) { + Some(sel) => sel.content_bounds(), + None => m.surface.content_bounds(), + }; + check_budget(src, h, "layer mask")?; + } + } + Ok(()) +} + /// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]). A Background /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). +/// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { + check_layer_budget(doc_sel, l, h)?; + apply_transform(doc_sel, group, l, h, affine, interp) +} + +fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; let locks = transform_locks(l, group, false); if locks.position || locks.all { @@ -245,7 +317,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L if h.is_identity() { if let LayerContent::Group(g) = &mut l.content { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp)?; } } return Ok(()); @@ -256,7 +328,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut L match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - transform_layer(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp)?; } } LayerContent::Text(t) => { @@ -501,10 +573,16 @@ fn transform(s: &mut Session, p: &Value) -> Result { let lone = lone.is_some(); s.edit("Free Transform", |doc, _| { let sel = doc.selection.clone(); + // The selection outline moves too: price it before anything is built. + if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { + check_budget(sel.content_bounds(), &h, "selection")?; + } if lone { // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. if !h.is_identity() { let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); + check_budget(src, &h, "mask or channel")?; *surf = match &sel { Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, None => warp_gray(surf, &h, interp)?, @@ -577,6 +655,112 @@ mod tests { st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds() } + // ---- Pixel budget (#3773). Tests run with a 2²⁰ px budget, so nothing here comes near the real one. ---- + + /// A 16 x 16 document whose active layer is a 1 x 8 column at x = 10. + fn column() -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 2, 11, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s + } + + /// Stretch x by `k` about x = 10 (the column's left edge). + fn stretch(k: f64) -> Value { + json!({"matrix": [k, 0, 0, 1, -10.0 * k, 0], "interpolation": "nearest"}) + } + + #[test] + fn a_scale_past_the_pixel_budget_is_refused_and_changes_nothing() { + let mut s = column(); + let st = s.active().unwrap(); + let (before, revision, steps) = (st.doc.clone(), st.revision, st.history.past_len()); + // 200000 x 10 px of output: twice the test budget. + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.starts_with("not enough memory for transforming pixels"), "{err}"); + assert!(err.contains("would be 200002 x 10 px") && err.ends_with("the document was not changed"), "{err}"); + let st = s.active().unwrap(); + assert!(st.doc == before, "the document is untouched"); + assert_eq!((st.revision, st.history.past_len()), (revision, steps)); + // Within the budget the same stretch still works, nothing clipped. + s.execute("edit.transform", stretch(1e4)).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(0, 2, 10_000, 10)); + } + + #[test] + fn a_group_with_one_layer_past_the_budget_is_not_half_transformed() { + let mut s = column(); + let wide = s.active().unwrap().active_layer.unwrap(); + // Over the column: a 1 px dot, which fits the budget stretched by the same amount. + s.execute("layer.new.layer", json!({})).unwrap(); + let dot = s.active().unwrap().active_layer.unwrap(); + s.edit("paint", |doc, _| { + doc.layer_mut(dot).unwrap().surface_mut().unwrap().fill_rect(Rect::new(10, 12, 11, 13), &[0.0, 1.0, 0.0, 1.0]); + // Widen the column to 8 px (x 2..10), so it alone needs 400002 x 10 px. + doc.layer_mut(wide).unwrap().surface_mut().unwrap().fill_rect(Rect::new(2, 2, 10, 10), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + s.execute("layer.select", json!({"layer": wide.0})).unwrap(); + s.execute("layer.select", json!({"layer": dot.0, "mode": "add"})).unwrap(); + let group = LayerId(s.execute("layer.groupLayers", json!({})).unwrap()["layer"].as_u64().unwrap()); + let st = s.active().unwrap(); + assert!(matches!(&st.doc.layer(group).unwrap().content, LayerContent::Group(g) if g.children.len() == 2)); + let (before, revision) = (st.doc.clone(), st.revision); + let k = 5e4; + assert!(s.execute("edit.transform", json!({"layer": group.0, "matrix": [k, 0, 0, 1, -10.0 * k, 0]})).is_err()); + let st = s.active().unwrap(); + assert!(st.doc == before && st.revision == revision, "nothing changed"); + // The layer-level check runs before any child is warped, so even the layer itself + // (not just the edit's copy of the document) is left as it was. + let mut l = st.doc.layer(group).unwrap().clone(); + let h = Homography([k, 0.0, -10.0 * k, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]); + assert!(transform_layer(None, Locks::default(), &mut l, &h, None, Interp::Nearest).is_err()); + assert!(&l == st.doc.layer(group).unwrap(), "no child was transformed"); + } + + #[test] + fn moving_a_small_layer_far_off_the_canvas_is_within_budget() { + let mut s = session(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 900_000, -900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(900_010, -899_990, 900_030, -899_980)); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, -900_000, 900_000]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "and back, every pixel kept"); + } + + #[test] + fn selections_masks_and_channels_have_the_same_budget() { + // The selection outline of a layer transform. + let mut s = column(); + s.execute("select.rect", json!({"x": 10, "y": 2, "width": 1, "height": 8})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let err = s.execute("edit.transform", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + // Transform Selection. + let err = s.execute("select.transformSelection", stretch(2e5)).unwrap_err().to_string(); + assert!(err.contains("transformed selection would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + // A targeted alpha channel. + let mut s = column(); + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 2, 11, 10), &[1.0]); + Ok(()) + }) + .unwrap(); + let before = s.active().unwrap().doc.clone(); + let mut p = stretch(2e5); + p["target"] = json!({"channel": 0}); + let err = s.execute("edit.transform", p).unwrap_err().to_string(); + assert!(err.contains("transformed mask or channel would be"), "{err}"); + assert!(s.active().unwrap().doc == before); + } + #[test] fn scale_via_quad_and_undo() { let mut s = session(); diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index ecb19fff7b..0af28a81c5 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -669,6 +669,9 @@ pub fn warps_type(app: &PhotocraftApp) -> bool { fn enter_type_warp(app: &mut PhotocraftApp, ctx: &egui::Context) { if app.ui.transform.as_ref().is_some_and(|t| t.quad != corners(t.rect)) { commit(app); + if app.ui.transform.is_some() { + return; // refused: the box stays as it was + } if let Err(e) = begin(app, ctx) { app.ui.status = e; return; @@ -954,7 +957,7 @@ pub fn commit(app: &mut PhotocraftApp) { } let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; - app.transform_preview = None; + let preview = app.transform_preview.take(); if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -968,12 +971,12 @@ pub fn commit(app: &mut PhotocraftApp) { None => p["quad"] = json!(t.quad), } if let Err(e) = app.run("select.transformSelection", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } - if let Some(target) = t.path { - let mut p = target; + if let Some(target) = &t.path { + let mut p = target.clone(); match &t.warp { Some(w) if w.is_identity() => return, Some(w) => p["warp"] = json!(w), @@ -988,7 +991,7 @@ pub fn commit(app: &mut PhotocraftApp) { }, } if let Err(e) = app.run("path.transform", p) { - app.ui.status = e; + keep_open(app, t, preview, e); } return; } @@ -1004,8 +1007,8 @@ pub fn commit(app: &mut PhotocraftApp) { return; // untouched: nothing to do and no history step; a ⌥⌘T copy or a place stays } let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); - if let Some(target) = t.target { - p["target"] = target; + if let Some(target) = &t.target { + p["target"] = target.clone(); } app.run("edit.transform", p) }; @@ -1015,17 +1018,19 @@ pub fn commit(app: &mut PhotocraftApp) { fold_made(app, made); } } - Err(e) => { - if made.is_some() - && let Some(document) = document - { - take_back_made(app, document, LayerId(t.layer)); - } - app.ui.status = e; - } + Err(e) => keep_open(app, t, preview, e), } } +/// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it +/// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or +/// a placed layer it made goes only when it is cancelled. +fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option, e: String) { + app.ui.transform = Some(t); + app.transform_preview = preview; + app.ui.status = e; +} + /// A transform whose layer or document went away (undo, close) ends silently; picking another tool /// or selecting another layer (Layers panel, ⌘-click, the control channel) applies it, so the box /// never stays on a layer that is no longer the active one (#1400). Checked every frame and before @@ -1055,6 +1060,10 @@ pub fn end_if_left(app: &mut PhotocraftApp) { || (!selection && st.active_layer.is_some_and(|active| active.0 != layer.0)) { commit(app); + // Picking another tool or layer can't leave a refused transform open: it ends, as Esc would. + if app.ui.transform.is_some() { + cancel(app); + } } } @@ -3413,6 +3422,30 @@ mod tests { assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); } + /// A commit the engine refuses (here a collapsed box; a result over the pixel budget, #3773, is + /// refused the same way) shows the reason and keeps the box open, unchanged; picking another + /// tool then ends it. + #[test] + fn a_refused_commit_keeps_the_box_open() { + let ctx = egui::Context::default(); + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + let (revision, steps) = (app.session.active().unwrap().revision, app.session.active().unwrap().history.past_len()); + begin(&mut app, &ctx).unwrap(); + if let Some(t) = app.ui.transform.as_mut() { + t.quad = [[16.0, 16.0]; 4]; + } + commit(&mut app); + let t = app.ui.transform.as_ref().expect("the box stays open"); + assert_eq!(t.quad, [[16.0, 16.0]; 4], "as the user left it"); + assert!(app.transform_preview.is_some(), "with its preview"); + assert!(app.ui.status.contains("collapses"), "{}", app.ui.status); + let st = app.session.active().unwrap(); + assert_eq!((st.revision, st.history.past_len()), (revision, steps), "the document was not changed"); + app.ui.tool = if app.ui.tool == Tool::Brush { Tool::Move } else { Tool::Brush }; + end_if_left(&mut app); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "another tool ends it"); + } + /// #352: ⌥⌘T transforms a copy. OK leaves the original and a moved copy as one history step; /// Cancel leaves no copy and nothing to redo; with a selection the copy holds the selected /// pixels only. From 465a663540249f8d2705963e5e06f72a9bc5b6b0 Mon Sep 17 00:00:00 2001 From: deadmeu Date: Tue, 13 Oct 2026 01:31:32 +0000 Subject: [PATCH 276/523] feat(ui): developer tile grid overlay (ui.set tileGrid) (#4217) Draw the active layer's storage tiles over the canvas: a faint TILE_SIZE grid over the document, every stored tile filled and labelled tx,ty, tiles with identical pixels in one colour per group and unique tiles in blue. It shows how sparse a layer is, which tiles copy-on-write shares, and which ones .pcraft saves once. It has no menu item and isn't a View > Show item. The control channel and MCP turn it on with ui.set {"tileGrid": true}; ui.inspect reports it as view.tile_grid, and View > Extras doesn't hide it. Grouping hashes each distinct tile allocation once, in parallel (or serially while jobs hold the pool, #1554), cached per document revision and active layer. A 36 MP layer of all-different tiles groups in 3.9 ms (8-bit) and 7.5 ms (16-bit) on 24 cores in release; the ignored tile_grouping_36mp test reproduces this. Also documents tileGrid in docs/control-protocol.md and docs/development.md, and adds my contributors/people.toml entry. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 3 + crates/automation/src/server.rs | 3 +- crates/ui-egui/src/canvas.rs | 6 + crates/ui-egui/src/control.rs | 27 ++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/tile_overlay.rs | 293 +++++++++++++++++++++++++++++ crates/ui-egui/src/view_cmds.rs | 4 + docs/control-protocol.md | 1 + docs/development.md | 1 + 9 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tile_overlay.rs diff --git a/contributors/people.toml b/contributors/people.toml index 9a8bf4b4ce..f5b001204d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -140,3 +140,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.deadmeu] +sync_github_name = true diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 71c290cae7..74b3770dff 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -323,7 +323,8 @@ pub struct UiSetParams { /// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, - /// the Content-Aware Move tool's Transform On Drop). Other fields are an error. + /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the + /// active layer's storage tiles). Other fields are an error. pub fields: Value, } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bfcb0d1a7f..3094ce6fa1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2823,6 +2823,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::pixel_grid::paint(app, &painter, &xf, idx, output, visible_doc_rect(&xf)); } + // Developer tile grid (`ui.set {"tileGrid": true}`): the active layer's storage tiles. + // Not tied to View › Extras (⌘H), which hides only the View › Show overlays. + if app.ui.view.tile_grid { + crate::tile_overlay::paint(app, &ctx, &painter, &xf, idx, visible_doc_rect(&xf)); + } + // View › Show › Layer Edges: drawn at this depth, but only once this frame's tool events are // in (`layer_edges_rect`), so a press that Auto-Selects a layer outlines that layer. let layer_edges = painter.add(egui::Shape::Noop); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fb1f652576..ceacb71928 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,7 +131,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 34] = [ +pub const UI_SET_FIELDS: [&str; 35] = [ "tool", "panels", "dock", @@ -166,6 +166,7 @@ pub const UI_SET_FIELDS: [&str; 34] = [ "cropShield", "shapeStroke", "camTransformOnDrop", + "tileGrid", ]; /// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app @@ -492,6 +493,8 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) let eyedropper_ring = bool_field(p, "eyedropperRing")?; // Content-Aware Move's Transform On Drop checkbox. let cam_transform_on_drop = bool_field(p, "camTransformOnDrop")?; + // Developer overlay of the active layer's storage tiles (tile_overlay.rs). + let tile_grid = bool_field(p, "tileGrid")?; // The Crop tool's overlay menu (#1919). let crop_overlay = match p.get("cropOverlay") { Some(v) => Some(v.as_str().and_then(crate::crop_overlay::CropOverlay::from_id).ok_or_else(|| { @@ -650,6 +653,9 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = cam_transform_on_drop { app.ui.tool_options.cam_transform_on_drop = on; } + if let Some(on) = tile_grid { + app.ui.view.tile_grid = on; + } if let Some(k) = crop_overlay { app.ui.tool_options.crop_overlay = k; } @@ -1858,6 +1864,25 @@ mod tests { assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options"); } + #[test] + fn ui_set_toggles_the_developer_tile_grid() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert!(!app.ui.view.tile_grid, "off by default"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": true}))["ok"], true); + assert!(app.ui.view.tile_grid); + assert_eq!(call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["view"]["tile_grid"], true); + // Not a View › Show item: Extras doesn't hide it, and Show Extras Options doesn't list it. + crate::menus::invoke(&mut app, &ctx, "view.extras", json!({})).unwrap(); + assert!(!app.ui.view.extras && app.ui.view.tile_grid); + assert!(serde_json::to_value(app.ui.view.show).unwrap().get("tile_grid").is_none()); + let bad = call(&mut app, &ctx, "ui.set", json!({"tileGrid": "yes"})); + assert_eq!(bad["ok"], false, "{bad}"); + assert!(app.ui.view.tile_grid, "a rejected value changes nothing"); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tileGrid": false}))["ok"], true); + assert!(!app.ui.view.tile_grid); + } + #[test] fn ui_set_drives_the_eyedropper_options() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cef61293f8..a0aa5d4e9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -191,6 +191,7 @@ pub mod targa_options_ui; pub mod text_field_menu; pub mod theme; pub mod tiff_options_ui; +mod tile_overlay; mod timeline_ui; mod titlebar; pub mod tone; diff --git a/crates/ui-egui/src/tile_overlay.rs b/crates/ui-egui/src/tile_overlay.rs new file mode 100644 index 0000000000..03d8156dde --- /dev/null +++ b/crates/ui-egui/src/tile_overlay.rs @@ -0,0 +1,293 @@ +//! The developer tile grid: the active layer's storage tiles over the canvas, turned on with the +//! control channel's `ui.set {"tileGrid": true}` ([`crate::view_cmds::ViewOptions::tile_grid`]). +//! It shows how sparse a layer is, which tiles copy-on-write shares, and which ones `.pcraft` +//! saves once. +//! +//! A layer's pixels live in a sparse [`Surface`] of [`TILE_SIZE`] tiles; cells with no stored +//! tile read as the surface's default pixel and cost nothing. The overlay draws the tile grid +//! faintly over the document, fills every stored tile, and labels it `tx,ty` once it is large +//! enough on screen. Tiles with identical pixels share a hue; a tile whose pixels appear nowhere +//! else is plain blue. +//! +//! Grouping reads every stored tile, so it is cached per document revision and active layer, +//! never redone per frame (AGENTS.md rule 8). Tile identity can't stand in for the revision: a +//! tile nothing else shares is written in place. Each allocation is hashed once, and tiles are +//! grouped by that 64-bit hash of their bytes. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use egui::{Align2, Color32, FontId, Painter, Shape, Stroke}; +use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord}; +use photocraft_raster::{Surface, Tile}; +use rayon::prelude::*; + +use crate::PhotocraftApp; +use crate::canvas::ViewXform; + +/// Outline and fill of a tile whose pixels appear nowhere else in the layer. +const UNIQUE: Color32 = Color32::from_rgb(0x2d, 0x8c, 0xeb); +/// Alpha of a stored tile's fill; low so the image stays readable underneath. +const FILL_ALPHA: u8 = 40; +/// The grid over cells with no stored tile: a translucent mid grey, seen on light and dark pixels. +const EMPTY_LINE: Color32 = Color32::from_rgba_premultiplied(32, 32, 32, 64); +/// Tile labels show once a tile is at least this many points across. +const LABEL_MIN: f32 = 56.0; + +/// One stored tile and the group of tiles it shares its pixels with (`None` = unique). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TileInfo { + pub coord: TileCoord, + pub group: Option, +} + +/// The tiles of one layer, valid for one document revision. +#[derive(Clone)] +struct Cached { + revision: u64, + tiles: Arc>, +} + +/// A tile's allocation as a plain address, so it can cross to the Rayon pool. +fn ptr(t: &Arc) -> usize { + Arc::as_ptr(t) as usize +} + +fn content_hash(t: &Tile) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + t.bytes().hash(&mut h); + h.finish() +} + +/// Every stored tile of `s` with its shared-pixels group. Groups are numbered in tile order, so +/// the same surface always gets the same colours. `parallel` hashes on the Rayon pool; pass +/// false while jobs hold it, so the UI thread never waits on them (#1554). +pub fn tile_infos(s: &Surface, parallel: bool) -> Vec { + // Hash each distinct allocation once; copy-on-write clones share one. + let mut by_ptr: HashMap> = HashMap::new(); + for (_, t) in s.tiles() { + by_ptr.entry(ptr(t)).or_insert(t); + } + let distinct: Vec<(usize, &Arc)> = by_ptr.into_iter().collect(); + let hashed: Vec<(usize, u64)> = if parallel { + distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect() + } else { + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; + let hash_of: HashMap = hashed.into_iter().collect(); + let mut count: HashMap = HashMap::new(); + for (_, t) in s.tiles() { + if let Some(h) = hash_of.get(&ptr(t)) { + *count.entry(*h).or_default() += 1; + } + } + let mut group_of: HashMap = HashMap::new(); + s.tiles() + .map(|(c, t)| { + let group = hash_of.get(&ptr(t)).filter(|h| count.get(h).is_some_and(|&n| n > 1)).map(|h| { + let next = group_of.len(); + *group_of.entry(*h).or_insert(next) + }); + TileInfo { coord: *c, group } + }) + .collect() +} + +/// Hues (0..1) left to unique tiles: shared groups never land in the blues, nor in the +/// blue-violets that read as blue next to them ([`UNIQUE`] is about 0.58). +const BLUE_HUES: (f32, f32) = (0.45, 0.75); +/// Gap kept from either end of the allowed range, as a share of it. +const HUE_MARGIN: f32 = 0.05; + +/// The hue of shared group `g`: golden-ratio steps keep neighbouring group numbers far apart, +/// spread over the wheel outside [`BLUE_HUES`] and clear of its edges. +fn group_hue(g: usize) -> f32 { + let step = (g as f32 * 0.618_034).fract(); + let span = 1.0 - (BLUE_HUES.1 - BLUE_HUES.0); + (BLUE_HUES.1 + (HUE_MARGIN + step * (1.0 - 2.0 * HUE_MARGIN)) * span).fract() +} + +/// The colour of a tile group: blue for unique tiles, an evenly spread hue per shared group. +pub fn group_color(group: Option) -> Color32 { + let Some(g) = group else { return UNIQUE }; + egui::ecolor::Hsva::new(group_hue(g), 0.85, 0.95, 1.0).into() +} + +/// The tile cells of the grid that touch `r`, as an inclusive (tx0, ty0, tx1, ty1) range. +pub fn cell_range(r: DRect) -> Option<(i32, i32, i32, i32)> { + if r.is_empty() { + return None; + } + let (a, b) = (TileCoord::containing(r.x0, r.y0), TileCoord::containing(r.x1 - 1, r.y1 - 1)); + Some((a.tx, a.ty, b.tx, b.ty)) +} + +fn quad(xf: &ViewXform, r: DRect) -> [egui::Pos2; 4] { + let (x0, y0, x1, y1) = (r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32); + [xf.to_screen(x0, y0), xf.to_screen(x1, y0), xf.to_screen(x1, y1), xf.to_screen(x0, y1)] +} + +/// Draw the active layer's tiles of document `idx`. `visible` is the document area in view. +pub fn paint(app: &PhotocraftApp, ctx: &egui::Context, painter: &Painter, xf: &ViewXform, idx: usize, visible: DRect) { + let Some(st) = app.session.documents().get(idx) else { return }; + let Some(surface) = st.active_layer.and_then(|id| st.doc.layer(id)).and_then(|l| l.surface()) else { return }; + let revision = st.revision; + let mem = egui::Id::new(("pc-tile-overlay", st.doc.id.0, st.active_layer.map(|l| l.0))); + let cached = ctx.data(|d| d.get_temp::(mem)).filter(|c| c.revision == revision); + let tiles = match cached { + Some(c) => c.tiles, + None => { + let tiles = Arc::new(tile_infos(surface, !app.session.has_jobs())); + ctx.data_mut(|d| d.insert_temp(mem, Cached { revision, tiles: tiles.clone() })); + tiles + } + }; + + // The grid over the document, so the cells without a stored tile show too. + let bounds = st.doc.bounds(); + if let Some((tx0, ty0, tx1, ty1)) = cell_range(visible.intersect(&bounds)) { + let line = Stroke::new(1.0, EMPTY_LINE); + let edge = |t: i32| t.saturating_mul(TILE_SIZE); + let (top, bottom) = (bounds.y0.max(edge(ty0)) as f32, bounds.y1.min(edge(ty1.saturating_add(1))) as f32); + let (left, right) = (bounds.x0.max(edge(tx0)) as f32, bounds.x1.min(edge(tx1.saturating_add(1))) as f32); + for tx in tx0..=tx1.saturating_add(1) { + let x = edge(tx).clamp(bounds.x0, bounds.x1) as f32; + painter.line_segment([xf.to_screen(x, top), xf.to_screen(x, bottom)], line); + } + for ty in ty0..=ty1.saturating_add(1) { + let y = edge(ty).clamp(bounds.y0, bounds.y1) as f32; + painter.line_segment([xf.to_screen(left, y), xf.to_screen(right, y)], line); + } + } + + // Stored tiles, which may lie past the canvas edge. + let size = TILE_SIZE as f32 * xf.zoom; + for t in tiles.iter() { + let r = t.coord.rect(); + if r.intersect(&visible).is_empty() { + continue; + } + let c = group_color(t.group); + let pts = quad(xf, r).to_vec(); + let fill = Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), FILL_ALPHA); + painter.add(Shape::convex_polygon(pts.clone(), fill, Stroke::NONE)); + painter.add(Shape::closed_line(pts, Stroke::new(1.0, c))); + if size >= LABEL_MIN { + let mid = xf.to_screen(r.x0 as f32 + TILE_SIZE as f32 / 2.0, r.y0 as f32 + TILE_SIZE as f32 / 2.0); + let label = format!("{},{}", t.coord.tx, t.coord.ty); + let font = FontId::monospace((size / 8.0).clamp(10.0, 16.0)); + // A dark drop shadow keeps the label readable over light pixels. + painter.text(mid + egui::vec2(1.0, 1.0), Align2::CENTER_CENTER, &label, font.clone(), Color32::from_black_alpha(180)); + painter.text(mid, Align2::CENTER_CENTER, label, font, Color32::WHITE); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::PixelFormat; + + /// The document rect of tile cell (tx, ty). + fn cell(tx: i32, ty: i32) -> DRect { + TileCoord::new(tx, ty).rect() + } + + #[test] + fn empty_surface_has_no_tiles() { + assert!(tile_infos(&Surface::new(PixelFormat::RGBA8), true).is_empty()); + } + + #[test] + fn identical_pixels_share_a_group_and_unique_tiles_have_none() { + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + identical_pixels_share_a_group_in(fmt); + } + } + + fn identical_pixels_share_a_group_in(fmt: PixelFormat) { + let mut s = Surface::new(fmt); + // Two separately allocated tiles with the same red pixels, as after loading a .pcraft. + s.fill_rect(cell(0, 0), &[1.0, 0.0, 0.0, 1.0]); + s.fill_rect(cell(2, 0), &[1.0, 0.0, 0.0, 1.0]); + // One shared allocation in two cells (copy-on-write), and a lone green tile. + let blue = s.solid_tile(&[0.0, 0.0, 1.0, 1.0]); + s.put_tiles([(TileCoord::new(0, 1), blue.clone()), (TileCoord::new(1, 1), blue)]); + s.fill_rect(cell(1, 0), &[0.0, 1.0, 0.0, 1.0]); + for parallel in [true, false] { + let infos = tile_infos(&s, parallel); + let group = |tx, ty| infos.iter().find(|i| i.coord == TileCoord::new(tx, ty)).map(|i| i.group); + assert_eq!(infos.len(), 5); + assert_eq!(group(1, 0), Some(None), "green is unique"); + let (red, blue) = (group(0, 0).flatten(), group(0, 1).flatten()); + assert!(red.is_some() && blue.is_some() && red != blue); + assert_eq!(group(2, 0).flatten(), red); + assert_eq!(group(1, 1).flatten(), blue); + } + } + + #[test] + fn groups_get_distinct_colours_and_unique_tiles_stay_blue() { + assert_eq!(group_color(None), UNIQUE); + let colours: Vec = (0..6).map(|g| group_color(Some(g))).collect(); + for (i, a) in colours.iter().enumerate() { + assert_ne!(*a, UNIQUE); + assert!(colours.iter().skip(i + 1).all(|b| b != a)); + } + // Huge group numbers still map to a colour. + let _ = group_color(Some(usize::MAX)); + } + + #[test] + fn shared_groups_never_take_the_blue_of_unique_tiles() { + for g in (0..10_000).chain([usize::MAX]) { + let h = group_hue(g); + assert!((0.0..1.0).contains(&h), "group {g}: hue {h}"); + assert!(!(BLUE_HUES.0..=BLUE_HUES.1).contains(&h), "group {g}: hue {h} is blue"); + // Clear of the range's edges too, where hues still read as blue. + assert!((h - BLUE_HUES.1).abs() > 0.01 && (h - BLUE_HUES.0).abs() > 0.01, "group {g}: hue {h} is at the edge"); + } + } + + /// `cargo test --release -p photocraft-ui-egui --lib tile_grouping_36mp -- --ignored --nocapture` + #[test] + #[ignore = "benchmark"] + fn tile_grouping_36mp() { + let (w, h) = (7360, 4912); + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + // Every tile different, the worst case: each one is hashed and none share. + let mut s = Surface::new(fmt); + let Some((tx1, ty1)) = cell_range(DRect::from_xywh(0, 0, w, h)).map(|(_, _, tx1, ty1)| (tx1, ty1)) else { return }; + for ty in 0..=ty1 { + for tx in 0..=tx1 { + let seed = (tx * 31 + ty * 17) as usize; + for (i, b) in s.tile_mut(TileCoord::new(tx, ty)).bytes_mut().iter_mut().enumerate() { + *b = (i ^ seed) as u8; + } + } + } + for parallel in [true, false] { + let mut best = f64::MAX; + for _ in 0..8 { + let t = std::time::Instant::now(); + std::hint::black_box(tile_infos(&s, parallel)); + best = best.min(t.elapsed().as_secs_f64() * 1000.0); + } + let mode = if parallel { "parallel" } else { "serial" }; + println!("36 MP {fmt:?}, {} tiles, {mode}: {best:.1} ms", s.tile_count()); + } + } + } + + #[test] + fn cell_range_covers_the_cells_a_rect_touches() { + let t = TILE_SIZE; + // Exactly one cell, then a partial cell past whole ones (a canvas not a multiple of it). + assert_eq!(cell_range(cell(0, 0)), Some((0, 0, 0, 0))); + assert_eq!(cell_range(DRect::new(0, 0, 7 * t + t / 2, 4 * t + 1)), Some((0, 0, 7, 4))); + // One pixel either side of a cell edge, including left of the origin. + assert_eq!(cell_range(DRect::new(-1, t - 1, t + 1, t + 4)), Some((-1, 0, 1, 1))); + assert_eq!(cell_range(DRect::EMPTY), None); + } +} diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 33fc6affc5..585e5dd074 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -96,6 +96,9 @@ pub struct ViewOptions { pub middle_eastern_composer: bool, /// Parameters from the last successfully applied New Guide Layout dialog. pub guide_layout: Value, + /// Developer overlay of the active layer's storage tiles (tile_overlay.rs), set with the + /// control channel's `ui.set {"tileGrid": true}`. + pub tile_grid: bool, } /// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions). @@ -121,6 +124,7 @@ impl Default for ViewOptions { language_features: "defaultFeatures".into(), middle_eastern_composer: false, guide_layout: default_guide_layout(), + tile_grid: false, } } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f698ee2a6c..166a61210c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,6 +52,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once. Window ends with the open documents as `window.document.` (tab order, the active one checked, #2991); invoking one brings that document to the front and replies `{document: index}` - `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar. - `ui.set` also accepts `camTransformOnDrop` (boolean), the Content-Aware Move tool's Transform On Drop checkbox: a drop then puts a scale/rotate box on the patch (`ui.inspect` › `camTransform`: its document/revision, drag `offset` and `frame`), driven with `ui.pointer` (handles as Free Transform's; outside the box rotates), committed with `ui.key Enter` (one `paint.contentAwareMove` with `scaleX`, `scaleY`, `rotate`) or dropped with `Escape`. +- `ui.set` also accepts `tileGrid` (boolean), a developer overlay of the active layer's storage tiles: the tile grid over the document, every stored tile filled and labelled `tx,ty`, tiles with identical pixels in a shared colour and unique ones in blue. It has no menu item and View › Extras doesn't hide it. `ui.inspect` reports it as `view.tile_grid`. - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. diff --git a/docs/development.md b/docs/development.md index 73a3bde0bf..6be2bd050b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -144,6 +144,7 @@ See `docs/control-protocol.md` for every method. Tips: - **macOS does not render occluded windows.** `ui.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before other visual checks. - **Capture only our own window.** For native captures (to see the real title bar and traffic lights), capture by window id (`screencapture -l `), never a screen region, which can grab other apps. - **Pointer gestures:** `ui.pointer` takes events in *document* coordinates and runs them through the same tool state machine as the mouse. +- **Seeing tile storage:** `ui.set {"tileGrid": true}` overlays the active layer's stored tiles, which shows how sparse a layer is and which tiles share pixels (`ui-egui/src/tile_overlay.rs`). ## Headless CLI From ccfc878a1484a9f630b4284c318a7ef09fc79a10 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:34:54 +0200 Subject: [PATCH 277/523] Camera Raw Filter: save and load settings as .xmp (#4279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Camera Raw Filter: save and load settings as .xmp (#4274) [WIP] * Camera Raw Filter: Save Settings… and Load Settings… (.xmp) (#4274) Adds Save Settings… and Load Settings… to the Camera Raw Filter dialog's footer. They write and read Adobe Camera Raw settings files: the crs: namespace on x:xmpmeta/rdf:RDF/rdf:Description, as attributes or elements. photocraft_io::camera_raw_xmp maps every control the dialog has: - Basic (Exposure2012 … Saturation, IncrementalTemperature/Tint) - the parametric and point tone curves (ToneCurvePV2012[Red|Green|Blue]) - the HSL mixer - Color Grading (including the SplitToning* names for the shadow and highlight wheels) - sharpening and noise reduction - grain - post-crop vignetting Loading applies only what the file holds over the current settings, as Camera Raw does. Values are clamped to the slider ranges. Unsupported settings (lens, calibration, geometry, defringe) are ignored, and the status bar counts them when they aren't neutral. Malformed files fail with a message and leave the settings unchanged. The control channel takes {"ui":{"saveSettings"|"loadSettings":true}}. Translations are added for all 17 catalogs. Fixes #4274 * Add Turkish and Hungarian rows for the Camera Raw settings strings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/io/src/camera_raw_xmp.rs | 423 ++++++++++++++++++++++++++++ crates/io/src/lib.rs | 1 + crates/ui-egui/src/camera_raw_ui.rs | 141 +++++++++- crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/hu.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + 22 files changed, 676 insertions(+), 3 deletions(-) create mode 100644 crates/io/src/camera_raw_xmp.rs diff --git a/crates/io/src/camera_raw_xmp.rs b/crates/io/src/camera_raw_xmp.rs new file mode 100644 index 0000000000..159e298a06 --- /dev/null +++ b/crates/io/src/camera_raw_xmp.rs @@ -0,0 +1,423 @@ +//! Camera Raw Filter › Save Settings… / Load Settings… (#4274): Adobe Camera Raw settings files +//! (`.xmp`), the `crs:` namespace (`http://ns.adobe.com/camera-raw-settings/1.0/`) on an +//! `x:xmpmeta/rdf:RDF/rdf:Description`, as attributes or as child elements. +//! +//! Loading applies only the settings the file holds, over the current ones, as Camera Raw's +//! Load Settings does; settings PhotoCraft has no control for (lens corrections, calibration, +//! geometry, defringe, …) are ignored and named in the report. Saving writes every supported +//! control, so loading the file in Photoshop reproduces the dialog. +//! +//! White balance: a Camera Raw Filter on a rendered image uses the relative +//! `IncrementalTemperature` / `IncrementalTint` (−100..=100), which are PhotoCraft's Temperature +//! and Tint. The absolute `Temperature` (kelvin) / `Tint` of raw-file settings have no +//! counterpart and are ignored. + +use photocraft_algo::camera_raw::{CameraRaw, validate_curve}; + +/// The Camera Raw settings namespace. +pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/"; +const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +/// Largest settings file read; real ones are a few kilobytes. +pub const MAX_XMP_BYTES: usize = 4 * 1024 * 1024; + +type Field = fn(&mut CameraRaw) -> &mut f32; + +/// `crs:` scalar name, PhotoCraft field, and the slider range values are clamped to. +const SCALARS: &[(&str, Field, f32, f32)] = &[ + ("IncrementalTemperature", |p| &mut p.temperature, -100.0, 100.0), + ("IncrementalTint", |p| &mut p.tint, -100.0, 100.0), + ("Exposure2012", |p| &mut p.exposure, -5.0, 5.0), + ("Contrast2012", |p| &mut p.contrast, -100.0, 100.0), + ("Highlights2012", |p| &mut p.highlights, -100.0, 100.0), + ("Shadows2012", |p| &mut p.shadows, -100.0, 100.0), + ("Whites2012", |p| &mut p.whites, -100.0, 100.0), + ("Blacks2012", |p| &mut p.blacks, -100.0, 100.0), + ("Texture", |p| &mut p.texture, -100.0, 100.0), + ("Clarity2012", |p| &mut p.clarity, -100.0, 100.0), + ("Dehaze", |p| &mut p.dehaze, -100.0, 100.0), + ("Vibrance", |p| &mut p.vibrance, -100.0, 100.0), + ("Saturation", |p| &mut p.saturation, -100.0, 100.0), + ("ParametricShadows", |p| &mut p.curve_shadows, -100.0, 100.0), + ("ParametricDarks", |p| &mut p.curve_darks, -100.0, 100.0), + ("ParametricLights", |p| &mut p.curve_lights, -100.0, 100.0), + ("ParametricHighlights", |p| &mut p.curve_highlights, -100.0, 100.0), + ("ParametricShadowSplit", |p| &mut p.curve_splits[0], 0.0, 100.0), + ("ParametricMidtoneSplit", |p| &mut p.curve_splits[1], 0.0, 100.0), + ("ParametricHighlightSplit", |p| &mut p.curve_splits[2], 0.0, 100.0), + ("HueAdjustmentRed", |p| &mut p.hsl_hue[0], -100.0, 100.0), + ("HueAdjustmentOrange", |p| &mut p.hsl_hue[1], -100.0, 100.0), + ("HueAdjustmentYellow", |p| &mut p.hsl_hue[2], -100.0, 100.0), + ("HueAdjustmentGreen", |p| &mut p.hsl_hue[3], -100.0, 100.0), + ("HueAdjustmentAqua", |p| &mut p.hsl_hue[4], -100.0, 100.0), + ("HueAdjustmentBlue", |p| &mut p.hsl_hue[5], -100.0, 100.0), + ("HueAdjustmentPurple", |p| &mut p.hsl_hue[6], -100.0, 100.0), + ("HueAdjustmentMagenta", |p| &mut p.hsl_hue[7], -100.0, 100.0), + ("SaturationAdjustmentRed", |p| &mut p.hsl_sat[0], -100.0, 100.0), + ("SaturationAdjustmentOrange", |p| &mut p.hsl_sat[1], -100.0, 100.0), + ("SaturationAdjustmentYellow", |p| &mut p.hsl_sat[2], -100.0, 100.0), + ("SaturationAdjustmentGreen", |p| &mut p.hsl_sat[3], -100.0, 100.0), + ("SaturationAdjustmentAqua", |p| &mut p.hsl_sat[4], -100.0, 100.0), + ("SaturationAdjustmentBlue", |p| &mut p.hsl_sat[5], -100.0, 100.0), + ("SaturationAdjustmentPurple", |p| &mut p.hsl_sat[6], -100.0, 100.0), + ("SaturationAdjustmentMagenta", |p| &mut p.hsl_sat[7], -100.0, 100.0), + ("LuminanceAdjustmentRed", |p| &mut p.hsl_lum[0], -100.0, 100.0), + ("LuminanceAdjustmentOrange", |p| &mut p.hsl_lum[1], -100.0, 100.0), + ("LuminanceAdjustmentYellow", |p| &mut p.hsl_lum[2], -100.0, 100.0), + ("LuminanceAdjustmentGreen", |p| &mut p.hsl_lum[3], -100.0, 100.0), + ("LuminanceAdjustmentAqua", |p| &mut p.hsl_lum[4], -100.0, 100.0), + ("LuminanceAdjustmentBlue", |p| &mut p.hsl_lum[5], -100.0, 100.0), + ("LuminanceAdjustmentPurple", |p| &mut p.hsl_lum[6], -100.0, 100.0), + ("LuminanceAdjustmentMagenta", |p| &mut p.hsl_lum[7], -100.0, 100.0), + // Color Grading keeps the split-toning names for the shadow and highlight wheels' hue and + // saturation, and the balance. + ("SplitToningShadowHue", |p| &mut p.grade_shadows.hue, 0.0, 360.0), + ("SplitToningShadowSaturation", |p| &mut p.grade_shadows.sat, 0.0, 100.0), + ("ColorGradeShadowLum", |p| &mut p.grade_shadows.lum, -100.0, 100.0), + ("ColorGradeMidtoneHue", |p| &mut p.grade_midtones.hue, 0.0, 360.0), + ("ColorGradeMidtoneSat", |p| &mut p.grade_midtones.sat, 0.0, 100.0), + ("ColorGradeMidtoneLum", |p| &mut p.grade_midtones.lum, -100.0, 100.0), + ("SplitToningHighlightHue", |p| &mut p.grade_highlights.hue, 0.0, 360.0), + ("SplitToningHighlightSaturation", |p| &mut p.grade_highlights.sat, 0.0, 100.0), + ("ColorGradeHighlightLum", |p| &mut p.grade_highlights.lum, -100.0, 100.0), + ("ColorGradeGlobalHue", |p| &mut p.grade_global.hue, 0.0, 360.0), + ("ColorGradeGlobalSat", |p| &mut p.grade_global.sat, 0.0, 100.0), + ("ColorGradeGlobalLum", |p| &mut p.grade_global.lum, -100.0, 100.0), + ("ColorGradeBlending", |p| &mut p.grade_blending, 0.0, 100.0), + ("SplitToningBalance", |p| &mut p.grade_balance, -100.0, 100.0), + ("Sharpness", |p| &mut p.sharpen_amount, 0.0, 150.0), + ("SharpenRadius", |p| &mut p.sharpen_radius, 0.5, 3.0), + ("SharpenDetail", |p| &mut p.sharpen_detail, 0.0, 100.0), + ("SharpenEdgeMasking", |p| &mut p.sharpen_masking, 0.0, 100.0), + ("LuminanceSmoothing", |p| &mut p.noise_luminance, 0.0, 100.0), + ("LuminanceNoiseReductionDetail", |p| &mut p.noise_luminance_detail, 0.0, 100.0), + ("ColorNoiseReduction", |p| &mut p.noise_color, 0.0, 100.0), + ("ColorNoiseReductionDetail", |p| &mut p.noise_color_detail, 0.0, 100.0), + ("GrainAmount", |p| &mut p.grain_amount, 0.0, 100.0), + ("GrainSize", |p| &mut p.grain_size, 0.0, 100.0), + ("GrainFrequency", |p| &mut p.grain_roughness, 0.0, 100.0), + ("PostCropVignetteAmount", |p| &mut p.vignette_amount, -100.0, 100.0), + ("PostCropVignetteMidpoint", |p| &mut p.vignette_midpoint, 0.0, 100.0), + ("PostCropVignetteRoundness", |p| &mut p.vignette_roundness, -100.0, 100.0), + ("PostCropVignetteFeather", |p| &mut p.vignette_feather, 0.0, 100.0), + ("PostCropVignetteHighlightContrast", |p| &mut p.vignette_highlights, 0.0, 100.0), +]; + +type Curve = fn(&mut CameraRaw) -> &mut Vec<[f32; 2]>; +const CURVES: &[(&str, Curve)] = &[ + ("ToneCurvePV2012", |p| &mut p.point_curve), + ("ToneCurvePV2012Red", |p| &mut p.point_curve_red), + ("ToneCurvePV2012Green", |p| &mut p.point_curve_green), + ("ToneCurvePV2012Blue", |p| &mut p.point_curve_blue), +]; + +const STYLES: &[(&str, &str)] = &[("1", "highlightPriority"), ("2", "colorPriority"), ("3", "paintOverlay")]; + +/// Bookkeeping fields Camera Raw writes that carry no setting. +const METADATA: &[&str] = &[ + "Version", + "ProcessVersion", + "HasSettings", + "HasCrop", + "WhiteBalance", + "ToneCurveName2012", + "CameraProfile", + "PresetType", + "UUID", + "SupportsAmount", + "SupportsColor", + "SupportsMonochrome", + "SupportsHighDynamicRange", + "SupportsNormalDynamicRange", + "SupportsSceneReferred", + "SupportsOutputReferred", + "CameraModelRestriction", + "Copyright", + "ContactInfo", + "Name", + "ShortName", + "SortName", + "Group", + "Description", +]; + +/// What [`load`] applied. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LoadReport { + /// How many settings were applied. + pub applied: usize, + /// `crs:` settings without a PhotoCraft control (set to something other than their neutral + /// value), or with an unusable value, in file order. + pub ignored: Vec, +} + +fn number(s: &str) -> Option { + let s = s.trim(); + let v: f32 = s.strip_prefix('+').unwrap_or(s).parse().ok()?; + v.is_finite().then_some(v) +} + +/// `"0, 0"` items of an `rdf:Seq` → curve points. +fn curve(node: roxmltree::Node<'_, '_>) -> Option> { + let seq = node.children().find(|c| c.is_element() && c.tag_name().namespace() == Some(RDF_NS) && c.tag_name().name() == "Seq")?; + let mut points = Vec::new(); + for li in seq.children().filter(|c| c.is_element() && c.tag_name().name() == "li") { + let text = li.text().unwrap_or(""); + let (x, y) = text.split_once(',')?; + points.push([number(x)?, number(y)?]); + } + validate_curve(&points).ok()?; + // Camera Raw's linear curve is the identity's two end points; PhotoCraft's is empty. + if points == [[0.0, 0.0], [255.0, 255.0]] { + points.clear(); + } + Some(points) +} + +/// An unsupported setting at its neutral value changes nothing, so it isn't reported: zero, +/// `False`, and the few neutral values that aren't zero. +fn neutral(name: &str, value: &str) -> bool { + let v = value.trim(); + if v.eq_ignore_ascii_case("false") || number(v) == Some(0.0) { + return true; + } + match name { + "GrainSeed" => true, + "VignetteMidpoint" => number(v) == Some(50.0), + "PerspectiveScale" => number(v) == Some(100.0), + _ => name.starts_with("Defringe") && name.contains("Hue"), + } +} + +fn apply(params: &mut CameraRaw, report: &mut LoadReport, name: &str, value: &str) { + if let Some((_, field, lo, hi)) = SCALARS.iter().find(|(n, ..)| *n == name) { + match number(value) { + Some(v) => { + *field(params) = v.clamp(*lo, *hi); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if name == "PostCropVignetteStyle" { + match STYLES.iter().find(|(n, _)| *n == value.trim()) { + Some((_, style)) => { + params.vignette_style = (*style).into(); + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if !METADATA.contains(&name) && !neutral(name, value) { + report.ignored.push(name.to_string()); + } +} + +/// Apply a Camera Raw settings file over `params`. Fails on input that isn't XMP or holds no +/// Camera Raw settings; never panics on malformed input. +pub fn load(params: &mut CameraRaw, bytes: &[u8]) -> Result { + if bytes.len() > MAX_XMP_BYTES { + return Err("the settings file is too large".into()); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a Camera Raw settings file (not UTF-8 text)".to_string())?; + // Some writers put a byte order mark or an `` wrapper first; roxmltree takes both. + let text = text.trim_start_matches('\u{feff}'); + let doc = roxmltree::Document::parse(text).map_err(|e| format!("not a Camera Raw settings file ({e})"))?; + let mut next = params.clone(); + let mut report = LoadReport::default(); + let mut found = false; + for desc in doc.descendants().filter(|n| n.is_element() && n.tag_name().namespace() == Some(RDF_NS) && n.tag_name().name() == "Description") { + for a in desc.attributes().filter(|a| a.namespace() == Some(CRS_NS)) { + found = true; + apply(&mut next, &mut report, a.name(), a.value()); + } + for c in desc.children().filter(|c| c.is_element() && c.tag_name().namespace() == Some(CRS_NS)) { + found = true; + let name = c.tag_name().name(); + if let Some((_, field)) = CURVES.iter().find(|(n, _)| *n == name) { + match curve(c) { + Some(points) => { + *field(&mut next) = points; + report.applied += 1; + } + None => report.ignored.push(name.to_string()), + } + } else if c.children().any(|g| g.is_element()) { + // Structured settings (masks, looks, lens profiles…) have no counterpart. + if !METADATA.contains(&name) { + report.ignored.push(name.to_string()); + } + } else { + apply(&mut next, &mut report, name, c.text().unwrap_or("")); + } + } + } + if !found { + return Err("no Camera Raw settings in the file".into()); + } + *params = next; + Ok(report) +} + +fn signed(v: f32, decimals: usize) -> String { + let s = format!("{v:.decimals$}"); + if v > 0.0 && !s.starts_with('+') { format!("+{s}") } else { s } +} + +fn escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Write `params` as a Camera Raw settings file Photoshop's Load Settings… reads. +pub fn save(params: &CameraRaw) -> String { + let mut p = params.clone(); + let wb = if p.temperature != 0.0 || p.tint != 0.0 { "Custom" } else { "As Shot" }; + let mut out = String::from( + "\n \n signed(v, 2), + "SharpenRadius" => signed(v, 1), + _ if *lo < 0.0 => signed(v.round(), 0), + _ => format!("{:.0}", v.round()), + }; + out.push_str(&format!(" crs:{name}=\"{text}\"\n")); + } + let style = STYLES.iter().find(|(_, s)| *s == p.vignette_style).map_or("1", |(n, _)| n); + out.push_str(&format!(" crs:PostCropVignetteStyle=\"{}\"\n", escape(style))); + out.push_str(" crs:ToneCurveName2012=\"Custom\"\n crs:HasSettings=\"True\">\n"); + for (name, field) in CURVES { + let points = field(&mut p); + let points = if points.is_empty() { vec![[0.0, 0.0], [255.0, 255.0]] } else { points.clone() }; + out.push_str(&format!(" \n \n")); + for [x, y] in points { + out.push_str(&format!(" {}, {}\n", x.round(), y.round())); + } + out.push_str(&format!(" \n \n")); + } + out.push_str(" \n \n\n"); + out +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + /// A hand-written settings file laid out as Photoshop 26's Camera Raw Filter saves one (the + /// field set and order follow Camera Raw 18; the values are synthetic), plus element-form + /// settings, which XMP allows and some writers use. + const FIXTURE: &[u8] = br#" + + + +12 + + + 0, 0 + 255, 255 + + + + + 0, 0 + 128, 140 + 255, 255 + + + + +"#; + + #[test] + fn reads_a_photoshop_preset() { + let mut p = CameraRaw::default(); + let r = load(&mut p, FIXTURE).unwrap(); + assert_eq!(p.tint, 10.0); + assert_eq!(p.exposure, 0.05); + assert_eq!((p.contrast, p.highlights, p.shadows, p.whites), (-5.0, -20.0, 10.0, 35.0)); + assert_eq!((p.texture, p.clarity, p.vibrance, p.saturation), (30.0, 5.0, 20.0, -5.0)); + assert_eq!((p.hsl_hue[3], p.hsl_sat[3]), (25.0, 20.0)); + assert_eq!((p.sharpen_amount, p.noise_luminance, p.grain_amount), (15.0, 5.0, 10.0)); + assert_eq!(p.grade_blending, 50.0); + // The linear tone curve is PhotoCraft's empty one; an element-form setting is read too. + assert!(p.point_curve.is_empty()); + assert_eq!(p.point_curve_red, vec![[0.0, 0.0], [128.0, 140.0], [255.0, 255.0]]); + assert_eq!(p.dehaze, 12.0); + // Lens vignetting, calibration and geometry have no counterpart. + assert_eq!(r.ignored, vec!["VignetteAmount".to_string(), "RedHue".to_string()]); + } + + #[test] + fn save_then_load_round_trips() { + let mut p = CameraRaw { + temperature: -12.0, + exposure: 1.25, + contrast: 30.0, + sharpen_radius: 1.4, + vignette_amount: -40.0, + vignette_style: "paintOverlay".into(), + curve_splits: [20.0, 45.0, 80.0], + point_curve: vec![[0.0, 10.0], [128.0, 150.0], [255.0, 240.0]], + ..CameraRaw::default() + }; + p.hsl_lum[5] = -33.0; + p.grade_shadows.hue = 210.0; + p.grade_shadows.sat = 40.0; + p.grade_global.lum = 7.0; + let text = save(&p); + assert!(text.contains("crs:Exposure2012=\"+1.25\"")); + assert!(text.contains("crs:WhiteBalance=\"Custom\"")); + let mut back = CameraRaw::default(); + let r = load(&mut back, text.as_bytes()).unwrap(); + assert!(r.ignored.is_empty(), "{:?}", r.ignored); + assert_eq!(back, p); + } + + #[test] + fn load_applies_only_the_settings_in_the_file() { + let mut p = CameraRaw { contrast: 40.0, ..CameraRaw::default() }; + let xmp = br#" + -9 + abc"#; + let r = load(&mut p, xmp).unwrap(); + assert_eq!((p.exposure, p.contrast, p.clarity), (-5.0, 40.0, 0.0)); + assert_eq!(r, LoadReport { applied: 1, ignored: vec!["Clarity2012".into()] }); + } + + #[test] + fn malformed_input_is_an_error_and_leaves_settings_alone() { + let before = CameraRaw { exposure: 0.5, ..CameraRaw::default() }; + for bad in [ + &b""[..], + b"\xff\xfe\x00garbage", + b"", + b"]>&e;", + ] { + let mut p = before.clone(); + assert!(load(&mut p, bad).is_err()); + assert_eq!(p, before); + } + // A broken curve or NaN is skipped, not fatal. + let xmp = br#" + 10"#; + let mut p = before.clone(); + let r = load(&mut p, xmp).unwrap(); + assert_eq!(p, before); + assert_eq!(r.ignored, vec!["Contrast2012".to_string(), "ToneCurvePV2012".to_string()]); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 9852a48516..b6120eb088 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -36,6 +36,7 @@ mod adjust_map; pub mod affinity; pub mod annotations_map; pub mod blocks; +pub mod camera_raw_xmp; mod channel_map; pub mod comps_map; pub mod effects_map; diff --git a/crates/ui-egui/src/camera_raw_ui.rs b/crates/ui-egui/src/camera_raw_ui.rs index c939714d9c..80ce77f4b2 100644 --- a/crates/ui-egui/src/camera_raw_ui.rs +++ b/crates/ui-egui/src/camera_raw_ui.rs @@ -7,7 +7,9 @@ //! one history step (or a smart filter on a smart object) and exactly replayable. //! //! Control channel: `ui.menu.invoke {"id":"filter.cameraRaw","params":{"ui":{"set":{…}, -//! "commit":true | "cancel":true}}}`; the reply describes the dialog. +//! "commit":true | "cancel":true}}}`; the reply describes the dialog. `{"ui":{"saveSettings":true}}` +//! and `{"ui":{"loadSettings":true}}` run Save Settings… / Load Settings… (Camera Raw `.xmp` +//! settings files, see [`photocraft_io::camera_raw_xmp`]) through the file dialog. use egui::{Align2, Color32, FontId, Pos2, Rect as ERect, Sense, Stroke, TextureHandle, pos2, vec2}; use photocraft_algo::camera_raw::{CameraRaw, Wheel, curve_lut}; @@ -669,14 +671,22 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val for (key, value) in fields { match key.as_str() { "set" | "scope" | "view" => {} - "before" | "commit" | "cancel" if value.is_boolean() => {} - "before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" if value.is_boolean() => {} + "before" | "commit" | "cancel" | "saveSettings" | "loadSettings" => return Some(Err(format!("Camera Raw {key} must be a boolean"))), _ => return Some(Err(format!("unknown Camera Raw ui property {key}"))), } } if ui.get("commit").and_then(Value::as_bool) == Some(true) && ui.get("cancel").and_then(Value::as_bool) == Some(true) { return Some(Err("Camera Raw cannot commit and cancel together".into())); } + for (key, run) in [("saveSettings", save_settings as fn(&mut PhotocraftApp) -> Result), ("loadSettings", load_settings)] { + if ui.get(key).and_then(Value::as_bool) == Some(true) { + if fields.len() > 1 { + return Some(Err(format!("Camera Raw {key} must be sent on its own"))); + } + return Some(run(app)); + } + } let was_closed = app.camera_raw.is_none(); let previous_view = app.ui.camera_raw_scope.clone(); let previous_navigation = app.ui.camera_raw_preview; @@ -766,6 +776,52 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))) } +/// Save Settings…: the dialog's settings as a Camera Raw `.xmp` settings file. +fn save_settings(app: &mut PhotocraftApp) -> Result { + let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + let bytes = photocraft_io::camera_raw_xmp::save(&d.params).into_bytes(); + app.pick_save("Camera Raw Settings.xmp", move |app, path| { + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(&path, &bytes)?; + Ok(json!({"path": path})) + }) +} + +/// Load Settings…: applies a Camera Raw `.xmp` settings file's supported settings over the +/// dialog's; the status bar names how many were not supported. +fn load_settings(app: &mut PhotocraftApp) -> Result { + app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; + app.pick_file_bytes_filtered(&["xmp"], |app, name, bytes| { + let file = crate::file_open::display_name(&name); + let d = app.camera_raw.as_mut().ok_or(tl!("Camera Raw isn't open"))?; + let mut next = d.params.clone(); + let report = photocraft_io::camera_raw_xmp::load(&mut next, &bytes).map_err(|e| format!("{file}: {e}"))?; + next.validate_changes(&d.original).map_err(|e| format!("{file}: {e}"))?; + if d.params.point_curve != next.point_curve { + d.curve_state = Default::default(); + } + d.dirty |= d.params != next; + d.params = next; + app.ui.status_error = false; + app.ui.status = if report.ignored.is_empty() { + crate::i18n::fmt(tl!("Loaded Camera Raw settings from {file}"), &[("file", &file)]) + } else { + let template = "Loaded Camera Raw settings from {file} ({n} not supported)"; + crate::i18n::fmt(tl!(template), &[("file", &file), ("n", &report.ignored.len().to_string())]) + }; + Ok(json!({"applied": report.applied, "ignored": report.ignored})) + }) +} + +fn report_file_error(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r + && e != crate::file_dialog::CANCELLED + { + app.ui.status_error = true; + app.ui.status = e; + } +} + fn commit(app: &mut PhotocraftApp) -> Result { let d = app.camera_raw.as_ref().ok_or(tl!("Camera Raw isn't open"))?; let result = match d.target { @@ -1133,6 +1189,13 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { fu.add_space(12.0); widgets::checkbox(&mut fu, &mut d.show_before, tl!("Before (Y)")); fu.label(egui::RichText::new(format!("{:.0} ms", d.render_ms)).color(t.text_faint)); + fu.add_space(12.0); + if fu.button(tl!("Save Settings…")).on_hover_text(tl!("Save these settings as a Camera Raw .xmp file")).clicked() { + action = Some("save"); + } + if fu.button(tl!("Load Settings…")).on_hover_text(tl!("Load settings from a Camera Raw .xmp file")).clicked() { + action = Some("load"); + } }); super::camera_raw_scope_ui::persist(app, ctx); if ctx.input(|i| i.key_pressed(egui::Key::Y)) @@ -1150,6 +1213,14 @@ fn draw(app: &mut PhotocraftApp, ctx: &egui::Context, own_window: bool) { } } Some("cancel") => cancel(app), + Some("save") => { + let r = save_settings(app); + report_file_error(app, r); + } + Some("load") => { + let r = load_settings(app); + report_file_error(app, r); + } _ => {} } } @@ -1479,4 +1550,68 @@ mod tests { // A non-raw never queues the dialog; a closed document is an error, not a panic. assert!(open_raw(&mut app, &ctx, RawOpen { document: photocraft_doc::DocId(u64::MAX), name: "x".into(), path: None, bytes: None }).is_err()); } + + #[test] + fn save_and_load_settings_round_trip_through_xmp_files() { + use crate::file_dialog::FileDialogAnswer; + use std::sync::{Arc, Mutex}; + type Written = Arc)>>>; + let written: Written = Arc::default(); + let w = written.clone(); + let foreign = br#" + + "#; + let answers = vec![ + Some(FileDialogAnswer::SaveTo("/out/Look.xmp".into())), + Some(FileDialogAnswer::Contents("Look.xmp".into(), Vec::new())), + Some(FileDialogAnswer::Contents("Other.xmp".into(), foreign.to_vec())), + Some(FileDialogAnswer::Contents("Broken.xmp".into(), b" Date: Tue, 13 Oct 2026 02:41:23 +0100 Subject: [PATCH 278/523] feat(ui): Photoshop's type formatting keys while editing text (#4239) * feat(engine): relative leading, baseline shift and tracking steps in type.setStyle `leadingBy`, `baselineShiftBy` and `trackingBy` move every character in the range from its own value, so mixed values keep their differences. Auto leading starts from the value layout uses (the paragraph's auto-leading percentage of the character's size), results clamp to Photoshop's ranges, and non-finite or oversized steps are refused as bad params. `leadingBy` follows `metricsAsShown` like `leading`. Co-Authored-By: Claude Opus 5.5 * feat(ui): Photoshop's type formatting keys while editing text While a Type tool edit is active, Shift+Cmd+K/U/B/I and Shift+Cmd+/ toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough on the selection (off when every selected character has it, else on); Alt+Up/Down step leading by 2 pt and Shift+Alt+Up/Down baseline shift by 2 pt (10 with Cmd); Alt+Left/Right over a selection track it by 20 (100 with Cmd), a collapsed caret still kerning; Cmd+Alt+Shift+H toggles Hyphenate for the paragraphs touched. Each press is one type.setStyle history step, and the keys no longer fall through to Color Settings, Desaturate, Auto Color or Inverse while typing; outside an edit those shortcuts work as before. A collapsed caret in non-empty text changes nothing: Photoshop styles the next characters typed, which the document model can't hold apart from the text. In empty text the style becomes the typing style. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/type_cmds.rs | 136 ++++++++++++++++- crates/ui-egui/src/input_tests.rs | 245 +++++++++++++++++++++++++++++- crates/ui-egui/src/type_tool.rs | 158 +++++++++++++++++++ docs/ui-parity.md | 4 +- 4 files changed, 538 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/type_cmds.rs b/crates/engine/src/type_cmds.rs index a53b6baba4..4bf193b679 100644 --- a/crates/engine/src/type_cmds.rs +++ b/crates/engine/src/type_cmds.rs @@ -133,6 +133,81 @@ fn installed_postscript(family: &str, weight: u16, italic: bool) -> Option std::result::Result<(), String> { + for (key, max) in STEP_KEYS { + if let Some(v) = p.get(key) { + match v.as_f64() { + Some(by) if by.is_finite() && by.abs() <= max => {} + _ => return Err(format!("{key} must be a number between -{max} and {max}")), + } + } + } + Ok(()) +} + +/// Relative character steps (Photoshop's ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking +/// over a selection): every character moves from its own value, so mixed values keep their +/// differences. Auto leading starts from the value layout uses, the paragraph's auto-leading +/// percentage of the character's size. Results are clamped to Photoshop's ranges. Returns true +/// if any step key was present. +fn step_char_metrics(t: &mut TextLayer, a: usize, b: usize, p: &Value) -> bool { + let leading_by = f32p(p, "leadingBy").filter(|v| v.is_finite()); + let shift_by = f32p(p, "baselineShiftBy").filter(|v| v.is_finite()); + let tracking_by = f32p(p, "trackingBy").filter(|v| v.is_finite()); + if leading_by.is_none() && shift_by.is_none() && tracking_by.is_none() { + return false; + } + let paragraphs = t.paragraph_runs(); + let default_auto = ParagraphStyle::default().auto_leading; + // Each paragraph's share of `a..b`, with the auto leading it lays out with. + let mut pieces = Vec::new(); + let mut at = 0usize; + for pr in ¶graphs { + let (ps, pe) = (at, at.saturating_add(pr.len)); + at = pe; + let (pa, pb) = (a.clamp(ps, pe), b.clamp(ps, pe)); + if pa < pb { + pieces.push((pa, pb, pr.style.auto_leading)); + } + } + if at < b { + pieces.push((a.max(at), b, default_auto)); + } + if t.text.is_empty() { + // Empty text: the step sets the style the next typed characters get. + pieces = vec![(0, 0, paragraphs.first().map_or(default_auto, |r| r.style.auto_leading))]; + } + for (pa, pb, auto) in pieces { + style_range(t, pa, pb, &|st| { + if let Some(by) = leading_by { + let auto = if auto.is_finite() && auto > 0.0 { auto } else { default_auto }; + let base = st.leading_pt.unwrap_or(auto * st.size_pt); + let base = if base.is_finite() { base } else { 0.0 }; + st.leading_pt = Some((base + by).clamp(LEADING_MIN_PT, LEADING_MAX_PT)); + } + if let Some(by) = shift_by { + let base = if st.baseline_shift_pt.is_finite() { st.baseline_shift_pt } else { 0.0 }; + st.baseline_shift_pt = (base + by).clamp(-BASELINE_SHIFT_MAX_PT, BASELINE_SHIFT_MAX_PT); + } + if let Some(by) = tracking_by { + let base = if st.tracking.is_finite() { st.tracking } else { 0.0 }; + st.tracking = (base + by).clamp(TRACKING_MIN as f32, TRACKING_MAX as f32); + } + }); + } + true +} + /// Applies character-style keys from JSON. Returns true if any key was present. pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool { let mut any = false; @@ -582,7 +657,7 @@ fn shown_style_params<'a>(t: &TextLayer, p: &'a Value) -> Result 1e-3 { f64::from(scale) } else { 1.0 }; let mut params = p.clone(); - for key in ["size", "leading"] { + for key in ["size", "leading", "leadingBy"] { if let Some(value) = p.get(key).and_then(Value::as_f64) { let value = value / scale; if !value.is_finite() || value.abs() > f64::from(f32::MAX) { @@ -868,13 +943,14 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: Box::leak( - format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading include each layer's transform scale), {CHAR_PARAMS}, paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) + format!(r##"{{"layer":id? or "layers":[id,…]? (default selected Type layers),"range":[startChar,endChar]? (single layer; default all),"metricsAsShown":bool=false (size/leading/leadingBy include each layer's transform scale), {CHAR_PARAMS}, relative steps from each character's own value: "leadingBy":pt (auto leading starts from its laid-out value; result 0.01..=5000),"baselineShiftBy":pt (result -5000..=5000),"trackingBy":1/1000 em (result -1000..=10000), paragraph keys: "align":"left|center|right|justify|justifyCenter|justifyRight|justifyAll","firstLineIndent":pt,"startIndent":pt,"endIndent":pt,"spaceBefore":pt,"spaceAfter":pt,"autoLeading":%,"direction":"auto|ltr|rtl","hyphenate":bool}}"##) .into_boxed_str(), ), enabled: has_doc, journal: true, run: |s, p| { check_kerning(p).map_err(|m| bad("type.setStyle", m))?; + check_steps(p).map_err(|m| bad("type.setStyle", m))?; let ids = with_text_targets(s, p, "Set Type Style", |t, (a, b)| { let props = shown_style_params(t, p)?; check_size_tracking(&props).map_err(|m| bad("type.setStyle", m))?; @@ -884,6 +960,7 @@ pub fn specs() -> Vec { apply_char_props(st, &props); }); } + step_char_metrics(t, a, b, &props); let mut pprobe = ParagraphStyle::default(); if apply_para_props(&mut pprobe, p) { style_paragraphs(t, a, b, &|st| { @@ -1616,4 +1693,59 @@ mod tests { ]) ); } + + /// `leadingBy`, `baselineShiftBy` and `trackingBy` step every character from its own value + /// (auto leading from its laid-out value), clamp to Photoshop's ranges, and reject hostile + /// steps without changing anything. + #[test] + fn relative_steps_move_each_character_from_its_own_value() { + let mut s = session(); + let id = s.execute("type.create", json!({"x": 0, "y": 40, "text": "Hello\nworld", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [8, 11], "leading": 30, "tracking": 50})).unwrap(); + s.execute("type.setStyle", json!({"layer": id, "range": [6, 11], "autoLeading": 150})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + s.execute("type.setStyle", json!({"layer": id, "range": [0, 11], "leadingBy": 2, "baselineShiftBy": -2, "trackingBy": 20})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1, "one history step"); + let t = text_layer(&s, id); + let at = |c: usize| { + let byte = byte_at(&t.text, c); + let mut off = 0; + t.char_runs() + .into_iter() + .find(|r| { + off += r.len; + off > byte + }) + .unwrap() + .style + }; + // Auto leading 120% × 10 pt (first paragraph), 150% × 10 pt (second), and a set 30 pt. + assert_eq!(at(0).leading_pt, Some(14.0)); + assert_eq!(at(6).leading_pt, Some(17.0)); + assert_eq!(at(9).leading_pt, Some(32.0)); + assert_eq!((at(0).baseline_shift_pt, at(9).baseline_shift_pt), (-2.0, -2.0)); + assert_eq!((at(0).tracking, at(9).tracking), (20.0, 70.0)); + // Clamped to the ranges. + s.execute("type.setStyle", json!({"layer": id, "leadingBy": -5000, "baselineShiftBy": 10000, "trackingBy": -11000})).unwrap(); + let t = text_layer(&s, id); + assert!(t.char_runs().iter().all(|r| r.style.leading_pt == Some(LEADING_MIN_PT) + && r.style.baseline_shift_pt == BASELINE_SHIFT_MAX_PT + && r.style.tracking == TRACKING_MIN as f32)); + // A collapsed range in non-empty text changes no character. + let before = text_layer(&s, id).char_runs(); + s.execute("type.setStyle", json!({"layer": id, "range": [3, 3], "trackingBy": 100})).unwrap(); + assert_eq!(text_layer(&s, id).char_runs(), before); + // Hostile steps are refused. + let steps = s.active().unwrap().history.entries().len(); + for bad_step in [json!({"leadingBy": "up"}), json!({"trackingBy": 1e30}), json!({"baselineShiftBy": null}), json!({"leadingBy": [1]})] { + let mut p = bad_step.clone(); + p["layer"] = json!(id); + assert!(matches!(s.execute("type.setStyle", p), Err(EngineError::BadParams { .. })), "{bad_step}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + // Empty text: the step sets the style the next typed characters get. + let empty = s.execute("type.create", json!({"x": 0, "y": 80, "text": "", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("type.setStyle", json!({"layer": empty, "range": [0, 0], "leadingBy": -2})).unwrap(); + assert_eq!(text_layer(&s, empty).char_runs()[0].style.leading_pt, Some(10.0)); + } } diff --git a/crates/ui-egui/src/input_tests.rs b/crates/ui-egui/src/input_tests.rs index 956a9de9ba..4ac71a6634 100644 --- a/crates/ui-egui/src/input_tests.rs +++ b/crates/ui-egui/src/input_tests.rs @@ -504,10 +504,12 @@ fn type_tool_alt_arrows_kern_the_pair() { h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::ArrowRight); h.run_steps(2); assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((3, 6))); - // With a selection Alt+→ moves by word instead of kerning; at the text end there's no pair. + // With a selection Alt+→ tracks it instead (see `type_tool_format_keys_*`); at the text end + // there's no pair to kern. let before = kern(&h); - h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); + h.key_press(Key::ArrowRight); h.run_steps(2); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((6, 6))); h.key_press_modifiers(Modifiers::ALT, Key::ArrowRight); h.run_steps(2); assert_eq!(kern(&h), before); @@ -518,6 +520,245 @@ fn type_tool_alt_arrows_kern_the_pair() { assert_eq!(kern(&h).1, metric + 120.0); } +/// Starts a type editing session on `layer` with `anchor..caret` selected. +fn edit_type(h: &mut Harness<'static, PhotocraftApp>, layer: u64, anchor: usize, caret: usize) { + h.state_mut().ui.text_edit = Some(crate::state::TextEdit { + layer, + caret, + anchor, + session: "format-keys".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + h.run_steps(2); +} + +fn type_layer(h: &Harness<'static, PhotocraftApp>, id: u64) -> photocraft_doc::TextLayer { + match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Text(t) => t.clone(), + _ => panic!("not text"), + } +} + +/// The character style of character `c` of a type layer. +fn char_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::CharStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.char_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte || t.text.is_empty() + }) + .unwrap() + .style +} + +/// The paragraph style of the paragraph holding character `c`. +fn para_style(h: &Harness<'static, PhotocraftApp>, id: u64, c: usize) -> photocraft_doc::text::ParagraphStyle { + let t = type_layer(h, id); + let byte = t.text.char_indices().nth(c).map_or(t.text.len(), |(i, _)| i); + let mut end = 0; + t.paragraph_runs() + .into_iter() + .find(|r| { + end += r.len; + end > byte + }) + .unwrap() + .style +} + +fn history(h: &Harness<'static, PhotocraftApp>) -> Vec { + h.state().session.active().unwrap().history.entries() +} + +/// Photoshop's ⇧⌘K/U/B/I/: toggle All Caps, Underline, Faux Bold, Faux Italic and Strikethrough +/// on the selected characters while typing, one step per press, and never reach the global +/// ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse). +#[test] +fn type_tool_format_keys_toggle_character_styles() { + use photocraft_doc::text::{Caps, CharStyle}; + let mut h = harness(); + // A selection, so Select ▸ Inverse would be enabled if the key reached it. + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + type Has = fn(&CharStyle) -> bool; + let keys: [(Key, Has); 6] = [ + (Key::K, |s| s.caps == Caps::AllCaps), + (Key::U, |s| s.underline), + (Key::B, |s| s.faux_bold), + (Key::I, |s| s.faux_italic), + (Key::Slash, |s| s.strikethrough), + (Key::Questionmark, |s| s.strikethrough), + ]; + for (key, has) in keys { + let before = history(&h).len(); + let was = has(&char_style(&h, id, 0)); + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + assert_eq!(has(&char_style(&h, id, 0)), !was, "{key:?} toggles the selection"); + assert_eq!(has(&char_style(&h, id, 4)), !was, "{key:?}"); + assert!(!has(&char_style(&h, id, 6)), "{key:?} leaves the unselected characters"); + let steps = history(&h); + assert_eq!(steps.len(), before + 1, "{key:?}: one history step"); + assert_eq!(steps.last().map(String::as_str), Some("Set Type Style"), "{key:?}"); + assert!(h.state().ui.dialogs.is_empty(), "{key:?} must not open Color Settings"); + assert!(h.state().ui.text_edit.is_some()); + } + // The second strikethrough key turned it off again; press the rest again: all off. + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + let s = char_style(&h, id, 2); + assert!(s.caps == Caps::Normal && !s.underline && !s.faux_bold && !s.faux_italic && !s.strikethrough); + // Partly underlined: the key underlines the whole selection rather than flipping each part. + h.state_mut().run("type.setStyle", json!({"layer": id, "range": [0, 2], "underline": true})).unwrap(); + h.key_press_modifiers(cmd_shift, Key::U); + h.run_steps(2); + assert!((0..5).all(|c| char_style(&h, id, c).underline)); + // Undo takes back one press. + assert!(h.state_mut().session.undo()); + h.run_steps(1); + assert!(char_style(&h, id, 0).underline && !char_style(&h, id, 3).underline); + // A collapsed caret in non-empty text: nothing changes, and nothing global runs. + edit_type(&mut h, id, 3, 3); + let before = history(&h); + for key in [Key::K, Key::U, Key::B, Key::I] { + h.key_press_modifiers(cmd_shift, key); + h.run_steps(2); + } + assert_eq!(history(&h), before); + assert!(h.state().ui.dialogs.is_empty()); + // Empty text: the key sets the style the next characters typed get. + let empty = h.state_mut().run("type.create", json!({"x": 20, "y": 160, "text": "", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, empty, 0, 0); + h.key_press_modifiers(cmd_shift, Key::B); + h.run_steps(2); + h.event(egui::Event::Text("ab".into())); + h.run_steps(2); + assert_eq!(type_layer(&h, empty).text, "ab"); + assert!(char_style(&h, empty, 0).faux_bold && char_style(&h, empty, 1).faux_bold); +} + +/// Outside text editing ⇧⌘K and ⇧⌘I keep their global meanings. +#[test] +fn global_cmd_shift_keys_still_work_outside_type_editing() { + let mut h = harness(); + h.state_mut().run("select.all", json!({})).unwrap(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello", "size": 20})).unwrap()["layer"].as_u64().unwrap(); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none()); + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + h.key_press_modifiers(cmd_shift, Key::I); + h.run_steps(2); + assert_eq!(history(&h).last().map(String::as_str), Some("Inverse")); + h.key_press_modifiers(cmd_shift, Key::K); + h.run_steps(3); + assert_eq!(h.state().ui.dialogs.len(), 1, "Color Settings opens"); + assert!(!char_style(&h, id, 0).faux_italic && char_style(&h, id, 0).caps == photocraft_doc::text::Caps::Normal); +} + +/// ⌥↑/↓ leading (2 pt, 10 with ⌘), ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking over a selection +/// (20, 100 with ⌘): one step per press, from each character's own value, the selection kept. +#[test] +fn type_tool_format_keys_step_leading_baseline_and_tracking() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"x": 20, "y": 80, "text": "Hello world", "size": 10})).unwrap()["layer"].as_u64().unwrap(); + edit_type(&mut h, id, 0, 5); + let before = history(&h).len(); + let press = |h: &mut Harness<'static, PhotocraftApp>, m: Modifiers, k: Key| { + h.key_press_modifiers(m, k); + h.run_steps(2); + }; + // Auto leading is 120% of 10 pt: ⌥↓ makes it 14. + press(&mut h, Modifiers::ALT, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(14.0)); + press(&mut h, Modifiers::ALT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 4).leading_pt, Some(12.0)); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).leading_pt, Some(22.0)); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, 2.0); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT | Modifiers::COMMAND, Key::ArrowDown); + assert_eq!(char_style(&h, id, 0).baseline_shift_pt, -8.0); + press(&mut h, Modifiers::ALT, Key::ArrowRight); + assert_eq!(char_style(&h, id, 0).tracking, 20.0); + press(&mut h, Modifiers::ALT | Modifiers::COMMAND, Key::ArrowLeft); + assert_eq!(char_style(&h, id, 4).tracking, -80.0); + assert_eq!(history(&h).len(), before + 7, "one history step per press"); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((0, 5)), "the selection stays"); + let rest = char_style(&h, id, 7); + assert_eq!((rest.leading_pt, rest.baseline_shift_pt, rest.tracking), (None, 0.0, 0.0)); + // A collapsed caret: ⌥↑/↓ change nothing and don't move the caret. + edit_type(&mut h, id, 8, 8); + let steps = history(&h).len(); + press(&mut h, Modifiers::ALT, Key::ArrowDown); + press(&mut h, Modifiers::ALT | Modifiers::SHIFT, Key::ArrowUp); + assert_eq!(history(&h).len(), steps); + assert_eq!(h.state().ui.text_edit.as_ref().map(|e| (e.anchor, e.caret)), Some((8, 8))); +} + +/// ⌥⇧⌘H toggles Hyphenate for the paragraphs the selection or caret touches. +#[test] +fn type_tool_format_keys_toggle_hyphenate() { + let mut h = harness(); + let id = h.state_mut().run("type.create", json!({"box": [10, 10, 300, 200], "text": "one\ntwo\nthree", "size": 12})).unwrap()["layer"].as_u64().unwrap(); + let keys = Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT; + // The caret in the second paragraph. + edit_type(&mut h, id, 5, 5); + let before = history(&h).len(); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, true, false]); + // A selection over the first two: not all hyphenate, so both do; again, neither. + edit_type(&mut h, id, 1, 5); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [true, true, false]); + h.key_press_modifiers(keys, Key::H); + h.run_steps(2); + assert_eq!([0, 5, 9].map(|c| para_style(&h, id, c).hyphenate), [false, false, false]); + assert_eq!(history(&h).len(), before + 3); +} + +/// The formatting keys never panic: an edit of a layer that has gone, or with no document. +#[test] +fn type_tool_format_keys_survive_hostile_state() { + let mut h = harness(); + edit_type(&mut h, 9999, 0, 3); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::U); + h.key_press_modifiers(Modifiers::ALT, Key::ArrowDown); + h.run_steps(2); + assert!(h.state().ui.text_edit.is_none(), "an edit of a missing layer ends"); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + for ed in [None, Some(1)] { + app.ui.text_edit = ed.map(|layer| crate::state::TextEdit { + layer, + caret: 4, + anchor: 0, + session: "x".into(), + created: false, + mask: false, + dragging: false, + resize: None, + preedit: None, + }); + crate::type_tool::toggle_char_style(&mut app, crate::type_tool::CharToggle::AllCaps); + crate::type_tool::step_char_metric(&mut app, "leadingBy", 2.0); + crate::type_tool::step_char_metric(&mut app, "trackingBy", f32::NAN); + crate::type_tool::toggle_hyphenate(&mut app); + } + assert!(app.session.active().is_none()); +} + /// The Character panel's kerning field reads and parses Photoshop's values. #[test] fn kerning_field_values() { diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 291fdde78a..dc99138a32 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -460,6 +460,135 @@ fn kern_pair(app: &mut PhotocraftApp, id: LayerId, caret: usize, by: f32) { let _ = app.run("type.edit", json!({"layer": id.0, "kernPair": {"at": caret, "by": by}})); } +/// A character style the type editor's ⇧⌘ keys toggle (Photoshop: ⇧⌘K All Caps, ⇧⌘U +/// Underline, ⇧⌘B Faux Bold, ⇧⌘I Faux Italic, ⇧⌘/ Strikethrough). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CharToggle { + AllCaps, + Underline, + FauxBold, + FauxItalic, + Strikethrough, +} + +impl CharToggle { + fn is_on(self, st: &photocraft_doc::text::CharStyle) -> bool { + match self { + Self::AllCaps => st.caps == photocraft_doc::text::Caps::AllCaps, + Self::Underline => st.underline, + Self::FauxBold => st.faux_bold, + Self::FauxItalic => st.faux_italic, + Self::Strikethrough => st.strikethrough, + } + } + + fn param(self, on: bool) -> (&'static str, serde_json::Value) { + match self { + Self::AllCaps => ("caps", json!(if on { "all" } else { "normal" })), + Self::Underline => ("underline", json!(on)), + Self::FauxBold => ("fauxBold", json!(on)), + Self::FauxItalic => ("fauxItalic", json!(on)), + Self::Strikethrough => ("strikethrough", json!(on)), + } + } +} + +/// The byte range of characters `a..b` of `text`. +fn byte_range(text: &str, a: usize, b: usize) -> (usize, usize) { + let at = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + (at(a.min(b)), at(a.max(b))) +} + +/// The character styles of the selection, or of the typing style when the text is empty. Empty +/// when nothing is selected in non-empty text. +fn selected_styles(t: &TextLayer, a: usize, b: usize) -> Vec { + let runs = t.char_runs(); + if t.text.is_empty() { + return runs.into_iter().take(1).map(|r| r.style).collect(); + } + let (ba, bb) = byte_range(&t.text, a, b); + let mut at = 0usize; + let mut out = Vec::new(); + for r in runs { + let (rs, re) = (at, at.saturating_add(r.len)); + at = re; + if rs < bb && re > ba { + out.push(r.style); + } + } + out +} + +/// The character range the type editor's formatting keys act on: the selection, or the empty +/// text's typing style. `None` for a collapsed caret in non-empty text: Photoshop would style the +/// next characters typed, which the document model can't hold apart from the text, so the keys +/// do nothing there (rather than restyle a word the user didn't select). +fn format_range(app: &PhotocraftApp, ed: &TextEdit) -> Option<(usize, usize)> { + let t = text_layer(&app.session.active()?.doc, LayerId(ed.layer))?; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + (a < b || n == 0).then_some((a, b)) +} + +/// One `type.setStyle` step on the edited layer's `range`, kept apart from the typing session's +/// coalesced edit so Undo takes back one key press at a time (as with kerning). +fn set_style(app: &mut PhotocraftApp, ed: &TextEdit, range: (usize, usize), mut p: serde_json::Value) { + crate::type_transform::finish(app); + p["layer"] = json!(ed.layer); + p["range"] = json!([range.0, range.1]); + if let Err(e) = app.run("type.setStyle", p) { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// ⇧⌘K/U/B/I/: flips a character style over the selection: off when every selected character +/// already has it, else on. +pub(crate) fn toggle_char_style(app: &mut PhotocraftApp, which: CharToggle) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + let Some(styles) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))).map(|t| selected_styles(t, range.0, range.1)) else { + return; + }; + let on = !styles.iter().all(|st| which.is_on(st)); + let (key, value) = which.param(on); + set_style(app, &ed, range, json!({ key: value })); +} + +/// ⌥↑/↓ leading, ⇧⌥↑/↓ baseline shift and ⌥←/→ tracking: steps every selected character from its +/// own value (`key` is `leadingBy`, `baselineShiftBy` or `trackingBy`). +pub(crate) fn step_char_metric(app: &mut PhotocraftApp, key: &str, by: f32) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(range) = format_range(app, &ed) else { return }; + // Leading steps in the points the Character panel shows for a scaled layer. + set_style(app, &ed, range, json!({ key: by, "metricsAsShown": key == "leadingBy" })); +} + +/// ⌥⇧⌘H: flips Hyphenate for the paragraphs the selection (or the caret) is in: off when all of +/// them hyphenate, else on. +pub(crate) fn toggle_hyphenate(app: &mut PhotocraftApp) { + let Some(ed) = app.ui.text_edit.clone() else { return }; + let Some(t) = app.session.active().and_then(|s| text_layer(&s.doc, LayerId(ed.layer))) else { return }; + let n = t.text.chars().count(); + let (a, b) = (ed.caret.min(ed.anchor).min(n), ed.caret.max(ed.anchor).min(n)); + let (ba, bb) = byte_range(&t.text, a, b); + let len = t.text.len(); + let mut at = 0usize; + let mut all = true; + let mut any = false; + for r in t.paragraph_runs() { + let (ps, pe) = (at, at.saturating_add(r.len)); + at = pe; + let touches = if ba == bb { ps <= ba && (ba < pe || pe >= len) } else { ps < bb && pe > ba }; + if touches { + any = true; + all &= r.style.hyphenate; + } + } + let on = !(any && all); + set_style(app, &ed, (a, b), json!({ "hyphenate": on })); +} + /// IME composition. The preedit text is written into the layer (so it lays out and reflows like /// typed text) and replaced by every update; `commit` makes the result final. fn ime_update(app: &mut PhotocraftApp, s: &str, commit: bool) { @@ -677,6 +806,35 @@ pub fn handle_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { use egui::Key; let key = &flow_key(*key, vertical_flow); match key { + // Photoshop's type formatting keys act on the selected characters and win + // over the global ⇧⌘K/U/B/I (Color Settings, Desaturate, Auto Color, Inverse) + // while typing. Each press is one history step. + Key::K | Key::U | Key::B | Key::I | Key::Slash | Key::Questionmark if m.command && m.shift && !m.alt => { + let which = match key { + Key::K => CharToggle::AllCaps, + Key::U => CharToggle::Underline, + Key::B => CharToggle::FauxBold, + Key::I => CharToggle::FauxItalic, + _ => CharToggle::Strikethrough, + }; + toggle_char_style(app, which); + } + Key::H if m.command && m.shift && m.alt => toggle_hyphenate(app), + // ⌥↑/↓ leading -/+2 pt, ⇧⌥↑/↓ baseline shift +/-2 pt (10 with ⌘/Ctrl). + Key::ArrowUp | Key::ArrowDown if m.alt => { + let step = if m.command { 10.0 } else { 2.0 }; + let up = *key == Key::ArrowUp; + if m.shift { + step_char_metric(app, "baselineShiftBy", if up { step } else { -step }); + } else { + step_char_metric(app, "leadingBy", if up { -step } else { step }); + } + } + // ⌥←/→ over a selection tracks it by -/+20 (100 with ⌘/Ctrl). + Key::ArrowLeft | Key::ArrowRight if m.alt && !m.shift && a != b => { + let step = if m.command { 100.0 } else { 20.0 }; + step_char_metric(app, "trackingBy", if *key == Key::ArrowRight { step } else { -step }); + } Key::Backspace | Key::Delete => { if a == b { let fwd = *key == Key::Delete; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a48db144ae..33fb8b319f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -185,6 +185,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | +| Type formatting keys while editing text | done: ⇧⌘K, U, B, I and / toggle All Caps, Underline, Faux Bold, Faux Italic, Strikethrough (off when every selected character has it, else on); ⌥↑/↓ leading ∓2 pt, ⇧⌥↑/↓ baseline shift ±2 pt (10 with ⌘); ⌥←/→ tracking ±20 over a selection (100 with ⌘; a collapsed caret still kerns); ⌥⇧⌘H toggles Hyphenate for the touched paragraphs. One `type.setStyle` step per press (new `leadingBy`/`baselineShiftBy`/`trackingBy`; auto leading starts from its laid-out value); the global ⇧⌘K/U/B/I never fire while typing. Differs: a collapsed caret in non-empty text changes nothing, since the model can't hold a pending style for the next characters typed (empty text takes it as its typing style) (`type_tool.rs`, `input_tests.rs` `type_tool_format_keys_*`) | | ## Panels and workspace @@ -220,3 +221,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Layer and selection keys from Photoshop's shortcut set: ⌘, toggles, ⌘\, /, ⌘⌥2, ⌘⌥⌫, Tab rename-next, ⌥-click thumbnail to fit | | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | +| 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | From 0002d861eea5b0c91ad41259f6c420aaab3e5112 Mon Sep 17 00:00:00 2001 From: Kartik Dwivedi <116622069+Kartik8Dwivedi@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:14:49 +0530 Subject: [PATCH 279/523] fix(io): read CS2 placed layers that carry only a PlLd block (#2465) (#4268) Photoshop CS2 predates SoLd: its smart objects have a PlLd (plcL) block alone, with the embedded file in a global lnkD block. Import only read SoLd/SoLE, so such layers got an empty source id and Edit Contents said "the smart object's contents are unavailable". Parse PlLd from the Adobe PSD spec (unique id and the 8 corner doubles). PlLd stores no source size, so the transform takes it from the embedded file's header (linked::source_size_probe: PSD, TIFF, PNG and JPEG headers, any other image decoded flat by photocraft-codecs). It never imports the file as a document: a layered TIFF can carry PSD layer data whose lnkD holds another file, so that would nest without bound. Without the file, 100 % scale is assumed. source_geometry moves to linked.rs. Non-parallelogram corners give the projective placement as for SoLd. An unreadable PlLd is reported as a warning. The PlLd corners are the same placed quad as SoLd's Trnf, so they frame the same source-space box (#3677, blocks::placed_frame): the bounding box of a single-patch custom warp's 16 control points, which may spill past the source, else the source rect. corners_affine now maps such a frame onto the corners and is shared by parse_smart and plld_transform, which is the inverse of smart_map::quad_points (what plld_bytes writes): affine corners frame placed_frame(warp), projective ones the source rect. A custom warp's box needs no source size, so a spilled mesh is placed right even without the embedded file. corners_affine refuses non-finite corners or an empty frame (identity, as before). CS2 can warp a smart object, and PlLd keeps that warp after the corners (u32 warp version, then a versioned warp descriptor). It is read with the same code as the SoLd warp (warp_of), so a re-render from the now available source (Free Transform, Image Size, mode changes) stays warped. A stored warp that can't be read leaves the contents unavailable, keeping the warped pixels, with a warning. An edited PlLd-only layer (moved, transformed, warped, filtered or given other contents) is saved like any other smart object, SoLd plus a regenerated PlLd, with its current placement; an unedited one keeps its record verbatim, as does one whose file isn't embedded (its id is never read as a disk path). plld_edited compares against plld_transform with the record's own warp, so it uses the same frame as import and as the writer. A layer with both SoLE and PlLd is read from SoLE. Tests: PlLd round trip and malformed input (truncation, bad magic, oversized Pascal length, NaN) in blocks.rs; PlLd warps round trip through plld_bytes (preset, custom mesh, identity, none) and unreadable warps are reported as such; a PlLd whose custom mesh spills past the source places like the equivalent SoLd, with and without the source size; PlLd-only layers with a missing file, a broken record, a warp or an unreadable warp; a spilled-mesh PlLd-only layer imports with the SoLd layer's placement and an unedited one saves its PlLd verbatim (no SoLd) and reopens in place; and engine tests that rewrite an export into the CS2 layout (no SoLd, lnkD with version-2 liFD items) and check transform, contents, re-render, Edit Contents, and that a warped smart object re-renders warped. source_size_probe has header tests for each format and malformed JPEG marker walks. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/tests/smart_psd.rs | 147 +++++++++++ crates/io/src/blocks.rs | 284 ++++++++++++++++++++- crates/io/src/linked.rs | 136 +++++++++- crates/io/src/psd_export.rs | 53 ++-- crates/io/src/psd_import.rs | 62 ++++- crates/io/tests/smart_contents_identity.rs | 150 +++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 8 files changed, 803 insertions(+), 39 deletions(-) diff --git a/crates/engine/tests/smart_psd.rs b/crates/engine/tests/smart_psd.rs index 5f4c7bf6e5..f7b86048b9 100644 --- a/crates/engine/tests/smart_psd.rs +++ b/crates/engine/tests/smart_psd.rs @@ -468,3 +468,150 @@ fn displace_psd_filter_renders_with_its_embedded_map() { } } } + +/// A version-2 `liFD` item, as in Photoshop CS2's `lnkD` block: the fields every version has, and +/// none of the version 5–7 fields after the file data. +fn lifd_v2(f: &photocraft_io::linked::LinkedFile) -> Vec { + let mut item = b"liFD".to_vec(); + item.extend_from_slice(&2u32.to_be_bytes()); + item.push(f.uuid.len() as u8); + item.extend_from_slice(f.uuid.as_bytes()); + let name: Vec = f.file_name.encode_utf16().collect(); + item.extend_from_slice(&(name.len() as u32).to_be_bytes()); + item.extend(name.iter().flat_map(|u| u.to_be_bytes())); + item.extend_from_slice(&photocraft_io::linked::file_type(&f.bytes)); + item.extend_from_slice(b"8BIM"); + item.extend_from_slice(&(f.bytes.len() as u64).to_be_bytes()); + item.push(0); // no open-file descriptor + item.extend_from_slice(&f.bytes); + let mut out = (item.len() as u64).to_be_bytes().to_vec(); + out.extend(item); + out.resize(out.len().next_multiple_of(4), 0); + out +} + +/// `doc` saved as PSD, then rewritten into Photoshop CS2's layout: no `SoLd`, and `lnkD` with +/// version-2 items. +fn cs2_layout(doc: &Document) -> Vec { + let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap(); + let mut file = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap(); + for rec in file.layers_mut() { + rec.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE")); + } + assert!(file.layers().iter().any(|l| l.block(b"PlLd").is_some())); + let links = file.global_blocks.iter_mut().find(|b| &b.key == b"lnk2").unwrap(); + let files = photocraft_io::linked::parse_linked_files(&links.data); + assert_eq!(files.len(), 1); + links.key = *b"lnkD"; + links.data = files.iter().flat_map(lifd_v2).collect(); + file.to_bytes().unwrap() +} + +/// Photoshop CS2 predates `SoLd`: its placed layers carry a `PlLd` block alone, and the embedded +/// file sits in a global `lnkD` block of older `liFD` items. Such a smart object's contents open +/// (they were "unavailable") and it re-renders in place from them (#2465). +#[test] +fn cs2_plld_only_smart_object_opens_its_contents() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") }; + let nested = decode_source("x.pcraft", pcraft).unwrap(); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + for (a, b) in after.transform.m.iter().zip(before.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", after.transform, before.transform); + } + let (name, src) = photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).expect("the contents are available"); + let inner = decode_source(&name, &src).unwrap(); + assert_eq!((inner.size, inner.layers.len()), (nested.size, nested.layers.len())); + // It re-renders where it was placed. + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut rerendered = back.clone(); + *rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + // And Edit Contents (the double-click) opens the nested document. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.select_layer(photocraft_doc::LayerId(bid)).unwrap(); + s2.execute("layer.smartObjects.editContents", json!({})).unwrap(); + assert_eq!(s2.active().unwrap().doc.size, nested.size); +} + +/// CS2 can warp a smart object, and `PlLd` keeps the warp after the corners. Now that a +/// `PlLd`-only layer's contents are available, Free Transform, Image Size and mode changes +/// re-render it from them: it must come back warped, not silently unwarped (#2465). +#[test] +fn cs2_plld_only_warped_smart_object_stays_warped() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + s.execute("layer.smartObjects.warp", json!({"style": "wave", "bend": 60})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let (_, before) = smart(&doc); + assert!(before.warp.is_some()); + + let back = photocraft_io::import("cs2.psd", &cs2_layout(&doc)).unwrap().document; + let (bid, after) = smart(&back); + assert_eq!(after.warp, before.warp); + assert!(photocraft_engine::smart_cmds::contents_of(&back, photocraft_doc::LayerId(bid)).is_ok(), "the contents are available"); + // Re-rendered from its source, it is still warped where it was placed. + let rerender = |warp| { + let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone(); + let LayerContent::Smart(sm) = &mut l.content else { panic!() }; + sm.warp = warp; + assert!(refresh_layer(&back, &mut l).unwrap()); + let mut d = back.clone(); + *d.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l; + max_diff(&photocraft_compose::flatten(&d).px, &photocraft_compose::flatten(&doc).px) + }; + let d = rerender(after.warp.clone()); + assert!(d <= 2.0 / 255.0, "re-render differs by {d}"); + assert!(rerender(None) > 0.1, "an unwarped re-render would differ visibly"); +} + +/// A CS2 `PlLd`-only smart object is saved as it was while nothing changed it, and with its +/// current placement once it is moved: the old record was written back unchanged, so the save +/// reopened at the old place (#2465). +#[test] +fn cs2_plld_only_smart_object_saves_its_new_transform() { + let mut s = session(8); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap(); + let cs2 = cs2_layout(&s.active().unwrap().doc); + let plld = |bytes: &[u8]| { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + let rec = f.layers().iter().find(|l| l.block(b"PlLd").is_some()).cloned().expect("a placed layer"); + (rec.block(b"PlLd").unwrap().data.clone(), rec.block(b"SoLd").is_some()) + }; + let back = photocraft_io::import("cs2.psd", &cs2).unwrap().document; + let (bid, _) = smart(&back); + + // Unedited: the CS2 record is written back as it was. + let same = photocraft_io::export(&back, "psd", &Default::default()).unwrap().bytes; + assert_eq!(plld(&same), (plld(&cs2).0, false)); + + // Moved and scaled: written with the new placement, and it reopens there. + let mut s2 = Session::new(); + s2.add_document(back, None); + s2.execute("edit.transform", json!({"layer": bid, "matrix": [0.5, 0, 0, 0.5, 20, 12]})).unwrap(); + let edited = s2.active().unwrap().doc.clone(); + let (_, want) = smart(&edited); + let saved = photocraft_io::export(&edited, "psd", &Default::default()).unwrap().bytes; + assert!(structure_errors(&saved).is_empty(), "{:?}", structure_errors(&saved)); + assert_ne!(plld(&saved).0, plld(&cs2).0, "the old PlLd record was written back"); + let again = photocraft_io::import("again.psd", &saved).unwrap().document; + let (aid, got) = smart(&again); + for (a, b) in got.transform.m.iter().zip(want.transform.m) { + assert!((a - b).abs() < 1e-9, "transform {:?} vs {:?}", got.transform, want.transform); + } + assert!(photocraft_engine::smart_cmds::contents_of(&again, photocraft_doc::LayerId(aid)).is_ok(), "the contents are available"); +} diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 3a6b220d18..87fda102fe 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -527,15 +527,25 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, /// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, /// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. +/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { - use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - let w = get_desc(&d, "warp")?; + warp_of(get_desc(&d, "warp")?).flatten() +} + +/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) +/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it +/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +fn warp_of(w: &Descriptor) -> Option> { + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; + if style == WarpStyle::None { + return Some(None); + } let b = get_desc(w, "bounds")?; let g = |k: &str| num(b.get(k)); let bounds = [g("Left")?, g("Top ")?, g("Rght")?, g("Btom")?]; @@ -558,7 +568,7 @@ pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option, w: f64, h if ok { b } else { rect } } -/// Smart object identifier and transform from `SoLd`/`PlLd` (best effort). +/// Smart object identifier and transform from `SoLd`/`SoLE` (best effort). A `PlLd` block +/// stores no source size, so its transform needs the embedded file: see [`parse_plld`]. pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { // SoLd: 'soLD' + version(4) + versioned descriptor. let desc = if key == b"SoLd" || key == b"SoLE" { data.get(8..).and_then(parse_prefix_versioned) } else { None }; @@ -611,16 +622,97 @@ pub fn parse_smart(key: &[u8; 4], data: &[u8]) -> (String, Affine) { if let (Some(Value::List(pts)), Some(sz)) = (d.get("Trnf"), get_desc(&d, "Sz ")) { let p: Vec = pts.iter().filter_map(|v| num(Some(v))).collect(); let (w, h) = (num(sz.get("Wdth")).unwrap_or(0.0), num(sz.get("Hght")).unwrap_or(0.0)); - if p.len() == 8 && w > 0.0 && h > 0.0 { - let [x0, y0, x1, y1] = placed_frame(parse_placed_warp(key, data).as_ref(), w, h); - let (bw, bh) = (x1 - x0, y1 - y0); - let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); - affine = Affine { m: [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0] }; + if let (Ok(p), true) = (<[f64; 8]>::try_from(p), w > 0.0 && h > 0.0) { + affine = corners_affine(&p, placed_frame(parse_placed_warp(key, data).as_ref(), w, h)).unwrap_or(Affine::IDENTITY); } } (id, affine) } +/// The affine that takes the source-space box `frame` (left, top, right, bottom; see +/// [`placed_frame`]) onto placed corners `p` (top-left, top-right, bottom-right, bottom-left as +/// x, y pairs). `None` for an empty or non-finite frame, or non-finite corners. +pub fn corners_affine(p: &[f64; 8], frame: [f64; 4]) -> Option { + let [x0, y0, x1, y1] = frame; + let (bw, bh) = (x1 - x0, y1 - y0); + if !(frame.iter().chain(p).all(|v| v.is_finite()) && bw > 0.0 && bh > 0.0) { + return None; + } + let (a, b, c, d) = ((p[2] - p[0]) / bw, (p[3] - p[1]) / bw, (p[6] - p[0]) / bh, (p[7] - p[1]) / bh); + let m = [a, b, c, d, p[0] - a * x0 - c * y0, p[1] - b * x0 - d * y0]; + m.iter().all(|v| v.is_finite()).then_some(Affine { m }) +} + +/// The transform and (Distort/Perspective) projective placement of a `PlLd` record's corners `p` +/// for a source of `size` warped by `warp`: the inverse of [`crate::smart_map::plld_bytes`]'s +/// quad. As for `SoLd`'s `Trnf` (#3677), affine corners frame a single-patch custom warp's +/// control-point box (which may spill past the source), else the source rect; projective corners +/// frame the source rect, as `parse_smart_perspective` reads them. `PlLd` stores no source size: +/// without the source file (`None`) the layer is taken as placed at 100 %, the edge lengths being +/// its size (a custom warp's box needs no size); position and rotation stay right, only Replace +/// Contents and re-renders depend on the scale. Import and export (which keeps an unedited record +/// verbatim) both place a `PlLd` layer with this. +pub fn plld_transform(p: &[f64; 8], size: Option<(f64, f64)>, warp: Option<&photocraft_geom::warp::Warp>) -> (Affine, Option<[f64; 9]>) { + let (w, h) = size.unwrap_or_else(|| ((p[2] - p[0]).hypot(p[3] - p[1]), (p[6] - p[0]).hypot(p[7] - p[1]))); + let perspective = corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (w, h)); + let frame = if perspective.is_some() { [0.0, 0.0, w, h] } else { placed_frame(warp, w, h) }; + (corners_affine(p, frame).unwrap_or(Affine::IDENTITY), perspective) +} + +/// The source identifier and placed corners of a `PlLd` block, the placed-layer record Photoshop +/// CS2 and older write with no `SoLd` next to it (#2465). Adobe PSD spec, "Placed Layer": `plcL`, +/// version, Pascal unique id (unpadded), page, total pages, anti-alias policy, layer type, then +/// 8 doubles: the x, y of each corner (top-left, top-right, bottom-right, bottom-left). The warp +/// that follows is read by [`parse_plld_warp`]. `None` for anything else or a truncated record. +pub fn parse_plld(data: &[u8]) -> Option<(String, [f64; 8])> { + plld_record(data).map(|(id, corners, _)| (id, corners)) +} + +/// [`parse_plld`] and the offset just past the corners, where the warp starts. +fn plld_record(data: &[u8]) -> Option<(String, [f64; 8], usize)> { + if data.get(..4)? != b"plcL" { + return None; + } + let n = usize::from(*data.get(8)?); + let id = String::from_utf8_lossy(data.get(9..9 + n)?).into_owned(); + // Page number, total pages, anti-alias policy and layer type (4 × u32). + let at = 9 + n + 16; + let mut corners = [0.0; 8]; + for (i, c) in corners.iter_mut().enumerate() { + let b = data.get(at + i * 8..at + i * 8 + 8)?; + *c = f64::from_be_bytes(b.try_into().ok()?); + } + corners.iter().all(|v| v.is_finite()).then_some((id, corners, at + 64)) +} + +/// The warp of a placed layer's `PlLd` record (Photoshop CS2 can warp a smart object). +#[derive(Debug, Clone, PartialEq)] +pub enum PlldWarp { + /// No warp: `warpNone`, a warp that moves nothing, or a record that ends at its corners. + None, + /// The warp, in source pixels like the `SoLd` one. + Warp(photocraft_geom::warp::Warp), + /// A warp is stored but can't be read (or the record itself can't be): re-rendering the + /// layer from its source would drop it. + Unreadable, +} + +/// The warp after a `PlLd` record's corners: a u32 warp version, then a versioned `warp` +/// descriptor (Adobe PSD spec, "Placed Layer"; the layout [`crate::smart_map::plld_bytes`] +/// writes). +pub fn parse_plld_warp(data: &[u8]) -> PlldWarp { + let Some(rest) = plld_record(data).and_then(|(_, _, end)| data.get(end..)) else { return PlldWarp::Unreadable }; + // Only padding after the corners: no warp stored. + if rest.iter().all(|b| *b == 0) { + return PlldWarp::None; + } + match rest.get(4..).and_then(parse_prefix_versioned).as_ref().and_then(warp_of) { + Some(Some(w)) => PlldWarp::Warp(w), + Some(None) => PlldWarp::None, + None => PlldWarp::Unreadable, + } +} + /// The projective placement of a `SoLd` / `SoLE` block whose corners aren't a parallelogram /// (Distort, Perspective): source pixels → document pixels, row-major 3×3. Read from /// `nonAffineTransform` (the placed corners), else `Trnf`. `None` for affine placements, which @@ -638,6 +730,12 @@ pub fn parse_smart_perspective(key: &[u8; 4], data: &[u8]) -> Option<[f64; 9]> { (p.len() == 8 && p.iter().all(|v| v.is_finite())).then(|| [[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]]) }; let q = quad("nonAffineTransform").or_else(|| quad("Trnf"))?; + corners_perspective(q, (w, h)) +} + +/// The projective placement of a `w` × `h` source onto corners `q` that aren't a parallelogram; +/// `None` for affine corners (see [`corners_affine`]) or an empty size. +pub fn corners_perspective(q: [[f64; 2]; 4], (w, h): (f64, f64)) -> Option<[f64; 9]> { // A parallelogram (top-left + bottom-right = top-right + bottom-left) is affine. let tol = 1e-6 * (1.0 + q.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs()))); if (0..2).all(|i| (q[0][i] + q[2][i] - q[1][i] - q[3][i]).abs() <= tol) || w <= 0.0 || h <= 0.0 { @@ -916,6 +1014,172 @@ mod more_tests { assert_eq!(id, "uuid-1"); assert_eq!(a.m, [2.0, 0.0, 0.0, 2.0, 10.0, 20.0]); assert_eq!(parse_smart(b"PlLd", &data).0, ""); + assert_eq!(parse_plld(&data), None, "soLD data is not a plcL record"); + } + + /// A `PlLd` record (as Photoshop CS2 writes it, with no `SoLd`, #2465) gives its source id and + /// placed corners; with the source size those are the same transform `SoLd` gives. + #[test] + fn plld_id_and_corners() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let t = Affine { m: [1.5, 0.25, -0.5, 2.0, 10.0, 20.0] }; + let spec = PlacedSpec { idnt: "uuid-cs2", placed: "", transform: t, perspective: None, size: (40.0, 30.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + let (id, p) = parse_plld(&data).expect("a plcL record"); + assert_eq!(id, "uuid-cs2"); + assert_eq!(p, [10.0, 20.0, 70.0, 30.0, 55.0, 90.0, -5.0, 80.0]); + let back = corners_affine(&p, [0.0, 0.0, 40.0, 30.0]).unwrap(); + for (a, b) in back.m.iter().zip(t.m) { + assert!((a - b).abs() < 1e-12, "{back:?} vs {t:?}"); + } + assert_eq!(corners_perspective([[p[0], p[1]], [p[2], p[3]], [p[4], p[5]], [p[6], p[7]]], (40.0, 30.0)), None, "affine corners"); + assert_eq!(corners_affine(&p, [0.0, 0.0, 0.0, 30.0]), None); + assert_eq!(corners_affine(&p, [0.0, 0.0, f64::NAN, 30.0]), None); + assert_eq!(corners_affine(&p, [5.0, 0.0, 5.0, 30.0]), None, "an empty frame"); + let mut nan = p; + nan[3] = f64::INFINITY; + assert_eq!(corners_affine(&nan, [0.0, 0.0, 40.0, 30.0]), None); + // The placed-layer record is the one block that can be the principal one: PlLd-only + // layers have no smart transform from `parse_smart`. + assert_eq!(parse_smart(b"PlLd", &data), (String::new(), Affine::IDENTITY)); + } + + /// A `PlLd` record's corners frame the same box as `SoLd`'s `Trnf` (#3677): with a custom + /// warp whose control points spill past the source, the control-point box, not the source + /// rect. The CS2 record then places the layer exactly as the equivalent `SoLd` does, with or + /// without the source size (the box needs none). + #[test] + fn plld_corners_frame_a_spilled_custom_warp_like_sold() { + use crate::smart_map::{PlacedSpec, parse_sold, plld_bytes, sold_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp}; + let bounds = [0.0, 0.0, 50.0, 25.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-10.0, -5.0]; + mesh.points[15] = [60.0, 25.0]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [2.0, 0.5, -0.25, 2.0, 120.0, 210.0] }; + let spec = PlacedSpec { idnt: "cs2", placed: "p", transform: t, perspective: None, size: (50.0, 25.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + let plld = plld_bytes(&spec, &mut Vec::new()); + let sold = sold_bytes(None, &spec, &mut Vec::new()); + let (_, p) = parse_plld(&plld).expect("a plcL record"); + assert_eq!(parse_plld_warp(&plld), PlldWarp::Warp(warp.clone())); + let from_sold = parse_smart(b"SoLd", &sold).1; + assert_eq!(parse_sold(&sold).map(|s| s.transform), Some(from_sold)); + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(from_sold, t), "{from_sold:?} vs {t:?}"); + for size in [Some((50.0, 25.0)), None] { + let (got, perspective) = plld_transform(&p, size, Some(&warp)); + assert!(close(got, from_sold), "size {size:?}: {got:?} vs SoLd {from_sold:?}"); + assert_eq!(perspective, None); + } + // Framing the source rect instead would stretch it (the bug #3677 fixed for SoLd). + let rect = corners_affine(&p, [0.0, 0.0, 50.0, 25.0]).unwrap(); + assert!(!close(rect, t), "the test needs a mesh that spills: {rect:?}"); + // With no warp, or a preset one, the frame is the source rect, as before. + let plain = PlacedSpec { warp: None, ..spec }; + let (_, q) = parse_plld(&plld_bytes(&plain, &mut Vec::new())).unwrap(); + assert!(close(plld_transform(&q, Some((50.0, 25.0)), None).0, t)); + } + + /// Malformed `PlLd` data (wrong magic, truncated anywhere, a Pascal length past the end, + /// non-finite corners) reads as `None`, never a panic. + #[test] + fn plld_malformed_is_none() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + // Magic + version + Pascal "id" + 4 × u32 + 8 × f64: everything after is the warp. + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(parse_plld(&data[..end]).is_some()); + for n in 0..end { + assert_eq!(parse_plld(&data[..n]), None, "truncated to {n} bytes"); + } + let mut bad = data.clone(); + bad[..4].copy_from_slice(b"soLD"); + assert_eq!(parse_plld(&bad), None); + let mut long = data.clone(); + long[8] = 255; // a Pascal id longer than the record + assert_eq!(parse_plld(&long[..200.min(long.len())]), None); + let mut nan = data.clone(); + nan[end - 8..end].copy_from_slice(&f64::NAN.to_be_bytes()); + assert_eq!(parse_plld(&nan), None); + assert_eq!(parse_plld(&[]), None); + } + + /// Photoshop CS2 can warp a smart object; `PlLd` keeps that warp after the corners (a u32 + /// warp version, then the versioned `warp` descriptor). It reads back as written, and + /// `warpNone`, an identity warp or a record that stops at its corners mean no warp. + #[test] + fn plld_warps_round_trip() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; + let bounds = [0.0, 0.0, 60.0, 30.0]; + let mut arc = Warp::preset(WarpStyle::Arc, 40.0, bounds); + arc.vertical = true; + arc.h_distort = 10.0; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[15] = [67.0, 30.0]; + let custom = Warp::custom(mesh, bounds); + let flat = Warp::preset(WarpStyle::Flag, 0.0, bounds); + for (w, back) in + [(Some(&arc), PlldWarp::Warp(arc.clone())), (Some(&custom), PlldWarp::Warp(custom.clone())), (Some(&flat), PlldWarp::None), (None, PlldWarp::None)] + { + let t = Affine { m: [0.5, 0.0, 0.0, 0.5, 3.0, 4.0] }; + let spec = PlacedSpec { idnt: "w", placed: "", transform: t, perspective: None, size: (60.0, 30.0), dpi: 72.0, warp: w, filter_fx: None }; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data), back, "{w:?}"); + // The corners are unchanged by the warp that follows them. + assert_eq!(parse_plld(&data).map(|(_, p)| p[..2].to_vec()), Some(vec![3.0, 4.0])); + } + let spec = + PlacedSpec { idnt: "id", placed: "", transform: Affine::IDENTITY, perspective: None, size: (4.0, 4.0), dpi: 72.0, warp: None, filter_fx: None }; + let end = 4 + 4 + 1 + 2 + 16 + 64; + let data = plld_bytes(&spec, &mut Vec::new()); + assert_eq!(parse_plld_warp(&data[..end]), PlldWarp::None, "no warp stored"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0; 3]].concat()), PlldWarp::None, "padding"); + } + + /// A `PlLd` warp that is stored but can't be read (truncated, an unknown style, a missing + /// bounds rectangle or mesh) is `Unreadable`, so the import keeps the warped pixels. + #[test] + fn plld_unreadable_warps() { + use crate::smart_map::{PlacedSpec, plld_bytes}; + use photocraft_geom::warp::{Warp, WarpStyle}; + let arc = Warp::preset(WarpStyle::Arc, 40.0, [0.0, 0.0, 4.0, 4.0]); + let spec = PlacedSpec { + idnt: "id", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (4.0, 4.0), + dpi: 72.0, + warp: Some(&arc), + filter_fx: None, + }; + let data = plld_bytes(&spec, &mut Vec::new()); + let end = 4 + 4 + 1 + 2 + 16 + 64; + assert!(matches!(parse_plld_warp(&data), PlldWarp::Warp(_))); + for n in end + 1..data.len() - 4 { + if data[end..n].iter().any(|b| *b != 0) { + assert_eq!(parse_plld_warp(&data[..n]), PlldWarp::Unreadable, "truncated to {n} bytes"); + } + } + let with = |d: Descriptor| [data[..end].to_vec(), vec![0; 4], VersionedDescriptor::new(d).to_bytes()].concat(); + let style = |s: &str| Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new(s) }; + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpSomethingNew")))), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpArc")))), PlldWarp::Unreadable, "no bounds"); + assert_eq!(parse_plld_warp(&with(Descriptor::new("warp").with("warpStyle", style("warpNone")))), PlldWarp::None); + let bounds = Descriptor::new("classFloatRect") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(4.0)) + .with("Rght", Value::Double(4.0)); + let custom = Descriptor::new("warp").with("warpStyle", style("warpCustom")).with("bounds", Value::Descriptor(bounds)); + assert_eq!(parse_plld_warp(&with(custom)), PlldWarp::Unreadable, "custom with no mesh"); + assert_eq!(parse_plld_warp(&[data[..end].to_vec(), vec![0, 0, 0, 0, 0, 0, 0, 16, 1, 2]].concat()), PlldWarp::Unreadable); + assert_eq!(parse_plld_warp(&data[..end - 1]), PlldWarp::Unreadable, "the record itself"); + assert_eq!(parse_plld_warp(&[]), PlldWarp::Unreadable); } /// `SoLd` data with a 50 × 25 source, the quad `trnf` and, when given, a custom warp whose diff --git a/crates/io/src/linked.rs b/crates/io/src/linked.rs index b1b7286d25..636b6a1266 100644 --- a/crates/io/src/linked.rs +++ b/crates/io/src/linked.rs @@ -84,14 +84,101 @@ pub fn parse_linked_files(data: &[u8]) -> Vec { /// The embedded file for smart-object `uuid` among the document's preserved PSD global blocks. pub fn find_linked_file(meta: &Metadata, uuid: &str) -> Option { + find_in_blocks(meta.psd_global_blocks.iter().map(|(_, k, d)| (k, d.as_slice())), uuid) +} + +/// The embedded file for smart-object `uuid` among PSD global blocks given as (key, data). +pub fn find_in_blocks<'a>(blocks: impl IntoIterator, uuid: &str) -> Option { if uuid.is_empty() { return None; } - meta.psd_global_blocks - .iter() - .filter(|(_, k, _)| matches!(k, b"lnk2" | b"lnk3" | b"lnkD")) - .flat_map(|(_, _, d)| parse_linked_files(d)) - .find(|f| f.uuid == uuid) + blocks.into_iter().filter(|(k, _)| matches!(*k, b"lnk2" | b"lnk3" | b"lnkD")).flat_map(|(_, d)| parse_linked_files(d)).find(|f| f.uuid == uuid) +} + +/// The size of embedded file `uuid` among PSD global blocks given as (key, data), when it is there +/// and readable with a non-empty size (the source size a `PlLd` record leaves out). +pub(crate) fn source_size_in_blocks<'a>( + blocks: impl IntoIterator, + uuid: &str, + ctl: &photocraft_raster::Interrupt, +) -> Option<(f64, f64)> { + let f = find_in_blocks(blocks, uuid)?; + if ctl.cancelled() { + return None; + } + source_size_probe(&f.bytes).filter(|(w, h)| *w > 0.0 && *h > 0.0) +} + +/// Pixel size of an embedded source file from its header: PSD/PSB, TIFF (the default page's +/// directory), PNG (`IHDR`) and JPEG (the frame header); any other image is decoded flat by +/// `photocraft-codecs`. It never imports a document: a layered TIFF can carry PSD layer data +/// whose `lnkD` holds another file, so a full import here would nest without bound (#2465). +pub(crate) fn source_size_probe(bytes: &[u8]) -> Option<(f64, f64)> { + let be32 = |at: usize| bytes.get(at..at + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])); + let size = |w: u32, h: u32| (f64::from(w), f64::from(h)); + match bytes { + _ if crate::is_psd(bytes) => be32(18).zip(be32(14)).map(|(w, h)| size(w, h)), + [b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] | [b'I', b'I', 43, 0, ..] | [b'M', b'M', 0, 43, ..] => { + let info = photocraft_codecs::tiff_info(bytes).ok()?; + let page = info.pages.get(info.default_page()?)?; + Some(size(page.width, page.height)) + } + [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, ..] => be32(16).zip(be32(20)).map(|(w, h)| size(w, h)), + [0xFF, 0xD8, ..] => jpeg_size(bytes), + _ => photocraft_codecs::decode(bytes).ok().map(|img| { + let (w, h) = img.dimensions(); + size(w, h) + }), + } +} + +/// A JPEG's size from its first start-of-frame marker (ITU-T T.81 B.2.2), walking the marker +/// segments without decoding. Each step moves forward, so a malformed file ends the walk. +fn jpeg_size(bytes: &[u8]) -> Option<(f64, f64)> { + let mut at = 2usize; + loop { + // Markers are 0xFF then a code; extra 0xFF fill bytes may come first. + while bytes.get(at) == Some(&0xFF) && bytes.get(at + 1) == Some(&0xFF) { + at += 1; + } + if *bytes.get(at)? != 0xFF { + return None; + } + let code = *bytes.get(at + 1)?; + match code { + // SOF0..SOF15, except DHT (C4), JPG (C8) and DAC (CC). + 0xC0..=0xCF if !matches!(code, 0xC4 | 0xC8 | 0xCC) => { + let h = bytes.get(at + 5..at + 7)?; + let w = bytes.get(at + 7..at + 9)?; + return Some((f64::from(u16::from_be_bytes([w[0], w[1]])), f64::from(u16::from_be_bytes([h[0], h[1]])))); + } + // Standalone markers have no length. + 0x01 | 0xD0..=0xD7 => at = at.checked_add(2)?, + // Start of scan or end of image before any frame header. + 0xDA | 0xD9 => return None, + _ => { + let len = bytes.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]])))?; + if len < 2 { + return None; + } + at = at.checked_add(2 + len)?; + } + } + } +} + +/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). +/// Other formats are decoded in full, which `ctl` can cancel (its progress is left alone: this is +/// a step of a larger operation). +pub(crate) fn source_geometry(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt) -> Result<((f64, f64), f64), String> { + if crate::is_psd(bytes) { + let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); + let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; + return Ok(((w, h), 72.0)); + } + let cancel = || ctl.cancelled(); + let doc = crate::import_with(name, bytes, &photocraft_raster::Interrupt::cancel_only(&cancel)).map_err(|e| e.to_string())?.document; + Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) } /// The four-character file type Photoshop records for an embedded file, from its contents. @@ -210,6 +297,45 @@ mod tests { use super::*; use std::sync::Arc; + /// #2465: a placed source's size comes from its header, for every format a CS2 file embeds, + /// and never by importing it as a document. + #[test] + fn source_size_probe_reads_headers() { + use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType}; + let img = Image::new(7, 5, ChannelLayout::Rgb, SampleType::U8).unwrap(); + for format in [Format::Png, Format::Jpeg, Format::Tiff, Format::Bmp] { + let bytes = photocraft_codecs::encode(&img, format, &EncodeOptions::default()).unwrap(); + assert_eq!(source_size_probe(&bytes), Some((7.0, 5.0)), "{format:?}"); + } + let mut psd = b"8BPS\0\x01".to_vec(); + psd.extend([0; 6]); + psd.extend(3u16.to_be_bytes()); + psd.extend(40u32.to_be_bytes()); // height + psd.extend(30u32.to_be_bytes()); // width + assert_eq!(source_size_probe(&psd), Some((30.0, 40.0))); + assert_eq!(source_size_probe(b"not an image"), None); + assert_eq!(source_size_probe(&[]), None); + } + + /// Malformed JPEG marker walks end without a size and without panicking or looping. + #[test] + fn jpeg_size_rejects_malformed_marker_walks() { + let sof = [0xFF, 0xC0, 0, 17, 8, 0, 5, 0, 7]; + let ok: Vec = [&[0xFF, 0xD8][..], &[0xFF, 0xE0, 0, 4, 1, 2], &[0xFF, 0xFF, 0xD0], &sof].concat(); + assert_eq!(jpeg_size(&ok), Some((7.0, 5.0)), "APP0, a fill byte and a standalone marker are skipped"); + for bad in [ + vec![0xFF, 0xD8], + vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 1], // length below 2 + vec![0xFF, 0xD8, 0xFF, 0xE0, 0xFF, 0xFF], // length past the end + vec![0xFF, 0xD8, 0x00, 0x00], // not a marker + vec![0xFF, 0xD8, 0xFF, 0xDA, 0, 2], // scan before any frame header + vec![0xFF, 0xD8, 0xFF, 0xC0, 0, 17, 8], // truncated frame header + [&[0xFF, 0xD8][..], &[0xFF; 64]].concat(), + ] { + assert_eq!(jpeg_size(&bad), None, "{bad:?}"); + } + } + #[test] fn round_trips_and_finds_by_uuid() { let a = LinkedFile { uuid: "abc-1".into(), file_name: "a.png".into(), bytes: vec![1, 2, 3] }; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index a17e783366..822364bb85 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -244,17 +244,6 @@ impl SmartOut { } } -/// Size and resolution of a smart-object source file (PSD/PSB header, `.pcraft`, or any image). -fn source_geometry(name: &str, bytes: &[u8]) -> Result<((f64, f64), f64), String> { - if crate::is_psd(bytes) { - let rd = |at: usize| bytes.get(at..at + 4).map(|b| f64::from(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))); - let (h, w) = rd(14).zip(rd(18)).ok_or("truncated PSD header")?; - return Ok(((w, h), 72.0)); - } - let doc = crate::import(name, bytes).map_err(|e| e.to_string())?.document; - Ok(((f64::from(doc.size.width), f64::from(doc.size.height)), f64::from(doc.resolution_dpi))) -} - /// The source size of a smart object whose file we can't decode: its rendered bounds taken back /// through the inverse transform (the source's top-left is the origin). fn size_from_layer(sm: &photocraft_doc::SmartObject) -> (f64, f64) { @@ -593,11 +582,14 @@ impl Ex { } LayerContent::Smart(sm) => { let parsed = sm.psd_raw.as_deref().and_then(|d| crate::smart_map::parse_sold(d)); - if sm.psd_raw.is_some() && parsed.is_none() { - // Placed-layer data we don't parse (`PlLd` only): kept verbatim. + if sm.psd_raw.is_some() && parsed.is_none() && !self.plld_edited(sm) { + // Placed-layer data that isn't `SoLd` (a CS2 `PlLd` record, or one we can't + // read) and still says what the layer is: kept verbatim. self.smart.prune_ok = false; set_principal(&mut raw, smart_keys(sm.psd_raw.as_deref().map(Vec::as_slice)), sm.psd_raw.as_ref()); } else { + // An edited CS2 `PlLd`-only layer is written like any other smart object + // (`SoLd` and a regenerated `PlLd`), with its current placement (#2465). self.smart_blocks(l, sm, parsed, &mut raw); } } @@ -767,6 +759,36 @@ impl Ex { self.warnings.extend(warnings.into_iter().map(|w| if w.starts_with("layer ") { w } else { format!("layer \"{}\": {w}", l.name) })); } + /// Whether a CS2 placed layer (`PlLd` with no `SoLd`, #2465) no longer matches its record: + /// moved, transformed, warped, filtered or given other contents. `false` for a record that + /// can't be read (or whose warp can't), or whose file isn't embedded: its contents were never + /// available, so it is kept. + fn plld_edited(&self, sm: &photocraft_doc::SmartObject) -> bool { + let Some(data) = sm.psd_raw.as_deref() else { return false }; + let Some((id, corners)) = blocks::parse_plld(data) else { return false }; + // Without its embedded file the contents were never available: keep the record as it + // is, rather than resolve the id (bytes from the file) as a path on disk. + if !self.smart.known.contains(&id) { + return false; + } + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::Unreadable => return false, + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + }; + if sm.source != (photocraft_doc::SmartSource::Linked { path: id.clone() }) + || sm.warp != warp + || !sm.smart_filters.is_empty() + || sm.filter_mask.is_some() + { + return true; + } + // Placed as the importer places it (the same source size, from the same file, and the + // same frame: a custom warp's control-point box, #3677). + let size = crate::linked::source_size_in_blocks(self.smart.globals.iter().map(|(_, k, d)| (k, d.as_slice())), &id, &photocraft_raster::Interrupt::NONE); + blocks::plld_transform(&corners, size, warp.as_ref()) != (sm.transform, sm.perspective) + } + /// Whether an imported placed layer still says what the smart object is: transform, warp, /// filter stack and filter mask as stored. fn placed_unchanged(&self, t: &crate::smart_map::Placed, data: &[u8], sm: &photocraft_doc::SmartObject, stack: &crate::smart_map::FilterStack) -> bool { @@ -798,7 +820,7 @@ impl Ex { let geometry = stored.map(|size| (size, f64::from(self.dpi))).or_else(|| { let meta = photocraft_doc::Metadata { psd_global_blocks: self.smart.globals.clone(), ..Default::default() }; let f = crate::linked::find_linked_file(&meta, path)?; - source_geometry(&f.file_name, &f.bytes).ok() + crate::linked::source_geometry(&f.file_name, &f.bytes, &photocraft_raster::Interrupt::NONE).ok() }); let (size, dpi) = geometry.unwrap_or((size_from_layer(sm), f64::from(self.dpi))); let original = self.smart.linked_contents.entry(path.clone()).or_insert(sm.contents_id); @@ -908,7 +930,8 @@ impl Ex { (format!("{stem}.psb"), data, size, f64::from(doc.resolution_dpi), Some(doc)) } else { // Formats we can't decode (vector PDF/AI…) are still embedded; their size comes from the layer. - let (size, dpi) = source_geometry(file_name, bytes).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); + let (size, dpi) = + crate::linked::source_geometry(file_name, bytes, &photocraft_raster::Interrupt::NONE).unwrap_or(((0.0, 0.0), f64::from(self.dpi))); (file_name.to_string(), bytes.to_vec(), size, dpi, None) }; let uuid = contents_uuid(contents_id, &data); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index af19a0d76e..1e77cdaa6a 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -59,6 +59,8 @@ pub(crate) struct Ctx<'a> { pub filter_effects: Vec, /// Placed layers naming the same source share editable contents, not their placement. pub smart_contents: std::collections::HashMap, + /// Source sizes of `PlLd`-only placed layers, by source id (read once per embedded file). + pub plld_sizes: std::collections::HashMap>, /// Cancellation and progress for background opens (checked per layer record). pub ctl: photocraft_raster::Interrupt<'a>, /// Layer records decoded so far, out of `total` (progress). @@ -105,11 +107,51 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 (soft || pattern).then_some((fill, soft)) } +/// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. +/// An empty `id` leaves the contents unavailable (the stored pixels stay the rendering). +#[derive(Default)] +struct Placement { + id: String, + transform: photocraft_geom::Affine, + perspective: Option<[f64; 9]>, + warp: Option, +} + impl Ctx<'_> { fn warn(&mut self, s: impl Into) { self.warnings.push(s.into()); } + /// Source id, transform, (Distort/Perspective) projective placement and warp of a placed + /// layer that has only a `PlLd` block, as Photoshop CS2 and older write it (#2465). `PlLd` + /// stores the placed corners but not the source size, which comes from the embedded file. + fn plld_placement(&mut self, data: &[u8], name: &str) -> Placement { + let Some((id, p)) = blocks::parse_plld(data) else { + self.warn(format!("layer \"{name}\": its placed-layer data (PlLd) could not be read; the smart object's contents are unavailable")); + return Placement::default(); + }; + let warp = match blocks::parse_plld_warp(data) { + blocks::PlldWarp::None => None, + blocks::PlldWarp::Warp(w) => Some(w), + blocks::PlldWarp::Unreadable => { + // Re-rendering from the source would unwarp the layer: keep it as saved (its + // warped pixels), as before PlLd was read. + self.warn(format!( + "layer \"{name}\": its smart object warp (PlLd) could not be read; the layer keeps its warped pixels but its contents are unavailable" + )); + return Placement::default(); + } + }; + let (file, ctl) = (self.file, self.ctl); + let size = *self + .plld_sizes + .entry(id.clone()) + .or_insert_with(|| crate::linked::source_size_in_blocks(file.global_blocks.iter().map(|b| (&b.key, b.data.as_slice())), &id, &ctl)); + // Without the source (missing or unreadable), it is taken as placed at 100 %. + let (transform, perspective) = blocks::plld_transform(&p, size, warp.as_ref()); + Placement { id, transform, perspective, warp } + } + fn channel_plane(&mut self, rec: &LayerRecord, id: i16, name: &str) -> Option> { rec.channel(id)?; match rec.decode_channel(id, self.file.header.depth, self.file.header.version) { @@ -267,7 +309,8 @@ impl Ctx<'_> { let fill_key = [b"SoCo", b"GdFl", b"PtFl"].into_iter().find(|k| rec.block(k).is_some()); let vector_key = [b"vsms", b"vmsk"].into_iter().find(|k| rec.block(k).is_some()); let adj_key = ADJUSTMENT_KEYS.into_iter().find(|k| rec.block(k).is_some()); - let smart_key = [b"SoLd", b"PlLd", b"SoLE"].into_iter().find(|k| rec.block(k).is_some()); + // `SoLE` (a linked smart object) before `PlLd`: a `PlLd` alone is Photoshop CS2's record. + let smart_key = [b"SoLd", b"SoLE", b"PlLd"].into_iter().find(|k| rec.block(k).is_some()); let blocks = preserved_blocks(rec); // `psd_raw` shares the Arc of the matching `psd_blocks` entry. let principal = |k: &[u8; 4]| blocks.iter().find(|(bk, _)| bk == k).map(|(_, d)| d.clone()); @@ -321,7 +364,16 @@ impl Ctx<'_> { t.psd_raw = principal(b"TySh"); LayerContent::Text(t) } else if let Some(k) = smart_key { - let (id, transform) = rec.block(k).map(|b| blocks::parse_smart(k, &b.data)).unwrap_or_default(); + let Placement { id, transform, perspective, warp } = match rec.block(k) { + Some(b) if k == b"PlLd" => self.plld_placement(&b.data, &name), + Some(b) => { + let (id, transform) = blocks::parse_smart(k, &b.data); + // Distort / Perspective: the fourth corner (the affine `transform` drops it). + let perspective = blocks::parse_smart_perspective(k, &b.data); + Placement { id, transform, perspective, warp: blocks::parse_placed_warp(k, &b.data) } + } + None => Placement::default(), + }; let contents_id = if id.is_empty() { SmartContentsId::fresh() } else { *self.smart_contents.entry(id.clone()).or_insert_with(SmartContentsId::fresh) }; // Smart filters (`filterFX` in the placed-layer data) and their mask (`FEid`). @@ -340,10 +392,9 @@ impl Ctx<'_> { psd_raw: principal(k), filters_enabled: stack.enabled, filter_mask, - warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)), + warp, stack_mode: None, - // Distort / Perspective: the fourth corner (the affine `transform` drops it). - perspective: rec.block(k).and_then(|b| blocks::parse_smart_perspective(k, &b.data)), + perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { // A shape whose vector mask has a density or feather is a fill layer seen through a @@ -650,6 +701,7 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) txt2: file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)), filter_effects: Vec::new(), smart_contents: Default::default(), + plld_sizes: Default::default(), ctl: *ctl, done: 0, total: file.layers().len(), diff --git a/crates/io/tests/smart_contents_identity.rs b/crates/io/tests/smart_contents_identity.rs index 632547b0d4..5a0714b3d8 100644 --- a/crates/io/tests/smart_contents_identity.rs +++ b/crates/io/tests/smart_contents_identity.rs @@ -199,3 +199,153 @@ fn independent_unavailable_link_retains_separate_preserved_record() { let (again, _) = round_trip(&doc, "psd"); assert_eq!(after.to_bytes().unwrap(), again.to_bytes().unwrap()); } + +/// A `PlLd`-only placed layer (Photoshop CS2, #2465) whose embedded file is missing still gets its +/// source id and placement (at an assumed 100 % scale); an unreadable `PlLd` is reported. +#[test] +fn plld_only_layers_without_their_file_or_with_a_broken_record() { + let (mut file, _) = round_trip(&fixture(), "psd"); + file.global_blocks.retain(|b| !matches!(&b.key, b"lnk2" | b"lnk3" | b"lnkD")); + let rotated = Affine { m: [0.0, 1.0, -1.0, 0.0, 6.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: "missing", placed: "", transform: rotated, perspective: None, size: (3.0, 2.0), dpi: 72.0, warp: None, filter_fx: None }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let mut broken = record.clone(); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + broken.blocks.push(TaggedBlock::new(*b"PlLd", b"plcL\0\0\0\x03\x05ab\0\0".to_vec())); + *file.layers_mut() = vec![record, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(a) = &doc.layers[0].content else { panic!("PlLd layer is not a smart object") }; + assert_eq!(a.source, SmartSource::Linked { path: "missing".into() }); + for (x, y) in a.transform.m.iter().zip(rotated.m) { + assert!((x - y).abs() < 1e-12, "{:?} vs {rotated:?}", a.transform); + } + let LayerContent::Smart(b) = &doc.layers[1].content else { panic!("broken PlLd layer is not a smart object") }; + assert_eq!(b.source, SmartSource::Linked { path: String::new() }); + assert_eq!(warnings.iter().filter(|w| w.contains("(PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// Photoshop CS2 can warp a smart object and keeps the warp in `PlLd`, after the corners. A +/// `PlLd`-only layer imports with that warp, so re-rendering it from its now-available source +/// (Free Transform, Image Size, mode changes) keeps it warped. A warp that can't be read leaves +/// the contents unavailable, so the warped pixels stay the rendering (#2465). +#[test] +fn plld_only_layers_keep_their_warp() { + use photocraft_geom::warp::{Warp, WarpStyle}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let bulge = Warp::preset(WarpStyle::Bulge, 35.0, [0.0, 0.0, 2.0, 2.0]); + let spec = |warp| smart_map::PlacedSpec { + idnt: "missing", + placed: "", + transform: Affine { m: [2.0, 0.0, 0.0, 2.0, 1.0, 0.0] }, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut warped, mut flat, mut broken) = (record.clone(), record.clone(), record); + warped.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()))); + flat.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec(None), &mut Vec::new()))); + // The warp descriptor cut short: a warp is stored but can't be read. + let mut cut = smart_map::plld_bytes(&spec(Some(&bulge)), &mut Vec::new()); + cut.truncate(cut.len() - 40); + broken.blocks.push(TaggedBlock::new(*b"PlLd", cut)); + *file.layers_mut() = vec![warped, flat, broken]; + let (doc, warnings) = photocraft_io::psd_to_document(&file); + let sm = |i: usize| match &doc.layers[i].content { + LayerContent::Smart(s) => s, + _ => panic!("layer {i} is not a smart object"), + }; + assert_eq!(sm(0).warp, Some(bulge)); + assert_eq!(sm(0).source, SmartSource::Linked { path: "missing".into() }); + assert_eq!((sm(1).warp.as_ref(), &sm(1).source), (None, &SmartSource::Linked { path: "missing".into() })); + assert_eq!(sm(2).source, SmartSource::Linked { path: String::new() }, "unreadable warp: contents unavailable"); + assert!(sm(2).cache.is_some()); + assert_eq!(warnings.iter().filter(|w| w.contains("warp (PlLd) could not be read")).count(), 1, "{warnings:?}"); +} + +/// A linked smart object with both `SoLE` and `PlLd` is read from `SoLE`: `PlLd` alone is +/// Photoshop CS2's record (#2465). +#[test] +fn sole_wins_over_plld() { + let (mut file, _) = round_trip(&fixture(), "psd"); + let spec = smart_map::PlacedSpec { + idnt: "plld-only", + placed: "", + transform: Affine::IDENTITY, + perspective: None, + size: (2.0, 2.0), + dpi: 72.0, + warp: None, + filter_fx: None, + }; + let mut record = file.layers()[0].clone(); + let sold = record.block(b"SoLd").expect("the fixture has SoLd").data.clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &sold); + assert!(!id.is_empty() && id != "plld-only"); + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + record.blocks.push(TaggedBlock::new(*b"PlLd", smart_map::plld_bytes(&spec, &mut Vec::new()))); + record.blocks.push(TaggedBlock::new(*b"SoLE", sold.clone())); + *file.layers_mut() = vec![record]; + let (doc, _) = photocraft_io::psd_to_document(&file); + let LayerContent::Smart(sm) = &doc.layers[0].content else { panic!("not a smart object") }; + assert_eq!(sm.source, SmartSource::Linked { path: id }); + assert_eq!(sm.psd_raw.as_deref(), Some(&sold)); +} + +/// A CS2 `PlLd`-only layer with a custom warp whose control points spill past its source frames +/// that control-point box with its corners, as `SoLd`'s `Trnf` does (#3677): it imports with the +/// same placement as the equivalent `SoLd` layer. Unedited, it saves its `PlLd` record verbatim +/// (no `SoLd` added) and reopens in the same place (#2465). +#[test] +fn plld_only_spilled_custom_warp_places_like_sold_and_round_trips_verbatim() { + use photocraft_geom::warp::{BezierMesh, Warp}; + let (mut file, _) = round_trip(&fixture(), "psd"); + let mut record = file.layers()[0].clone(); + let (id, _) = photocraft_io::blocks::parse_smart(b"SoLd", &record.block(b"SoLd").expect("the fixture has SoLd").data); + assert!(!id.is_empty()); + // The embedded source is 2 × 2; the mesh reaches from (-1, -0.5) to (3, 2.5). + let bounds = [0.0, 0.0, 2.0, 2.0]; + let mut mesh = BezierMesh::identity(bounds, 1, 1); + mesh.points[0] = [-1.0, -0.5]; + mesh.points[15] = [3.0, 2.5]; + let warp = Warp::custom(mesh, bounds); + let t = Affine { m: [1.5, 0.0, 0.0, 1.5, 4.0, 1.0] }; + let spec = + smart_map::PlacedSpec { idnt: &id, placed: "p", transform: t, perspective: None, size: (2.0, 2.0), dpi: 72.0, warp: Some(&warp), filter_fx: None }; + record.blocks.retain(|b| !matches!(&b.key, b"SoLd" | b"SoLE" | b"PlLd")); + let (mut with_sold, mut cs2) = (record.clone(), record); + with_sold.blocks.push(TaggedBlock::new(*b"SoLd", smart_map::sold_bytes(None, &spec, &mut Vec::new()))); + let plld = smart_map::plld_bytes(&spec, &mut Vec::new()); + cs2.blocks.push(TaggedBlock::new(*b"PlLd", plld.clone())); + *file.layers_mut() = vec![with_sold, cs2]; + + let (doc, warnings) = photocraft_io::psd_to_document(&file); + assert!(!warnings.iter().any(|w| w.contains("PlLd")), "{warnings:?}"); + let sm = |d: &Document, i: usize| match &d.layers[i].content { + LayerContent::Smart(s) => s.clone(), + _ => panic!("layer {i} is not a smart object"), + }; + let close = |a: Affine, b: Affine| a.m.iter().zip(b.m).all(|(x, y)| (x - y).abs() < 1e-9); + let (a, b) = (sm(&doc, 0), sm(&doc, 1)); + assert!(close(a.transform, t), "SoLd: {:?} vs {t:?}", a.transform); + assert!(close(b.transform, a.transform), "PlLd {:?} vs SoLd {:?}", b.transform, a.transform); + assert_eq!((&b.source, &b.warp, b.perspective), (&a.source, &a.warp, a.perspective)); + assert_eq!(b.warp.as_ref(), Some(&warp)); + assert_eq!(b.source, SmartSource::Linked { path: id.clone() }); + + // Unedited: the CS2 record is written back as it was, with no SoLd, and reopens in place. + let out = export(&doc, "psd", &ExportOptions::default()).unwrap(); + let saved = PsdFile::from_bytes(&out.bytes).unwrap(); + let rec = &saved.layers()[1]; + assert_eq!(rec.block(b"PlLd").map(|b| &b.data), Some(&plld)); + assert!(rec.block(b"SoLd").is_none(), "an unedited PlLd-only layer gained a SoLd"); + let again = import("psd", &out.bytes).unwrap().document; + let c = sm(&again, 1); + assert!(close(c.transform, t), "{:?} vs {t:?}", c.transform); + assert_eq!(c.warp.as_ref(), Some(&warp)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 12c6dc9d1d..39f7ece88f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -21,7 +21,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Format | Photoshop | PhotoCraft read | PhotoCraft write | Fidelity and tests | |---|---|---|---|---| -| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469, #2465 (#2374, text layers, closed 2026-10-10) | +| PSD | R/W | yes | yes | 1/8/16/32-bit; Bitmap, Gray, Indexed, RGB, CMYK, Multichannel, Lab, Duotone; layers, groups, masks, vector masks, adjustment and fill layers, effects, text, shapes, smart objects (SoLd/lnk2; CS2 PlLd/lnkD read with their warp, kept as is or re-saved as SoLd once edited), smart filters (14 mapped, rest verbatim), slices, comps, Advanced Blending. Oracle floors: Photoshop-authored 133/256, io 146/170, psd-tools 236/309; round trip ≥ 169/170, 308/309, 258/256. Known failures: #1281, #2469 (#2374, text layers, closed 2026-10-10; #2465, CS2 `PlLd`-only smart objects, fixed 2026-10-12) | | PSB | R/W | yes | yes | Auto-switches above 2 GB (REL-212-2); 4–5 GB fixtures generated (FILE-216-2); > 4 GB fails on Windows (#375) | | Cloud PSD (.psdc) | R/W | no | no | Out of scope (Adobe cloud) | | TIFF | R/W | yes | yes | 8/16/32f, Gray/RGB/CMYK, BigTIFF, every IFD, banded parallel decode; layered TIFF both byte orders (FILE-215-7: extra alpha channels not written) | @@ -141,6 +141,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | | 2026-10-12 | minor | Presets table: Contours `.shc` read and write through the Contour Editor (FILE-215-15); `.csh` and `.tpl` stay missing | diff --git a/docs/gaps.md b/docs/gaps.md index 5a50685368..8e75167810 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -46,7 +46,7 @@ what an agent can't finish alone. oracles 133/256 (52%), io corpus 146/170 (86%), psd-tools 236/309 (76%); round trips ~100% within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers re-saved unmodified would not reopen (#2374, closed 2026-10-10), Photoshop 26.6 cannot reopen our files (#1281), "PSD not - opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465), PSB smart object + opening in Photoshop" (#2469), a CS2-era smart object unavailable (#2465, fixed 2026-10-12), PSB smart object blank (#1764), perspective placed layers flattened (#579), PSB > 4 GB fails on Windows (#375). No test opens our exports in a third-party editor (FILE-216-4). - **Impact:** PSD is the format professionals exchange. Beta requires it to be reliable. @@ -240,6 +240,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | | 2026-10-12 | minor | G4: checkbox-only Brush Settings rows preserve the open controls and Smoothing settings (#4283); no readiness percentage or estimate changed | From 72ef526d8e0a78c1f842bf74c0ef07f3e9877196 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:47:04 +0200 Subject: [PATCH 280/523] Pen: show a close cue when hovering the first anchor (#4193) (#4361) Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 3a5dade0e2..a13f8fa569 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -304,6 +304,12 @@ pub fn pen_grab_handle(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { true } +/// Would a press at `at` close the Pen path: is the pointer on the first anchor of a path that +/// already has a segment, and no drag is under way? The canvas then shows the close cue (#4193). +pub(crate) fn over_first_anchor(pen: &PenPath, at: [f64; 2], tol: f64) -> bool { + !pen.dragging && pen.moving.is_none() && pen.knots.len() >= 2 && pen.knots.first().is_some_and(|k| (k[0][0] - at[0]).hypot(k[0][1] - at[1]) < tol) +} + /// Is a close-drag in progress: does the path end in the stand-in for its first anchor? fn closing(pen: &PenPath) -> bool { pen.knots.len() >= 3 && pen.knots.first().map(|k| k[0]) == pen.knots.last().map(|k| k[0]) @@ -892,6 +898,16 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform } draw_anchor(painter, c, last, accent); } + // Hovering the first anchor: Photoshop's close cue, a small ring beside the pointer. + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + if let Some(h) = app.hover_doc + && over_first_anchor(pen, h, tol) + && let Some(first) = pen.knots.first() + { + let c = to_scr(first[0]); + painter.rect_filled(Rect::from_center_size(c, vec2(8.0, 8.0)), 0.0, accent); + painter.circle_stroke(c + vec2(11.0, 11.0), 3.5, Stroke::new(1.5, accent)); + } } } @@ -1881,6 +1897,21 @@ mod tests { assert!(photocraft_engine::vector_cmds::shape_path(&json!({"kind": "rect"})).is_err()); } + #[test] + fn pen_shows_the_close_cue_only_over_the_first_anchor_of_a_real_segment() { + let mut app = app(); + app.ui.tool = Tool::Pen; + pen_down(&mut app, 20.0, 20.0); + pen_up(&mut app); + let one = app.ui.pen.clone().unwrap(); + assert!(!over_first_anchor(&one, [20.0, 20.0], 6.0), "a single anchor has nothing to close"); + pen_down(&mut app, 120.0, 20.0); + pen_up(&mut app); + let two = app.ui.pen.clone().unwrap(); + assert!(over_first_anchor(&two, [22.0, 21.0], 6.0)); + assert!(!over_first_anchor(&two, [60.0, 60.0], 6.0)); + } + #[test] fn pen_builds_and_closes_a_work_path() { let mut app = app(); From f1855caf73ea3bc32a4ebe90d08807771b886e18 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:47:09 +0200 Subject: [PATCH 281/523] Type tool: spelling suggestions in the edit menu (#4370) (#4375) * Type tool: spelling suggestions in the edit menu (#4370) Co-Authored-By: Claude Opus 5.5 * Type spelling menu: cap suggestions at 3 so the menu stays short (#4370) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas_tool_menu.rs | 157 ++++++++++++++++++++- crates/ui-egui/src/control.rs | 23 ++- crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/text_field_menu.rs | 6 +- crates/ui-egui/src/transform_type_tests.rs | 1 + crates/ui-egui/src/type_tool.rs | 5 + docs/context-menu-parity.md | 4 +- docs/scorecard.md | 2 +- scorecard/ui.toml | 2 +- 28 files changed, 223 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3094ce6fa1..0277fbdf43 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3183,7 +3183,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::canvas_tool_menu::open_view(app, &ctx, tool, [p.x, p.y], xf.to_doc(p)); } } else { - crate::canvas_tool_menu::open(app, &ctx, tool, [p.x, p.y]); + crate::canvas_tool_menu::open_at(app, &ctx, tool, [p.x, p.y], Some(xf.to_doc(p))); } } // The (temporary) Hand pans above; its gestures never reach the tool underneath. While a diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index b6b86111e1..7fe4fce9e3 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -31,8 +31,30 @@ pub struct CanvasToolMenu { /// Opened while a Type tool edits text: the text and type rows instead of the tool's actions. #[serde(default)] pub text: bool, + /// The misspelled word under the pointer when the type edit menu opened: its suggestions head + /// the menu. + #[serde(default)] + pub spelling: Option, } +/// A misspelled word in the type layer being edited (`edit.checkSpelling`'s `list` entry). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Spelling { + pub layer: u64, + /// The word's characters `start..end` in the layer's text. + pub start: usize, + pub end: usize, + pub word: String, + pub suggestions: Vec, +} + +/// Most suggestions the type edit menu lists. +const MAX_SUGGESTIONS: usize = 3; +/// The spelling rows' ids: `spelling.replace.` picks the n-th suggestion. +const SPELLING_REPLACE: &str = "spelling.replace."; +const SPELLING_NONE: &str = "spelling.none"; +const SPELLING_ADD: &str = "spelling.add"; + /// One menu row: label and command id. `None` is a separator. pub type Row = Option<(&'static str, &'static str)>; @@ -275,7 +297,7 @@ pub fn rows(menu: &CanvasToolMenu) -> &'static [Row] { /// Is `command` one of the open menu's rows, and enabled? pub fn available(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> bool { - rows(menu).iter().flatten().any(|(_, id)| *id == command) && entry_enabled(app, menu, command) + (rows(menu).iter().flatten().any(|(_, id)| *id == command) || is_spelling(menu, command)) && entry_enabled(app, menu, command) } /// Is `command` the transform box's current mode (checked in the transform menu)? @@ -311,6 +333,49 @@ pub fn editing_text(app: &PhotocraftApp, tool: Tool) -> bool { tool.is_type() && app.ui.text_edit.is_some() } +/// The misspelled word of the edited text under document point `doc`, with its suggestions. +fn spelling_at(app: &mut PhotocraftApp, doc: [f64; 2]) -> Option { + let layer = app.ui.text_edit.as_ref()?.layer; + if !doc.iter().all(|v| v.is_finite()) { + return None; + } + let at = crate::type_tool::offset_at(app, photocraft_doc::LayerId(layer), doc[0], doc[1]); + let found = app.session.execute("edit.checkSpelling", json!({"action": "list", "layer": layer, "suggestions": MAX_SUGGESTIONS})).ok()?; + let hit = found["misspellings"].as_array()?.iter().find(|m| { + let (a, b) = (m["start"].as_u64().unwrap_or(u64::MAX) as usize, m["end"].as_u64().unwrap_or(0) as usize); + a <= at && at <= b + })?; + Some(Spelling { + layer, + start: hit["start"].as_u64()? as usize, + end: hit["end"].as_u64()? as usize, + word: hit["word"].as_str()?.to_string(), + suggestions: hit["suggestions"].as_array()?.iter().filter_map(|s| s.as_str().map(str::to_string)).take(MAX_SUGGESTIONS).collect(), + }) +} + +/// The spelling rows heading the type edit menu (label, id): the suggestions, or a greyed note +/// when there are none, then Add to Dictionary. Empty away from a misspelled word. +pub fn spelling_rows(menu: &CanvasToolMenu) -> Vec<(String, String)> { + let Some(sp) = menu.spelling.as_ref().filter(|_| menu.text) else { return Vec::new() }; + let mut rows: Vec<(String, String)> = sp.suggestions.iter().enumerate().map(|(i, s)| (s.clone(), format!("{SPELLING_REPLACE}{i}"))).collect(); + if rows.is_empty() { + rows.push(("No Spelling Suggestions".into(), SPELLING_NONE.into())); + } + rows.push(("Add to Dictionary".into(), SPELLING_ADD.into())); + rows +} + +/// Is `command` one of the menu's spelling rows? +fn is_spelling(menu: &CanvasToolMenu, command: &str) -> bool { + spelling_rows(menu).iter().any(|(_, id)| id == command) +} + +/// Does a spelling row's label come from the UI (translated) rather than from the dictionary? +pub fn spelling_label_is_ui(id: &str) -> bool { + matches!(id, SPELLING_NONE | SPELLING_ADD) +} + /// Right-click over a Free Transform box: its modes. pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> bool { if !pos.iter().all(|v| v.is_finite()) { @@ -328,6 +393,7 @@ pub fn open_transform(app: &mut PhotocraftApp, ctx: &Context, pos: [f32; 2]) -> pasteboard: false, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -346,10 +412,24 @@ pub fn engine_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { Some(c) if menu.pasteboard => ("prefs.set".into(), json!({"values": {"interface.canvasColor": c.name()}})), _ if menu.pasteboard => ("prefs.set".into(), json!({})), _ if menu.text && matches!(command, FAUX_BOLD | FAUX_ITALIC) => ("type.setStyle".into(), json!({})), + _ if is_spelling(menu, command) => spelling_call(menu, command), _ => (command.into(), json!({})), } } +/// The `edit.checkSpelling` call a spelling row makes: replace the word with a suggestion, or add +/// it to the user dictionary. +fn spelling_call(menu: &CanvasToolMenu, command: &str) -> (String, Value) { + let Some(sp) = menu.spelling.as_ref() else { return ("edit.checkSpelling".into(), json!({})) }; + let replace = command.strip_prefix(SPELLING_REPLACE).and_then(|n| n.parse::().ok()).and_then(|n| sp.suggestions.get(n)); + match replace { + Some(to) => { + ("edit.checkSpelling".into(), json!({"action": "change", "layer": sp.layer, "start": sp.start, "end": sp.end, "word": sp.word, "replace": to})) + } + None => ("edit.checkSpelling".into(), json!({"action": "addToDictionary", "word": sp.word})), + } +} + /// Does a right-click with `tool` at document point `doc` open the pasteboard menu? Outside the /// image, for the tools whose right-click has no job there: not the painting tools (their Brush /// Preset picker or right-button erase), the Pen (its path menu) or text being edited. @@ -377,6 +457,7 @@ pub fn open_pasteboard(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: pasteboard: true, anchor: None, text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -406,6 +487,8 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } if menu.text { return match command { + SPELLING_NONE => false, + c if is_spelling(menu, c) => app.ui.text_edit.is_some(), FAUX_BOLD | FAUX_ITALIC => app.ui.text_edit.is_some(), c => TYPE_EDIT_MENU.iter().flatten().any(|(_, id)| *id == c) && crate::menus::is_enabled(app, c), }; @@ -449,6 +532,12 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str) } pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) -> bool { + open_at(app, ctx, tool, pos, None) +} + +/// [`open`] for a right-click at document point `doc`: over a misspelled word of the text being +/// edited, the menu starts with its spelling suggestions. +pub fn open_at(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2], doc: Option<[f64; 2]>) -> bool { let text = editing_text(app, tool); if !(applies(tool) || text) || !pos.iter().all(|v| v.is_finite()) { return false; @@ -458,7 +547,9 @@ pub fn open(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; 2]) - let has_selection = app.session.active().is_some_and(|s| s.doc.selection.is_some()); let has_path = crate::vector_ui::active_path_name(app).is_some() || app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2); let path_name = crate::vector_ui::active_path_name(app); - app.ui.canvas_tool_menu = Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text }); + let spelling = if text { doc.and_then(|d| spelling_at(app, d)) } else { None }; + app.ui.canvas_tool_menu = + Some(CanvasToolMenu { pos, tool, has_selection, has_path, path_name, transform: false, pasteboard: false, anchor: None, text, spelling }); crate::canvas_popup::mark_open(ctx, menu_id()); true } @@ -481,6 +572,7 @@ pub fn open_view(app: &mut PhotocraftApp, ctx: &Context, tool: Tool, pos: [f32; pasteboard: false, anchor: Some(doc), text: false, + spelling: None, }); crate::canvas_popup::mark_open(ctx, menu_id()); true @@ -521,6 +613,8 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { choose_pasteboard(app, &menu, command) } else if menu.anchor.is_some() { choose_view(app, ctx, &menu, command) + } else if menu.text && is_spelling(&menu, command) { + choose_spelling(app, &menu, command) } else if menu.text { choose_type(app, ctx, command) } else if menu.tool == Tool::Pen && !menu.transform { @@ -538,6 +632,19 @@ pub fn choose(app: &mut PhotocraftApp, ctx: &Context, command: &str) { } } +/// A spelling row: replace the word under the pointer (the caret lands after the replacement), or +/// add it to the user dictionary. +fn choose_spelling(app: &mut PhotocraftApp, menu: &CanvasToolMenu, command: &str) -> Result<(), String> { + let (id, params) = engine_call(menu, command); + let result = app.run(&id, params)?; + if let Some(end) = result["end"].as_u64().map(|e| e as usize) + && let Some(e) = app.ui.text_edit.as_mut() + { + (e.caret, e.anchor) = (end, end); + } + Ok(()) +} + /// A type edit row. Undo, Cut, Copy, Paste and Select All run as their Edit and Select menu items, /// which act on the text while it is being edited (Paste asks the window for the clipboard text). fn choose_type(app: &mut PhotocraftApp, ctx: &Context, command: &str) -> Result<(), String> { @@ -668,6 +775,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { ui.spacing_mut().item_spacing.y = 0.0; ui.set_width(200.0); crate::widgets::menu_scroll(ui, |ui| { + let spelling = spelling_rows(&menu); + for (label, command) in &spelling { + let text = if spelling_label_is_ui(command) { tl!(label).to_string() } else { label.clone() }; + let item = egui::Button::new(text).min_size(egui::vec2(200.0, 20.0)); + if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { + selected = Some(command.clone()); + } + } + if !spelling.is_empty() { + ui.separator(); + } for row in rows(&menu) { let Some((label, command)) = *row else { ui.separator(); @@ -675,14 +793,14 @@ pub fn show(app: &mut PhotocraftApp, ctx: &Context) { }; let item = egui::Button::selectable(mode_checked(app, &menu, command), tl!(label)).min_size(egui::vec2(200.0, 20.0)); if ui.add_enabled(entry_enabled(app, &menu, command), item).clicked() { - selected = Some(command); + selected = Some((*command).to_string()); } } }); }); }); if let Some(command) = selected { - choose(app, ctx, command); + choose(app, ctx, &command); } else if crate::canvas_popup::outside_pressed(ctx, id, area.response.rect) { app.ui.canvas_tool_menu = None; } @@ -849,6 +967,37 @@ mod tests { assert!(app.ui.text_edit.is_none() && text_of(&app, id).is_none(), "the type layer is pixels now"); } + #[test] + fn type_edit_menu_lists_spelling_suggestions_for_the_word_under_the_pointer() { + let mut app = app(); + app.ui.tool = Tool::Type; + let id = app.run("type.create", json!({"text": "Teh cat", "size": 12, "x": 4, "y": 20})).unwrap()["layer"].as_u64().unwrap(); + let ctx = Context::default(); + crate::menus::invoke(&mut app, &ctx, "type.editText", json!({})).unwrap(); + // Away from the misspelling (past the end of the line): no spelling rows. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + assert!(spelling_rows(app.ui.canvas_tool_menu.as_ref().unwrap()).is_empty()); + // Over "Teh": its suggestions head the menu, then Add to Dictionary. + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([5.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + let rows = spelling_rows(&menu); + assert!(rows.iter().any(|(l, _)| l == "The"), "{rows:?}"); + assert_eq!(rows.last().map(|(_, id)| id.as_str()), Some(SPELLING_ADD)); + assert!(rows.len() <= MAX_SUGGESTIONS + 1, "at most {MAX_SUGGESTIONS} suggestions and Add to Dictionary: {rows:?}"); + assert!(available(&app, &menu, &rows[0].1)); + let pick = rows.iter().find(|(l, _)| l == "The").unwrap().1.clone(); + choose(&mut app, &ctx, &pick); + assert_eq!(text_of(&app, id).unwrap().text, "The cat"); + assert_eq!(app.ui.text_edit.as_ref().map(|e| (e.caret, e.anchor)), Some((3, 3)), "the caret lands after the replacement"); + // Add to Dictionary stops flagging a word. + app.run("type.insertText", json!({"layer": id, "text": " Zqx"})).unwrap(); + assert!(open_at(&mut app, &ctx, Tool::Type, [10.0, 10.0], Some([600.0, 15.0]))); + let menu = app.ui.canvas_tool_menu.clone().unwrap(); + assert!(menu.spelling.as_ref().is_some_and(|s| s.word == "Zqx")); + choose(&mut app, &ctx, SPELLING_ADD); + assert!(app.session.prefs().user_dictionary.iter().any(|w| w == "Zqx")); + } + #[test] fn right_click_outside_the_image_picks_the_pasteboard_color() { let mut app = app(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ceacb71928..e797b27cc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -366,6 +366,22 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest outcome } +/// The open canvas context menu's rows for `ui.state`: the spelling suggestions first (type edit +/// menu over a misspelled word), then the menu's own rows. +fn context_entries(app: &PhotocraftApp, menu: &crate::canvas_tool_menu::CanvasToolMenu) -> Vec { + use crate::canvas_tool_menu::{entry_enabled, rows, spelling_rows}; + let spelling = spelling_rows(menu); + let mut entries: Vec = spelling.iter().map(|(label, id)| json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)})).collect(); + if !spelling.is_empty() { + entries.push(json!({"separator": true})); + } + entries.extend(rows(menu).iter().map(|row| match row { + Some((label, id)) => json!({"label": label, "id": id, "enabled": entry_enabled(app, menu, id)}), + None => json!({"separator": true}), + })); + entries +} + fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -1027,7 +1043,7 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } if crate::canvas_tool_menu::applies(app.ui.tool) || crate::canvas_tool_menu::editing_text(app, app.ui.tool) { if down { - crate::canvas_tool_menu::open(app, ctx, app.ui.tool, screen_point(app, x, y)); + crate::canvas_tool_menu::open_at(app, ctx, app.ui.tool, screen_point(app, x, y), Some([x, y])); } continue; } @@ -1259,10 +1275,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "pos": menu.pos, "tool": menu.tool, "pasteboard": menu.pasteboard, - "entries": crate::canvas_tool_menu::rows(menu).iter().map(|row| match row { - Some((label, id)) => json!({"label": label, "id": id, "enabled": crate::canvas_tool_menu::entry_enabled(app, menu, id)}), - None => json!({"separator": true}), - }).collect::>() + "entries": context_entries(app, menu) }) }), "panels": app.ui.panels, diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6b2c38cc89..2a9c4a9d5d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -138,6 +138,8 @@ Search… Hledat… Check Spelling… Kontrola pravopisu… Find and Replace Text… Najít a nahradit text… + Add to Dictionary Přidat do slovníku + No Spelling Suggestions Žádné návrhy pravopisu Fill… Vyplnit… Stroke… Tah… Content-Aware Fill… Výplň s ohledem na obsah… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e86037b7f4..d209af709e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -134,6 +134,8 @@ Search… Suchen… Check Spelling… Rechtschreibung prüfen… Find and Replace Text… Text suchen und ersetzen… + Add to Dictionary Zum Wörterbuch hinzufügen + No Spelling Suggestions Keine Rechtschreibvorschläge Fill… Füllen… Stroke… Nachziehen… Content-Aware Fill… Inhaltsbasiert füllen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ffa683b6e0..e27766fefa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -115,6 +115,8 @@ Search… Αναζήτηση… Check Spelling… Ορθογραφικός έλεγχος… Find and Replace Text… Εύρεση και αντικατάσταση κειμένου… + Add to Dictionary Προσθήκη στο λεξικό + No Spelling Suggestions Δεν υπάρχουν ορθογραφικές προτάσεις Fill… Γέμισμα… Stroke… Περίγραμμα… Preferences… Προτιμήσεις… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 49b56d3520..62886211e8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -131,6 +131,8 @@ Search… Buscar… Check Spelling… Revisar ortografía… Find and Replace Text… Buscar y reemplazar texto… + Add to Dictionary Añadir al diccionario + No Spelling Suggestions Sin sugerencias ortográficas Fill… Rellenar… Stroke… Contornear… Content-Aware Fill… Relleno según el contenido… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1e30b6a39e..7789b2c02b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -136,6 +136,8 @@ Search… Rechercher… Check Spelling… Vérifier l'orthographe… Find and Replace Text… Rechercher et remplacer du texte… + Add to Dictionary Ajouter au dictionnaire + No Spelling Suggestions Aucune suggestion orthographique Fill… Remplir… Stroke… Contourner… Content-Aware Fill… Remplissage d'après le contenu… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c3a74e745f..1c9f15f0ba 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -126,6 +126,8 @@ File Info… Fájladatok… Fill… Kitöltés… Find and Replace Text… Szöveg keresése és cseréje… + Add to Dictionary Hozzáadás a szótárhoz + No Spelling Suggestions Nincs helyesírási javaslat Fit Image… Kép méretre igazítása… Flatten All Layer Effects Összes rétegeffektus egybesimítása Flatten All Masks Összes maszk egybesimítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 30fecce4c2..cb77490c4b 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -129,6 +129,8 @@ Search… Cari… Check Spelling… Periksa Ejaan… Find and Replace Text… Cari dan Ganti Teks… + Add to Dictionary Tambahkan ke Kamus + No Spelling Suggestions Tidak Ada Saran Ejaan Fill… Isi… Stroke… Stroke… Content-Aware Fill… Isi Content-Aware… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 12a8c283ea..288353a61b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -131,6 +131,8 @@ Search… Cerca… Check Spelling… Controlla ortografia… Find and Replace Text… Trova e sostituisci testo… + Add to Dictionary Aggiungi al dizionario + No Spelling Suggestions Nessun suggerimento ortografico Fill… Riempi… Stroke… Traccia… Content-Aware Fill… Riempimento in base al contenuto… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fafb869f8e..26a31ce4de 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -132,6 +132,8 @@ Search… 検索… Check Spelling… スペルチェック… Find and Replace Text… テキストを検索して置換… + Add to Dictionary 辞書に追加 + No Spelling Suggestions スペルの候補はありません Fill… 塗りつぶし… Stroke… 境界線を描く… Content-Aware Fill… コンテンツに応じた塗りつぶし… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3609ad270d..b6a95907d3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -121,6 +121,8 @@ Search… 검색… Check Spelling… 맞춤법 검사… Find and Replace Text… 텍스트 찾기 및 바꾸기… + Add to Dictionary 사전에 추가 + No Spelling Suggestions 맞춤법 제안 없음 Fill… 채우기… Stroke… 획… Content-Aware Fill… 내용 인식 채우기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index abc3f3f5c6..1ec6d64b0f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -124,6 +124,8 @@ Search… Zoeken… Check Spelling… Spelling controleren… Find and Replace Text… Tekst zoeken en vervangen… + Add to Dictionary Aan woordenboek toevoegen + No Spelling Suggestions Geen spellingsuggesties Fill… Vullen… Stroke… Omlijnen… Content-Aware Fill… Inhoudsbewust vullen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ddda4d88d4..fab352b28a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -131,6 +131,8 @@ Search… Szukaj… Check Spelling… Sprawdź pisownię… Find and Replace Text… Znajdź i zamień tekst… + Add to Dictionary Dodaj do słownika + No Spelling Suggestions Brak sugestii pisowni Fill… Wypełnij… Stroke… Obrys… Content-Aware Fill… Wypełnienie z uwzględnieniem zawartości… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 05408b834e..b7c0def324 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -132,6 +132,8 @@ Search… Pesquisar… Check Spelling… Verificar ortografia… Find and Replace Text… Localizar e substituir texto… + Add to Dictionary Adicionar ao dicionário + No Spelling Suggestions Sem sugestões de ortografia Fill… Preencher… Stroke… Traçado… Content-Aware Fill… Preenchimento sensível ao conteúdo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 6e5ee708ae..2f174fd7b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -270,6 +270,8 @@ Check Spelling Проверка орфографии Language Options Языковые параметры Find and Replace Text… Найти и заменить текст… + Add to Dictionary Добавить в словарь + No Spelling Suggestions Нет вариантов написания Paste Lorem Ipsum Вставить условный текст Glyphs Panel Панель «Глифы» Type Styles Стили текста diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4305ff7faa..e9dc17d8d4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -121,6 +121,8 @@ Search… Ara… Check Spelling… Yazımı Denetle… Find and Replace Text… Metni Bul ve Değiştir… + Add to Dictionary Sözlüğe Ekle + No Spelling Suggestions Yazım Önerisi Yok Fill… Doldur… Stroke… Kontur… Content-Aware Fill… İçeriğe Uygun Dolgu… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index de4e42fc58..0facb9e9cc 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -248,6 +248,8 @@ Check Spelling Перевірити правопис Language Options Мовні параметри Find and Replace Text… Знайти та замінити текст… + Add to Dictionary Додати до словника + No Spelling Suggestions Немає варіантів написання Paste Lorem Ipsum Вставити Lorem Ipsum Glyphs Panel Панель гліфів Type Styles Текстові стилі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5ed7ec483a..03ad654f76 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -73,6 +73,8 @@ Fill… Tô… Filter Bộ lọc Find and Replace Text… Tìm và thay chữ… + Add to Dictionary Thêm vào từ điển + No Spelling Suggestions Không có gợi ý chính tả Fit Image… Vừa khung ảnh… Flatten All Layer Effects Làm phẳng mọi hiệu ứng lớp Flatten All Masks Làm phẳng mọi mặt nạ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 29d2b57ea6..080a5ac03d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -130,6 +130,8 @@ Search… 搜索… Check Spelling… 检查拼写… Find and Replace Text… 查找和替换文本… + Add to Dictionary 添加到词典 + No Spelling Suggestions 没有拼写建议 Fill… 填充… Stroke… 描边… Content-Aware Fill… 内容识别填充… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 36f523934d..3ed91649c2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -138,6 +138,8 @@ Search… 搜尋… Check Spelling… 拼字檢查… Find and Replace Text… 尋找並取代文字… + Add to Dictionary 加入字典 + No Spelling Suggestions 沒有拼字建議 Fill… 填滿… Stroke… 筆畫… Content-Aware Fill… 內容感知填滿… diff --git a/crates/ui-egui/src/text_field_menu.rs b/crates/ui-egui/src/text_field_menu.rs index 420263e7ef..6556c68cfc 100644 --- a/crates/ui-egui/src/text_field_menu.rs +++ b/crates/ui-egui/src/text_field_menu.rs @@ -17,9 +17,9 @@ //! caret to the pointer on any press), so Copy and Cut act on it. //! //! The right-click belongs to the field: the canvas and its tool menus only see pointer presses -//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu -//! yet: the spell checker (`edit.checkSpelling`, whose `suggest` action lists them) is not wired -//! into text fields. +//! on the canvas, which a field drawn above it takes. Spelling suggestions are not in this menu: +//! the spell checker (`edit.checkSpelling`) checks type layers, so they head the Type tool's edit +//! menu instead (`canvas_tool_menu`, over a misspelled word of the text being edited, #4370). use egui::text::CCursorRange; use egui::{Context, Event, Id, Key, Modifiers, PointerButton, Pos2, RawInput, SurrenderFocusOn, Ui}; diff --git a/crates/ui-egui/src/transform_type_tests.rs b/crates/ui-egui/src/transform_type_tests.rs index ea3c128a18..dcbbb70b3c 100644 --- a/crates/ui-egui/src/transform_type_tests.rs +++ b/crates/ui-egui/src/transform_type_tests.rs @@ -52,6 +52,7 @@ fn type_layers_offer_free_transform_but_not_distort_or_perspective() { pasteboard: false, anchor: None, text: false, + spelling: None, }; assert!(crate::canvas_tool_menu::available(&app, &menu, "edit.freeTransform")); assert!(!crate::canvas_tool_menu::available(&app, &menu, "edit.transform.distort")); diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index dc99138a32..6a7c816084 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -151,6 +151,11 @@ pub fn edit_at(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { } fn hit_offset(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { + offset_at(app, id, x, y) +} + +/// The character offset of the caret position nearest document point (`x`, `y`) in layer `id`. +pub(crate) fn offset_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64) -> usize { let Some((l, aff, text)) = layout(app, id) else { return 0 }; let (tx, ty) = to_text(&aff, x, y); photocraft_text::hit_char(&l, &text, tx, ty).0 diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 425c91f42f..739e445cdc 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -27,7 +27,7 @@ The “PhotoCraft at branch point” column records behavior before this PR. Pic | Ruler, Note, Count | Unit / measurement, note edit/delete, count-group operations **(inferred)** | No tool menu | P2 | | Brush, Pencil, Eraser, Background Eraser, Spot Healing, Healing, Clone Stamp, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge | Brush preset, size and hardness where applicable | Picker | P0; [Adobe Brush Settings](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html). Alt+secondary-drag on Windows and Control+Option-drag on macOS adjust size/hardness ([Adobe cursor shortcuts](https://helpx.adobe.com/in/photoshop/desktop/get-started/settings-and-preferences/change-tool-pointers.html)) | | Gradient, Paint Bucket | Gradient preset/type/mode or fill source/tolerance **(inferred context placement)** | No tool menu | P1 | -| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Spelling suggestions for the word under the pointer and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | +| Type | In text: cut/copy/paste, spelling suggestions, text formatting; outside text: type-layer action **(inferred context placement)** | While editing text (canvas_tool_menu.rs `TYPE_EDIT_MENU`): Undo, Cut, Copy, Paste, Select All (on the text), Check Spelling…, Find and Replace Text…, Faux Bold/Italic (checked), Rasterize Type, Create Work Path, Convert to Shape, Horizontal/Vertical and the anti-alias modes (checked), Convert to Paragraph/Point Text, Warp Text…; Esc closes only the menu. Over a misspelled word the menu starts with up to 3 spelling suggestions (replace the word) and Add to Dictionary (#4370); Ignore / Ignore All and the non-editing type-layer menu remain open | P1; separate editing and nonediting states; [Adobe type editing](https://helpx.adobe.com/photoshop/desktop/text-typography/get-started-with-text/edit-text.html) | | Hand, Zoom | Fit on Screen, 100%, 200%, Print Size; zoom step choices **(inferred context placement)** | View menu (#3242): Zoom In, Zoom Out, Fit on Screen, Fill Screen, 100%, 200%, Print Size. Zoom In/Out step about the right-clicked point (Preferences › Tools › Zoom Clicked Point to Center centres it). Outside the image the pasteboard menu still wins. | P1; view actions stay out of document history; row order still needs a dated Photoshop capture | | Pen, Path Selection | Path/point actions, Make Selection, Fill/Stroke Path, Free Transform Path **(inferred context placement)** | Pen canvas right-click follows the user-provided 20-row Photoshop capture below. Paths rows offer Make Selection, Fill and Stroke. Path Selection's own canvas menu remains open. | P1; distinguish node, segment, path, and blank canvas | | Rectangle, Ellipse Shape, Triangle, Polygon, Line, Custom Shape | Shape/path operations, fill/stroke settings, transform **(inferred context placement)** | No tool menu | P2 | @@ -83,7 +83,7 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | | Document tab | Close, Close Others, Close All, Reveal in file manager, duplicate/move to window where supported | Close, Close Others, Close All, Reveal in Finder, and drag to reorder implemented; duplicate/move to window where supported remains open | **Inferred** exact list; P1 | | Empty pasteboard / workspace | Gray, Black, Custom canvas color choices; verify any additional entries in a current Photoshop capture | No menu | Adobe [documents right-clicking outside the image for canvas color](https://helpx.adobe.com/ph_en/photoshop/using/filling-stroking-selections-layers-paths.html); exact current layout still needs a capture | -| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions missing: `edit.checkSpelling` (`suggest`) is not wired into the menu yet (UI-217-8) | P1; **inferred** exact spelling list | +| Text field / selected text | Native edit menu and spelling suggestions without routing to active image tool | Edit menu implemented in text_field_menu.rs for every text field (Undo, Cut, Copy, Paste, Delete, Select All; a right-click inside the selection keeps it; never reaches the canvas or tool menu). Spelling suggestions live in the Type tool's edit menu (canvas_tool_menu.rs), not in generic fields, which hold no type layer text (UI-217-8) | P1; **inferred** exact spelling list | | Tool icon in Options bar | Reset Tool, Reset All Tools | Audit | Explicitly documented in [Adobe Photoshop reference](https://helpx.adobe.com/archive/en/photoshop/cc/2015/photoshop_reference.pdf); historical source, verify modern label | | Adaptive Wide Angle constraint line | Auto, Horizontal, Vertical orientation | Current right-click deletes constraint | P1 behavior mismatch: [Adobe documents orientation choice](https://helpx.adobe.com/photoshop/using/adaptive-wide-angle-filter.html) | diff --git a/docs/scorecard.md b/docs/scorecard.md index 651d68309f..d25198c082 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -189,7 +189,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-5 | Panel groups can float (menu + drag out of the dock) and are restored at launch | missing | [#217](https://github.com/storytold/photocraft/issues/217) | No floating groups in dock.rs/panels.rs | | UI-217-6 | Document tabs: overflow list, context menu (Close/Others/All/Reveal), drag to reorder | done | [#217](https://github.com/storytold/photocraft/issues/217) | canvas.rs: Close/Others/All menu with unsaved prompt; Reveal reuses file.revealInFinder (engine layer_menu_cmds::reveal); the » chevron (tab_strip) lists overflow; dragging a tab reorders via document.move | | UI-217-7 | Command palette shows recently run commands on an empty query | done | [#217](https://github.com/storytold/photocraft/issues/217) | palette.rs: note_recent from menus::invoke keeps the last 8 commands (dedup, newest first); an empty query lists them via recent_hits; the_palette_records_and_replays_recent_commands_on_an_empty_query covers record -> empty query -> replay | -| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet | +| UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | | UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 3fa79b59fa..54cc13876c 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -59,7 +59,7 @@ id = "UI-217-8" target = "Tool-specific canvas right-click menu; right-click spelling suggestions in text fields" status = "partial" issue = 217 -note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; spelling suggestions (edit.checkSpelling suggest) not in the text-field menu yet" +note = "paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet" [[item]] id = "UI-217-9" From b507e04f1f78872e66c1dc98e35580dd65aac2ac Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:14 +0200 Subject: [PATCH 282/523] Menus keep clear of a panel that covers a maximized window (X11) (#3737) (#4380) On X11 a window manager that doesn't reserve a bottom panel's space maximizes PhotoCraft to the whole monitor, and the panel covers its bottom. The work-area check (#315) trusted every maximized window to be inside the work area, so long menus (Layer, View, Window, Edit at larger UI scales) ran under the panel with their scroll arrow hidden. A maximized window whose position is known is now checked like any other; only full screen, or maximized with no position (Wayland, #3739), uses the whole window. --- crates/ui-egui/src/menu_nav_tests.rs | 54 ++++++++++++++++- crates/ui-egui/src/work_area.rs | 87 +++++++++++++++++++++------- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index 2dafc1c5c1..1643e2765b 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -563,9 +563,61 @@ fn submenus_stay_above_the_taskbar() { } } +/// #3737 on X11 (Linux Mint, 1920 × 1080 px): the window manager maximized the window to the whole +/// monitor without keeping it clear of the bottom panel, so the window's position is known but the +/// panel covers its bottom. At 1× and at a 150% UI scale (1280 × 720 pt). +fn maximized_under_a_panel(scale: f32) -> Harness<'static, PhotocraftApp> { + let size = egui::vec2(1920.0, 1080.0) / scale; + let mut h = harness((1920.0, 1080.0, scale)); + let v = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + v.monitor_size = Some(size); + v.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), size)); + v.maximized = Some(true); + h.run_steps(3); + h +} + +#[test] +fn menus_of_a_maximized_window_stay_above_a_panel_it_runs_under() { + let mut scrolled = 0; + for scale in [1.0_f32, 1.5] { + for menu in LONGEST.iter().copied().chain(["View", "Window"]) { + let mut h = maximized_under_a_panel(scale); + let bottom = taskbar_bottom(&h); + let what = format!("{scale}x {menu}"); + assert!((bottom - (1080.0 / scale - crate::work_area::TASKBAR)).abs() < 1.0, "{what}: visible bottom {bottom}"); + open(&mut h, menu); + for r in popups(&h) { + assert!(r.bottom() <= bottom + 0.5, "{what}: popup {r:?} runs under the panel (from {bottom})"); + } + let last = rows(&h, 0).len() - 1; + let in_view = |h: &Harness<'static, PhotocraftApp>| { + let nav = Nav::current(&h.ctx); + nav.views[0].expand(0.5).contains_rect(nav.rows[0][last].rect) && nav.views[0].bottom() <= bottom + 0.5 + }; + let Some(down) = h.query_by_label("Scroll menu down").map(|n| n.rect()) else { + assert!(in_view(&h), "{what}: fits, so its last row is in view"); + continue; + }; + scrolled += 1; + assert!(down.bottom() <= bottom, "{what}: ▼ at {down:?} is under the panel"); + h.hover_at(down.center()); + for _ in 0..400 { + h.run_steps(1); + if in_view(&h) { + break; + } + } + assert!(in_view(&h), "{what}: ▼ scrolls to the last row, above the panel"); + } + } + assert!(scrolled >= 3, "at 150% the long menus overflow above the panel: {scrolled}"); +} + #[test] fn a_maximized_window_or_an_unknown_monitor_uses_the_whole_window() { - let mut h = taskbar_harness((1366.0, 728.0), 0.0, 1.0); + // Maximized inside the work area (the window manager kept it clear of the taskbar). + let mut h = taskbar_harness((1366.0, 720.0), 0.0, 1.0); h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); h.run_steps(2); assert_eq!(crate::work_area::visible_rect(&h.ctx), h.ctx.content_rect()); diff --git a/crates/ui-egui/src/work_area.rs b/crates/ui-egui/src/work_area.rs index 0ac79ea6a2..df4bc7a77e 100644 --- a/crates/ui-egui/src/work_area.rs +++ b/crates/ui-egui/src/work_area.rs @@ -19,11 +19,19 @@ pub const TASKBAR: f32 = 48.0; const MIN_VISIBLE: f32 = 160.0; /// Where the window's content can be seen, in egui points. `monitor` is the monitor's size, -/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]); `fills_screen` -/// is true for a maximized or full-screen window (already inside the work area). -pub fn visible_part(content: Rect, monitor: Option, inner: Option, fills_screen: bool) -> Rect { +/// `inner` the content's rect on the desktop (both from [`egui::ViewportInfo`]). `state` says +/// whether the window is maximized or full screen. +pub fn visible_part(content: Rect, monitor: Option, inner: Option, state: WindowState) -> Rect { let Some(m) = monitor else { return content }; - if fills_screen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + if state == WindowState::FullScreen || !m.x.is_finite() || !m.y.is_finite() || m.x < 1.0 || m.y < 1.0 { + return content; + } + // A maximized window is usually inside the work area already, but not always (#3737): on X11 + // a window manager that doesn't reserve a panel's space (Cinnamon's Muffin with PhotoCraft's + // own title bar, a panel without a strut) maximizes it to the whole monitor, and the panel then + // covers its bottom. Where the window is known, it is checked like any other; only a maximized + // window with no position (Wayland) is taken to fit. + if state == WindowState::Maximized && inner.is_none() { return content; } // #3737: Wayland never tells a window where it is (winit's `inner_position` fails), so egui has @@ -49,6 +57,27 @@ pub fn visible_part(content: Rect, monitor: Option, inner: Option, f trusted(content, local) } +/// How the window sits on its monitor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowState { + Normal, + Maximized, + /// Full screen covers the taskbar on purpose: all of it can be seen. + FullScreen, +} + +impl WindowState { + fn of(v: &egui::ViewportInfo) -> Self { + if v.fullscreen == Some(true) { + Self::FullScreen + } else if v.maximized == Some(true) { + Self::Maximized + } else { + Self::Normal + } + } +} + /// `content` cut to `usable`, unless that leaves too little to be a sensible guess. fn trusted(content: Rect, usable: Rect) -> Rect { let visible = content.intersect(usable); @@ -63,8 +92,7 @@ pub fn visible_rect(ctx: &egui::Context) -> Rect { let content = ctx.content_rect(); ctx.input(|i| { let v = i.viewport(); - let fills = v.maximized == Some(true) || v.fullscreen == Some(true); - visible_part(content, v.monitor_size, v.inner_rect, fills) + visible_part(content, v.monitor_size, v.inner_rect, WindowState::of(v)) }) } @@ -107,6 +135,7 @@ pub fn fit_window(ctx: &egui::Context) -> bool { #[cfg(test)] mod tests { use super::*; + use WindowState::{FullScreen, Maximized, Normal}; use egui::{Rect, pos2, vec2}; const MONITOR: Vec2 = vec2(1366.0, 768.0); @@ -119,29 +148,46 @@ mod tests { fn a_window_taller_than_its_display_is_cut_at_the_taskbar() { // 1440 × 900 at the desktop origin on 1366 × 768: right and bottom are off-screen. let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // Below a 31 pt title bar, a full-height window reaches under the taskbar. let (content, inner) = window(0.0, 31.0, 1366.0, 737.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR - 31.0); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR - 31.0); // A small window clear of the taskbar is all visible. let (content, inner) = window(100.0, 80.0, 800.0, 500.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); // On a second monitor to the right, the same. let (content, inner) = window(1366.0 + 100.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false).bottom(), 768.0 - TASKBAR); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal).bottom(), 768.0 - TASKBAR); } #[test] fn maximized_unknown_or_nonsense_input_keeps_the_window() { let (content, inner) = window(0.0, 0.0, 1440.0, 900.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), true), content, "maximized: already in the work area"); - assert_eq!(visible_part(content, None, Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), false), content); - assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), FullScreen), content, "full screen covers the taskbar on purpose"); + assert_eq!(visible_part(content, None, Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(f32::NAN, 768.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(vec2(0.0, 0.0)), Some(inner), Normal), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(Rect::from_min_size(pos2(f32::INFINITY, 0.0), vec2(10.0, 10.0))), Normal), content); // A guess that would leave almost nothing visible is not trusted. let (content, inner) = window(0.0, 600.0, 1000.0, 300.0); - assert_eq!(visible_part(content, Some(MONITOR), Some(inner), false), content); + assert_eq!(visible_part(content, Some(MONITOR), Some(inner), Normal), content); + } + + #[test] + fn a_maximized_window_under_a_panel_is_still_cut_at_the_taskbar() { + // #3737, X11 (Linux Mint): the window manager maximized the window to the whole 1920 × 1080 + // monitor without reserving the bottom panel's space, so the panel covers it. + for scale in [1.0_f32, 1.5] { + let monitor = vec2(1920.0, 1080.0) / scale; + let content = Rect::from_min_size(pos2(0.0, 0.0), monitor); + let bottom = monitor.y - TASKBAR; + assert_eq!(visible_part(content, Some(monitor), Some(content), Maximized).bottom(), bottom, "at {scale}×"); + // A window manager that reserves the panel: the maximized window already ends above it. + let fitted = Rect::from_min_size(pos2(0.0, 0.0), vec2(monitor.x, bottom)); + assert_eq!(visible_part(fitted, Some(monitor), Some(fitted), Maximized), fitted, "at {scale}×"); + // Full screen is meant to cover the panel. + assert_eq!(visible_part(content, Some(monitor), Some(content), FullScreen), content, "at {scale}×"); + } } #[test] @@ -149,13 +195,14 @@ mod tests { // #3737, Wayland: only the monitor's size is known. A window taller than the monitor less // the taskbar can be seen at most down to there, wherever it is. let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(1366.0, 740.0)); - assert_eq!(visible_part(content, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(content, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); let big = Rect::from_min_size(pos2(0.0, 0.0), vec2(1440.0, 900.0)); - assert_eq!(visible_part(big, Some(MONITOR), None, false), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); + assert_eq!(visible_part(big, Some(MONITOR), None, Normal), Rect::from_min_max(pos2(0.0, 0.0), pos2(1366.0, 768.0 - TASKBAR))); // One that fits, or a maximized one, is all visible. let small = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 600.0)); - assert_eq!(visible_part(small, Some(MONITOR), None, false), small); - assert_eq!(visible_part(content, Some(MONITOR), None, true), content); + assert_eq!(visible_part(small, Some(MONITOR), None, Normal), small); + assert_eq!(visible_part(content, Some(MONITOR), None, Maximized), content); + assert_eq!(visible_part(content, Some(MONITOR), None, FullScreen), content); } fn frame(ctx: &egui::Context, monitor: Option, outer: Option, maximized: Option) -> Vec { From 10ba166a8268557b9feb11fb03bcaa568cd7afc9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:16 +0200 Subject: [PATCH 283/523] feat(codecs): open DDS textures, uncompressed and BC1-BC3 (#1327) (#4255) A read-only DirectDraw Surface codec on the model of PCX: the top mip level of a 2D texture, uncompressed (channel masks, RGB or luminance, 8 to 32 bits) or BC1/BC2/BC3 (DXT1/3/5), with the legacy or the DX10 header. Cubemaps, arrays, volumes and other formats are refused with an Unsupported error naming what was found; every header field is treated as hostile (checked sizes, limits before allocating, no indexing on file offsets). Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/photocraft-web/src/web.rs | 4 +- apps/photocraft/src/services.rs | 4 +- crates/codecs/README.md | 7 + crates/codecs/src/codecs/dds.rs | 284 ++++++++++++++++++++++++++++++++ crates/codecs/src/codecs/mod.rs | 1 + crates/codecs/src/format.rs | 16 +- crates/codecs/src/lib.rs | 4 +- crates/codecs/tests/dds.rs | 268 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 4 +- 9 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 crates/codecs/src/codecs/dds.rs create mode 100644 crates/codecs/tests/dds.rs diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index daf4ed9efb..e44a0e259b 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -18,8 +18,8 @@ type Inbox = Arc)>>>; /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", - "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", + "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index b24c2a025a..7ec91954b1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -21,8 +21,8 @@ mod recovery_tests; /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", - "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", + "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 8cba2b1f37..2409c46e07 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -40,6 +40,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?; | BMP | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | TGA | yes | yes | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | no | `image` | | ICO | yes | yes | U8 | RGBA (at most 256×256) | yes | no | no | no | no | no | no | `image` | +| DDS | yes | **no** | U8 | Gray, GrayA, RGB, RGBA | yes | no | no | no | no | no | n/a | built in | | Netpbm (PBM/PGM/PPM/PAM/PFM) | yes | yes | U8, U16, F32 (float: PFM gray/RGB only) | all six (PAM `CMYK`/`CMYK_ALPHA`) | yes (PAM) | no | no | no | no | no | no | built in | | QOI | yes | yes | U8 | RGB, RGBA | yes | no | no | no | no | no | no | `image` | | OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` | @@ -67,6 +68,12 @@ Palette BMP pixels decode as RGB in `Image`; `bmp_palette` exposes the embedded ## Documented asymmetries and limitations +* **DDS.** The top mip level of a 2D texture: uncompressed pixels described by channel masks + (8, 16, 24 or 32 bits, RGB or luminance, with or without alpha) or BC1/BC2/BC3 (DXT1/DXT3/DXT5), + with the legacy header or the DX10 one. Everything opens as U8; the other mip levels are + ignored. Cubemaps, texture arrays, volume textures, premultiplied DXT2/DXT4 and other formats + (BC4–BC7, float) are refused with `CodecError::Unsupported`. Writing is not provided. + * **FITS.** The image in the primary HDU, or the first `IMAGE` extension, is read; `NAXIS3 = 3` opens as RGB and any other plane count opens the first plane with `DecodeWarning::MorePages`. Rows follow `ROWORDER` (bottom-up by default, as Siril and DS9 do). Physical values that fit diff --git a/crates/codecs/src/codecs/dds.rs b/crates/codecs/src/codecs/dds.rs new file mode 100644 index 0000000000..efe93c7f2e --- /dev/null +++ b/crates/codecs/src/codecs/dds.rs @@ -0,0 +1,284 @@ +//! DirectDraw Surface (DDS) reader: the top mip level of a 2D texture, either uncompressed +//! (8 to 32 bits per pixel described by channel masks, RGB or luminance) or block-compressed +//! BC1/BC2/BC3 (DXT1/DXT3/DXT5), with or without the DX10 header. Read-only. Cubemaps, +//! texture arrays and volume textures are refused rather than flattened to one face or slice. + +use crate::SampleType; +use crate::error::CodecError; +use crate::format::Format; +use crate::image::{ChannelLayout, Image}; +use crate::options::Limits; + +/// `DDS ` plus the 124-byte DDS_HEADER. +const HEADER: usize = 128; +/// DDS_HEADER_DXT10, present when the FourCC is `DX10`. +const DX10_HEADER: usize = 20; + +const DDSD_DEPTH: u32 = 0x80_0000; +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; +const DDSCAPS2_CUBEMAP: u32 = 0x200; +const DDSCAPS2_VOLUME: u32 = 0x20_0000; +const DIMENSION_TEXTURE2D: u32 = 3; +const MISC_TEXTURECUBE: u32 = 0x4; + +fn bad(reason: impl std::fmt::Display) -> CodecError { + CodecError::malformed(Format::Dds, reason) +} +fn unsupported(reason: impl Into) -> CodecError { + CodecError::unsupported(Format::Dds, reason) +} +fn le32(b: &[u8], at: usize) -> Result { + let bytes = b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).ok_or_else(|| bad("truncated header"))?; + Ok(u32::from_le_bytes(bytes)) +} + +/// A FourCC as text when it is printable, else as the number legacy writers store there. +fn fourcc_name(fourcc: u32) -> String { + let bytes = fourcc.to_le_bytes(); + if bytes.iter().all(|c| c.is_ascii_graphic() || *c == b' ') { bytes.iter().map(|&c| char::from(c)).collect() } else { fourcc.to_string() } +} + +/// One channel of an uncompressed pixel: where it sits and its largest value. +#[derive(Clone, Copy)] +struct Field { + shift: u32, + max: u32, +} + +impl Field { + /// `None` for an empty mask; an error for one with gaps or wider than the pixel. + fn new(mask: u32, bits: u32) -> Result, CodecError> { + if mask == 0 { + return Ok(None); + } + let shift = mask.trailing_zeros(); + let max = mask >> shift; + if max & max.wrapping_add(1) != 0 || (bits < 32 && mask >> bits != 0) { + return Err(bad(format!("channel mask {mask:#x} is not a contiguous field of a {bits}-bit pixel"))); + } + Ok(Some(Field { shift, max })) + } + + fn get(self, pixel: u32) -> u8 { + let v = u64::from((pixel >> self.shift) & self.max); + let max = u64::from(self.max); + ((v * 255 + max / 2) / max) as u8 + } +} + +enum Encoding { + /// Uncompressed: bytes per pixel, then red (or luminance), green, blue and alpha. + Masks { + bytes: usize, + gray: bool, + fields: [Option; 4], + }, + Bc1, + Bc2, + Bc3, +} + +impl Encoding { + fn masks(bits: u32, masks: [u32; 4], gray: bool) -> Result { + if !matches!(bits, 8 | 16 | 24 | 32) { + return Err(bad(format!("{bits} bits per pixel is not 8, 16, 24 or 32"))); + } + let mut fields = [None; 4]; + for (field, mask) in fields.iter_mut().zip(masks) { + *field = Field::new(mask, bits)?; + } + let colour = if gray { fields[0].is_some() } else { fields[..3].iter().any(Option::is_some) }; + if !colour { + return Err(bad("the pixel format has no colour channel masks")); + } + Ok(Encoding::Masks { bytes: bits as usize / 8, gray, fields }) + } + + fn layout(&self) -> ChannelLayout { + match self { + Encoding::Masks { gray: true, fields, .. } if fields[3].is_some() => ChannelLayout::GrayA, + Encoding::Masks { gray: true, .. } => ChannelLayout::Gray, + Encoding::Masks { fields, .. } if fields[3].is_none() => ChannelLayout::Rgb, + _ => ChannelLayout::Rgba, + } + } + + /// Bytes of the top mip level. + fn level_size(&self, width: u32, height: u32) -> Option { + let (w, h) = (usize::try_from(width).ok()?, usize::try_from(height).ok()?); + let block = match self { + Encoding::Masks { bytes, .. } => return w.checked_mul(*bytes)?.checked_mul(h), + Encoding::Bc1 => 8, + Encoding::Bc2 | Encoding::Bc3 => 16, + }; + w.div_ceil(4).checked_mul(h.div_ceil(4))?.checked_mul(block) + } +} + +/// The legacy pixel format: a FourCC, or channel masks. +fn legacy_encoding(b: &[u8]) -> Result { + let (flags, fourcc, bits) = (le32(b, 80)?, le32(b, 84)?, le32(b, 88)?); + let alpha = if flags & DDPF_ALPHAPIXELS != 0 { le32(b, 104)? } else { 0 }; + if flags & DDPF_FOURCC != 0 { + return match &fourcc.to_le_bytes() { + b"DXT1" => Ok(Encoding::Bc1), + b"DXT3" => Ok(Encoding::Bc2), + b"DXT5" => Ok(Encoding::Bc3), + b"DXT2" | b"DXT4" => Err(unsupported("premultiplied-alpha DXT2/DXT4 textures are not supported")), + _ => Err(unsupported(format!("compression `{}` is not supported (DXT1, DXT3 and DXT5 are)", fourcc_name(fourcc)))), + }; + } + if flags & DDPF_RGB != 0 { + return Encoding::masks(bits, [le32(b, 92)?, le32(b, 96)?, le32(b, 100)?, alpha], false); + } + if flags & DDPF_LUMINANCE != 0 { + return Encoding::masks(bits, [le32(b, 92)?, 0, 0, alpha], true); + } + Err(unsupported(format!("pixel format flags {flags:#x} are not RGB, luminance or a supported compression"))) +} + +/// The DX10 header's DXGI format, refusing anything but a single 2D texture. +fn dx10_encoding(b: &[u8]) -> Result { + let (dxgi, dimension, misc, array) = (le32(b, HEADER)?, le32(b, HEADER + 4)?, le32(b, HEADER + 8)?, le32(b, HEADER + 12)?); + if misc & MISC_TEXTURECUBE != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if dimension != DIMENSION_TEXTURE2D { + return Err(unsupported(format!("only 2D textures are supported (resource dimension {dimension})"))); + } + if array > 1 { + return Err(unsupported(format!("texture arrays are not supported ({array} textures)"))); + } + const RGBA: [u32; 4] = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + const BGRA: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + const BGRX: [u32; 4] = [0xff_0000, 0xff00, 0xff, 0]; + match dxgi { + 27..=29 => Encoding::masks(32, RGBA, false), + 87 | 90 | 91 => Encoding::masks(32, BGRA, false), + 88 | 92 | 93 => Encoding::masks(32, BGRX, false), + 70..=72 => Ok(Encoding::Bc1), + 73..=75 => Ok(Encoding::Bc2), + 76..=78 => Ok(Encoding::Bc3), + _ => Err(unsupported(format!("DXGI format {dxgi} is not supported (BC1, BC2, BC3 and 8-bit RGBA or BGRA are)"))), + } +} + +pub(crate) fn decode(b: &[u8], limits: &Limits) -> Result { + if !b.starts_with(b"DDS ") { + return Err(bad("missing the `DDS ` signature")); + } + if le32(b, 4)? != 124 { + return Err(bad("header size is not 124")); + } + let (flags, height, width, depth, caps2) = (le32(b, 8)?, le32(b, 12)?, le32(b, 16)?, le32(b, 24)?, le32(b, 112)?); + if caps2 & DDSCAPS2_CUBEMAP != 0 { + return Err(unsupported("cubemaps are not supported")); + } + if caps2 & DDSCAPS2_VOLUME != 0 || (flags & DDSD_DEPTH != 0 && depth > 1) { + return Err(unsupported("volume textures are not supported")); + } + let dx10 = le32(b, 80)? & DDPF_FOURCC != 0 && &le32(b, 84)?.to_le_bytes() == b"DX10"; + let (encoding, start) = if dx10 { (dx10_encoding(b)?, HEADER + DX10_HEADER) } else { (legacy_encoding(b)?, HEADER) }; + let layout = encoding.layout(); + limits.check(width, height, layout, SampleType::U8)?; + let size = encoding.level_size(width, height).ok_or_else(|| bad("pixel data size overflow"))?; + let data = b.get(start..).and_then(|d| d.get(..size)).ok_or_else(|| bad(format!("truncated pixel data: {size} bytes needed")))?; + let (w, h) = (width as usize, height as usize); + let channels = layout.channels(); + let total = w.checked_mul(h).and_then(|n| n.checked_mul(channels)).ok_or_else(|| bad("pixel size overflow"))?; + let mut pixels = vec![0u8; total]; + match encoding { + Encoding::Masks { bytes, gray, fields } => { + for (src, dst) in data.chunks_exact(bytes).zip(pixels.chunks_exact_mut(channels)) { + let pixel = src.iter().rev().fold(0u32, |v, &byte| v << 8 | u32::from(byte)); + let channel = |i: usize| fields[i].map_or(0, |f| f.get(pixel)); + let values = if gray { [channel(0), channel(3), 0, 0] } else { [channel(0), channel(1), channel(2), channel(3)] }; + dst.copy_from_slice(&values[..channels]); + } + } + Encoding::Bc1 | Encoding::Bc2 | Encoding::Bc3 => { + let block_size = if matches!(encoding, Encoding::Bc1) { 8 } else { 16 }; + let blocks_x = w.div_ceil(4); + for (i, block) in data.chunks_exact(block_size).enumerate() { + let texels = match encoding { + Encoding::Bc1 => bc1(block, true), + Encoding::Bc2 => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc2_alpha(block)), + _ => with_alpha(bc1(block.get(8..).unwrap_or_default(), false), bc3_alpha(block)), + }; + let (bx, by) = ((i % blocks_x) * 4, (i / blocks_x) * 4); + for (t, texel) in texels.iter().enumerate() { + let (x, y) = (bx + t % 4, by + t / 4); + if x < w && y < h { + let at = (y * w + x) * 4; + if let Some(dst) = pixels.get_mut(at..at + 4) { + dst.copy_from_slice(texel); + } + } + } + } + } + } + Image::from_u8(width, height, layout, pixels) +} + +fn rgb565(c: u16) -> [u8; 3] { + let scale = |v: u16, max: u16| ((u32::from(v) * 255 + u32::from(max) / 2) / u32::from(max)) as u8; + [scale(c >> 11, 31), scale((c >> 5) & 63, 63), scale(c & 31, 31)] +} + +/// A BC1 colour block. BC2 and BC3 colour blocks always have four colours; only BC1's +/// `c0 <= c1` order selects three colours and transparent black. +fn bc1(block: &[u8], one_bit_alpha: bool) -> [[u8; 4]; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (c0, c1) = (u16::from_le_bytes([byte(0), byte(1)]), u16::from_le_bytes([byte(2), byte(3)])); + let (a, b) = (rgb565(c0), rgb565(c1)); + let mix = |wa: u32, wb: u32| { + let d = wa + wb; + let at = |i: usize| ((wa * u32::from(a[i]) + wb * u32::from(b[i]) + d / 2) / d) as u8; + [at(0), at(1), at(2), 255] + }; + let palette = if c0 > c1 || !one_bit_alpha { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(2, 1), mix(1, 2)] + } else { + [[a[0], a[1], a[2], 255], [b[0], b[1], b[2], 255], mix(1, 1), [0; 4]] + }; + let indices = u32::from_le_bytes([byte(4), byte(5), byte(6), byte(7)]); + std::array::from_fn(|t| palette[(indices >> (2 * t)) as usize & 3]) +} + +/// BC2: four explicit bits of alpha per texel. +fn bc2_alpha(block: &[u8]) -> [u8; 16] { + let bits = u64::from_le_bytes(std::array::from_fn(|i| block.get(i).copied().unwrap_or(0))); + std::array::from_fn(|t| ((bits >> (4 * t)) & 0xf) as u8 * 17) +} + +/// BC3: two alpha endpoints and a 3-bit index per texel into eight values, or into six plus +/// 0 and 255 when `a0 <= a1`. +fn bc3_alpha(block: &[u8]) -> [u8; 16] { + let byte = |i: usize| block.get(i).copied().unwrap_or(0); + let (a0, a1) = (u32::from(byte(0)), u32::from(byte(1))); + let mut palette = [a0, a1, 0, 0, 0, 0, 0, 255]; + if a0 > a1 { + for (k, v) in palette.iter_mut().enumerate().skip(2) { + let k = k as u32; + *v = ((8 - k) * a0 + (k - 1) * a1 + 3) / 7; + } + } else { + for (k, v) in palette.iter_mut().enumerate().take(6).skip(2) { + let k = k as u32; + *v = ((6 - k) * a0 + (k - 1) * a1 + 2) / 5; + } + } + let bits = u64::from_le_bytes([byte(2), byte(3), byte(4), byte(5), byte(6), byte(7), 0, 0]); + std::array::from_fn(|t| palette[((bits >> (3 * t)) & 7) as usize] as u8) +} + +fn with_alpha(mut texels: [[u8; 4]; 16], alpha: [u8; 16]) -> [[u8; 4]; 16] { + for (texel, a) in texels.iter_mut().zip(alpha) { + texel[3] = a; + } + texels +} diff --git a/crates/codecs/src/codecs/mod.rs b/crates/codecs/src/codecs/mod.rs index 16a0d0e159..b0b3ca46e9 100644 --- a/crates/codecs/src/codecs/mod.rs +++ b/crates/codecs/src/codecs/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod dds; pub(crate) mod deep_exr; pub(crate) mod dpx; pub(crate) mod exr; diff --git a/crates/codecs/src/format.rs b/crates/codecs/src/format.rs index e1e0927fad..dca235f80f 100644 --- a/crates/codecs/src/format.rs +++ b/crates/codecs/src/format.rs @@ -19,6 +19,8 @@ pub enum Format { Pcx, /// X PixMap icons (XPM2 plain text and XPM3 C-string arrays). Xpm, + /// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3. + Dds, Qoi, OpenExr, /// Radiance RGBE. @@ -67,6 +69,10 @@ pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[ Format::Pcx, "PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.", ), + ( + Format::Dds, + "DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.", + ), ( Format::Avif, "AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \ @@ -89,7 +95,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C impl Format { /// Every format known to the crate (enabled or not). - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::Png, Format::Jpeg, Format::Tiff, @@ -101,6 +107,7 @@ impl Format { Format::Pnm, Format::Pcx, Format::Xpm, + Format::Dds, Format::Qoi, Format::OpenExr, Format::Hdr, @@ -133,6 +140,7 @@ impl Format { Format::Pnm => "Netpbm", Format::Pcx => "PCX", Format::Xpm => "XPM", + Format::Dds => "DDS", Format::Qoi => "QOI", Format::OpenExr => "OpenEXR", Format::Hdr => "Radiance HDR", @@ -158,6 +166,7 @@ impl Format { Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"], Format::Pcx => &["pcx"], Format::Xpm => &["xpm"], + Format::Dds => &["dds"], Format::Qoi => &["qoi"], Format::OpenExr => &["exr"], Format::Hdr => &["hdr"], @@ -181,6 +190,7 @@ impl Format { Format::Pnm => "image/x-portable-anymap", Format::Pcx => "image/x-pcx", Format::Xpm => "image/x-xpixmap", + Format::Dds => "image/vnd-ms.dds", Format::Qoi => "image/qoi", Format::OpenExr => "image/x-exr", Format::Hdr => "image/vnd.radiance", @@ -232,6 +242,7 @@ pub fn caps(format: Format) -> FormatCaps { Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base }, Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base }, Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base }, + Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base }, Format::Qoi => base, Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base }, Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base }, @@ -267,6 +278,9 @@ pub fn detect(bytes: &[u8]) -> Option { if b.starts_with(b"qoif") { return Some(Format::Qoi); } + if b.starts_with(b"DDS ") { + return Some(Format::Dds); + } if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) { return Some(Format::OpenExr); } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3974929ee0..ec663ace78 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -43,7 +43,7 @@ pub use crate::resolution::{exif_resolution, export_exif, export_xmp, photoshop_ pub use crate::source_settings::{WebpEncoding, jpeg_quality, webp_encoding}; pub use half::f16; -use crate::codecs::{dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; +use crate::codecs::{dds, dpx, exr, fits, heif, jpeg, pcx, png, pnm, tiff, via_image, webp, xpm}; /// Detect the format and decode with default [`Limits`]. pub fn decode(bytes: &[u8]) -> Result { @@ -76,6 +76,7 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Pnm => pnm::decode(bytes, l), Format::Pcx => pcx::decode(bytes, l), Format::Xpm => xpm::decode(bytes, l), + Format::Dds => dds::decode(bytes, l), Format::Fits => fits::decode(bytes, l), Format::Dpx => dpx::decode(bytes, l), Format::OpenExr => exr::decode(bytes, l), @@ -227,6 +228,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result pnm::encode(image, plan, opts), Format::Pcx => Err(CodecError::unsupported(format, "PCX is import-only; save as PNG instead")), Format::Xpm => Err(CodecError::unsupported(format, "XPM is import-only; save as PNG instead")), + Format::Dds => Err(CodecError::unsupported(format, "DDS is import-only; save as PNG instead")), Format::Fits => fits::encode(image, plan, opts), Format::Dpx => dpx::encode(image, plan, opts), Format::OpenExr => exr::encode(image, plan, opts), diff --git a/crates/codecs/tests/dds.rs b/crates/codecs/tests/dds.rs new file mode 100644 index 0000000000..766555f6e5 --- /dev/null +++ b/crates/codecs/tests/dds.rs @@ -0,0 +1,268 @@ +use photocraft_codecs::{ + ChannelLayout, CodecError, DecodeOptions, Format, Limits, SampleType, caps, decode, decode_as, decode_as_with, detect, encode, from_extension, +}; + +const DDPF_ALPHAPIXELS: u32 = 0x1; +const DDPF_FOURCC: u32 = 0x4; +const DDPF_RGB: u32 = 0x40; +const DDPF_LUMINANCE: u32 = 0x2_0000; + +/// A DDS_HEADER for a single-level 2D texture, after the `DDS ` signature. +fn header(width: u32, height: u32, pf_flags: u32, fourcc: &[u8; 4], bits: u32, masks: [u32; 4]) -> Vec { + let mut b = vec![0u8; 128]; + b[0..4].copy_from_slice(b"DDS "); + let mut put = |at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + put(4, 124); + put(8, 0x1007); // CAPS | HEIGHT | WIDTH | PIXELFORMAT + put(12, height); + put(16, width); + put(76, 32); + put(80, pf_flags); + put(88, bits); + for (i, m) in masks.into_iter().enumerate() { + put(92 + 4 * i, m); + } + put(108, 0x1000); // DDSCAPS_TEXTURE + b[84..88].copy_from_slice(fourcc); + b +} + +fn compressed(width: u32, height: u32, fourcc: &[u8; 4]) -> Vec { + header(width, height, DDPF_FOURCC, fourcc, 0, [0; 4]) +} + +/// The DX10 extension header after a `DX10` FourCC: format, dimension, misc flags, array size. +fn dx10(width: u32, height: u32, dxgi: u32, dimension: u32, misc: u32, array: u32) -> Vec { + let mut b = compressed(width, height, b"DX10"); + for v in [dxgi, dimension, misc, array, 0] { + b.extend(v.to_le_bytes()); + } + b +} + +/// BC1 colour endpoints as RGB565, then one 2-bit index per pixel, row by row. +fn bc1_block(c0: u16, c1: u16, rows: [u8; 4]) -> Vec { + let mut b = Vec::new(); + b.extend(c0.to_le_bytes()); + b.extend(c1.to_le_bytes()); + b.extend(rows); + b +} + +const RED: u16 = 0xf800; +const GREEN: u16 = 0x07e0; +const BLUE: u16 = 0x001f; +const BLACK: u16 = 0x0000; +const WHITE: u16 = 0xffff; + +/// Indices 0, 1 on the top row and 2, 3 below them, for the top-left 2x2 pixels. +const INDICES_0123: [u8; 4] = [0x04, 0x0e, 0, 0]; + +#[test] +fn dds_is_readable_but_not_writable() { + assert_eq!(from_extension("texture.DDS"), Some(Format::Dds)); + assert!(caps(Format::Dds).read && !caps(Format::Dds).write); + let img = photocraft_codecs::Image::from_u8(1, 1, ChannelLayout::Rgba, vec![0, 0, 0, 0]).unwrap(); + assert!(matches!(encode(&img, Format::Dds, &Default::default()), Err(CodecError::Unsupported { .. }))); +} + +#[test] +fn decodes_bc1_four_colour_blocks() { + // c0 > c1: four opaque colours, the two in between at thirds. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(RED, BLUE, INDICES_0123)); + assert_eq!(detect(&b), Some(Format::Dds)); + let im = decode(&b).unwrap(); + assert_eq!((im.dimensions(), im.layout(), im.sample_type()), ((2, 2), ChannelLayout::Rgba, SampleType::U8)); + assert_eq!(im.data(), &[255, 0, 0, 255, 0, 0, 255, 255, 170, 0, 85, 255, 85, 0, 170, 255]); +} + +#[test] +fn decodes_bc1_one_bit_alpha_blocks() { + // c0 <= c1: three colours (the third halfway) and index 3 is transparent black. + let mut b = compressed(2, 2, b"DXT1"); + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn decodes_bc2_explicit_alpha_with_four_colour_blocks() { + let mut b = compressed(2, 2, b"DXT3"); + // Four bits of alpha per pixel: 15, 0 on the top row, 8, 1 below. + b.extend([0x0f, 0x00, 0x18, 0x00, 0, 0, 0, 0]); + // c0 < c1 would mean transparency in BC1; BC2 colour blocks always have four colours. + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 136, 85, 85, 85, 17]); +} + +#[test] +fn decodes_bc3_interpolated_alpha() { + // a0 > a1: eight alpha values, six interpolated at sevenths. Indices 0, 1 / 2, 7. + let mut b = compressed(2, 2, b"DXT5"); + b.extend([255, 0, 0x08, 0xa0, 0x03, 0, 0, 0]); + b.extend(bc1_block(BLACK, WHITE, [0x04, 0x0b, 0, 0])); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 0, 255, 255, 255, 255, 0, 170, 170, 170, 219, 85, 85, 85, 36]); + + // a0 <= a1: six values, four interpolated at fifths, then 0 and 255. Indices 2, 5 / 6, 7. + let mut b = compressed(2, 2, b"DXT5"); + let bits: u64 = 2 | 5 << 3 | 6 << 12 | 7 << 15; + b.extend([0, 255]); + b.extend(&bits.to_le_bytes()[..6]); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + assert_eq!(decode(&b).unwrap().data(), &[0, 255, 0, 51, 0, 255, 0, 204, 0, 255, 0, 0, 0, 255, 0, 255]); +} + +#[test] +fn bc_textures_need_not_be_a_multiple_of_four() { + // 5x3 is two blocks wide and one high; the right block's first column is x = 4. + let mut b = compressed(5, 3, b"DXT1"); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (5, 3)); + let px = |x: usize, y: usize| &im.data()[(y * 5 + x) * 4..(y * 5 + x + 1) * 4]; + assert_eq!((px(3, 2), px(4, 0), px(4, 2)), (&[255, 0, 0, 255][..], &[0, 0, 255, 255][..], &[0, 0, 255, 255][..])); +} + +#[test] +fn decodes_uncompressed_rgba_and_bgra_masks() { + let rgba = [0xff, 0xff00, 0xff_0000, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, rgba); + b.extend([1, 2, 3, 4, 250, 251, 252, 253]); + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgba); + assert_eq!(im.data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + let bgra = [0xff_0000, 0xff00, 0xff, 0xff00_0000]; + let mut b = header(2, 1, DDPF_RGB | DDPF_ALPHAPIXELS, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 4, 252, 251, 250, 253]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 250, 251, 252, 253]); + + // An alpha mask without DDPF_ALPHAPIXELS is not alpha: X8R8G8B8 opens as RGB. + let mut b = header(1, 1, DDPF_RGB, &[0; 4], 32, bgra); + b.extend([3, 2, 1, 0]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..])); +} + +#[test] +fn decodes_packed_masks_and_luminance() { + // R5G6B5: each field scaled to 0..255; 3 of 31 is 25. + let mut b = header(4, 1, DDPF_RGB, &[0; 4], 16, [0xf800, 0x07e0, 0x001f, 0]); + for v in [RED, GREEN, BLUE, 3 << 11] { + b.extend(v.to_le_bytes()); + } + let im = decode(&b).unwrap(); + assert_eq!(im.layout(), ChannelLayout::Rgb); + assert_eq!(im.data(), &[255, 0, 0, 0, 255, 0, 0, 0, 255, 25, 0, 0]); + + // 24-bit rows are packed three bytes a pixel, without padding. + let mut b = header(2, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([3, 2, 1, 6, 5, 4, 9, 8, 7, 12, 11, 10]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + + let mut b = header(2, 1, DDPF_LUMINANCE | DDPF_ALPHAPIXELS, &[0; 4], 16, [0xff, 0, 0, 0xff00]); + b.extend([10, 20, 30, 40]); + let im = decode(&b).unwrap(); + assert_eq!((im.layout(), im.data()), (ChannelLayout::GrayA, &[10u8, 20, 30, 40][..])); +} + +#[test] +fn decodes_dx10_headers() { + let mut b = dx10(1, 1, 28, 3, 0, 1); // R8G8B8A8_UNORM + b.extend([1, 2, 3, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(1, 1, 91, 3, 0, 1); // B8G8R8A8_UNORM_SRGB + b.extend([3, 2, 1, 4]); + assert_eq!(decode(&b).unwrap().data(), &[1, 2, 3, 4]); + + let mut b = dx10(2, 2, 71, 3, 0, 1); // BC1_UNORM + b.extend(bc1_block(BLUE, RED, INDICES_0123)); + assert_eq!(decode(&b).unwrap().data(), &[0, 0, 255, 255, 255, 0, 0, 255, 128, 0, 128, 255, 0, 0, 0, 0]); +} + +#[test] +fn only_the_top_mip_level_is_read() { + let mut b = compressed(4, 4, b"DXT1"); + b[28..32].copy_from_slice(&3u32.to_le_bytes()); + b.extend(bc1_block(RED, RED, [0; 4])); + b.extend(bc1_block(BLUE, BLUE, [0; 4])); + b.extend(bc1_block(GREEN, GREEN, [0; 4])); + let im = decode(&b).unwrap(); + assert_eq!(im.dimensions(), (4, 4)); + assert!(im.data().chunks(4).all(|p| p == [255, 0, 0, 255])); +} + +#[test] +fn refuses_cubemaps_arrays_and_volumes() { + let unsupported = |b: &[u8], what: &str| match decode_as(Format::Dds, b) { + Err(CodecError::Unsupported { reason, .. }) => assert!(reason.contains(what), "{reason}"), + other => panic!("{what}: {other:?}"), + }; + let mut cube = compressed(4, 4, b"DXT1"); + cube[112..116].copy_from_slice(&0xfe00u32.to_le_bytes()); // DDSCAPS2_CUBEMAP and all faces + cube.extend([0; 8 * 6]); + unsupported(&cube, "cubemap"); + let mut volume = compressed(4, 4, b"DXT1"); + volume[8..12].copy_from_slice(&(0x1007u32 | 0x80_0000).to_le_bytes()); // DDSD_DEPTH + volume[24..28].copy_from_slice(&4u32.to_le_bytes()); + volume[112..116].copy_from_slice(&0x20_0000u32.to_le_bytes()); // DDSCAPS2_VOLUME + volume.extend([0; 8 * 4]); + unsupported(&volume, "volume"); + unsupported(&dx10(4, 4, 71, 3, 0x4, 1), "cubemap"); + unsupported(&dx10(4, 4, 71, 3, 0, 6), "array"); + unsupported(&dx10(4, 4, 71, 4, 0, 1), "2D"); + unsupported(&dx10(4, 4, 98, 3, 0, 1), "98"); // BC7_UNORM + unsupported(&compressed(4, 4, b"ATI2"), "ATI2"); + unsupported(&compressed(4, 4, b"DXT2"), "premultiplied"); +} + +#[test] +fn rejects_truncated_files_at_every_length_without_panicking() { + let mut b = dx10(5, 3, 77, 3, 0, 1); // BC3_UNORM: two 16-byte blocks + b.extend([0x5a; 32]); + assert!(decode(&b).is_ok()); + for n in 0..b.len() { + assert!(decode_as(Format::Dds, &b[..n]).is_err(), "decoded {n} of {} bytes", b.len()); + } + let mut b = header(3, 2, DDPF_RGB, &[0; 4], 24, [0xff_0000, 0xff00, 0xff, 0]); + b.extend([7; 18]); + assert!(decode(&b).is_ok()); + assert!(decode_as(Format::Dds, &b[..b.len() - 1]).is_err(), "one byte short"); +} + +#[test] +fn rejects_bogus_sizes_and_pixel_formats() { + // Huge dimensions are refused before anything is allocated. + let mut huge = compressed(u32::MAX, u32::MAX, b"DXT1"); + huge.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &huge), Err(CodecError::LimitExceeded(_)))); + let opts = DecodeOptions { limits: Limits { max_alloc: 1024, ..Default::default() }, ..Default::default() }; + let mut tight = header(64, 64, DDPF_RGB, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + tight.extend(vec![0; 64 * 64 * 4]); + assert!(matches!(decode_as_with(Format::Dds, &tight, &opts), Err(CodecError::LimitExceeded(_)))); + assert!(decode_as(Format::Dds, &compressed(0, 4, b"DXT1")).is_err(), "zero width"); + + let mut bad_size = compressed(4, 4, b"DXT1"); + bad_size[4..8].copy_from_slice(&100u32.to_le_bytes()); + bad_size.extend([0; 8]); + assert!(matches!(decode_as(Format::Dds, &bad_size), Err(CodecError::Malformed { .. })), "header size must be 124"); + + for (bits, masks) in [ + (12, [0xf00, 0xf0, 0xf, 0]), // not a whole number of bytes + (16, [0xff_0000, 0xff00, 0xff, 0]), // a mask wider than the pixel + (32, [0xf0f, 0xf0f0, 0xf_0000, 0]), // a mask with a gap + (32, [0, 0, 0, 0]), // no colour at all + (0, [0xff, 0xff00, 0xff_0000, 0]), // zero bits per pixel + ] { + let mut b = header(1, 1, DDPF_RGB, &[0; 4], bits, masks); + b.extend([0; 4]); + assert!(decode_as(Format::Dds, &b).is_err(), "{bits} bits, masks {masks:x?}"); + } + let mut no_format = header(1, 1, 0, &[0; 4], 32, [0xff, 0xff00, 0xff_0000, 0]); + no_format.extend([0; 4]); + assert!(matches!(decode_as(Format::Dds, &no_format), Err(CodecError::Unsupported { .. }))); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 39f7ece88f..4cb21759ec 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -90,6 +90,7 @@ D5100 colour profile. Counts and the G14 estimate are unchanged. | Paint.NET `.pdn` | yes | no | [pdn.md](pdn.md) | | OpenRaster `.ora` | yes | yes | [ora.md](ora.md) | | ICO, QOI | yes | yes | | +| DirectDraw Surface `.dds` | yes | no | Top mip level of a 2D texture: uncompressed RGB, RGBA or luminance, BC1–BC3 (DXT1/3/5), legacy or DX10 header. Cubemaps, arrays, volumes and BC4–BC7 are refused (#1327). Photoshop needs a plug-in | | Krita `.kra`, GIMP `.xcf`, Procreate | no | no | Not in Photoshop either | ### Affinity import notes (moved from `roadmap.md`) @@ -141,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | DDS textures open, read-only (#1327) | | 2026-10-12 | minor | #2465 fixed: Photoshop CS2 placed layers (`PlLd` only, file in `lnkD`) open as editable smart objects and save their edits | | 2026-10-12 | minor | DPX read and write (#1049); Cineon still missing | | 2026-10-12 | minor | Photoshop Actions `.atn`: read and write, 90 events mapped, the rest kept verbatim (AUTO-219-2) | From f5d64a66bdb24471740a67e2f889d00c8e7475c4 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:18 +0200 Subject: [PATCH 284/523] Color panel: the spectrum field in every theme (#4367) (#4399) --- crates/ui-egui/src/color_tab_theme_tests.rs | 55 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/panels.rs | 25 +++++----- 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/src/color_tab_theme_tests.rs diff --git a/crates/ui-egui/src/color_tab_theme_tests.rs b/crates/ui-egui/src/color_tab_theme_tests.rs new file mode 100644 index 0000000000..b3566277e2 --- /dev/null +++ b/crates/ui-egui/src/color_tab_theme_tests.rs @@ -0,0 +1,55 @@ +//! The Color panel tab draws Photoshop's spectrum field in every theme (#4367): the light themes +//! used to show HSB sliders only. Those themes keep the sliders, under the field. + +use egui::{Rect, pos2}; +use egui_kittest::kittest::Queryable; + +use crate::PhotocraftApp; +use crate::dock::Group; +use crate::theme::{ThemeKind, Tokens}; + +fn left_top() -> egui::Id { + egui::Id::new("color-tab-test-origin") +} + +/// The Color tab of `kind`, alone in a 300 pt wide panel, starting from a red foreground. +fn color_tab(kind: ThemeKind) -> egui_kittest::Harness<'static, PhotocraftApp> { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.session.tools.foreground = [1.0, 0.0, 0.0, 1.0]; + let tab = Group::Color.tabs(Tokens::for_kind(kind).pro).iter().position(|name| *name == "Color").unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(300.0, 400.0)).build_ui_state( + move |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + let origin = ui.cursor().min; + ui.data_mut(|d| d.insert_temp(left_top(), Rect::from_min_size(origin, egui::Vec2::ZERO))); + crate::panels::dock_body(app, ui, Group::Color, tab); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, kind); + h.run_steps(3); + h +} + +#[test] +fn every_theme_shows_the_spectrum_field() { + for kind in ThemeKind::ALL { + let mut h = color_tab(kind); + let origin = h.ctx.data(|d| d.get_temp::(left_top())).unwrap().min; + // Left edge of the saturation/brightness field (after the 38 pt chips and the 8 pt gap), + // halfway down its 120 pt: no saturation, half brightness. + let p = pos2(origin.x + 38.0 + 8.0 + 1.0, origin.y + 60.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(2); + let [r, g, b, _] = h.state().session.tools.foreground; + assert!((r - g).abs() < 0.02 && (g - b).abs() < 0.02 && (0.3..0.7).contains(&r), "{kind:?}: the field picks a mid grey, got {r} {g} {b}"); + if !Tokens::for_kind(kind).pro { + assert!(h.query_by_label("Saturation").is_some(), "{kind:?}: the sliders stay under the field"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a0aa5d4e9b..d038859375 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2369,6 +2369,9 @@ mod move_auto_select_tests; #[cfg(test)] mod new_group_button_tests; +#[cfg(test)] +mod color_tab_theme_tests; + #[cfg(test)] mod new_layer_button_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 30a3bb400d..7ee7be0844 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1963,7 +1963,16 @@ fn empty(ui: &mut egui::Ui, s: &str) { ui.add_space(6.0); } +/// The Color tab of the themes other than Photoshop's: the same spectrum field (chips, saturation +/// and brightness field, hue strip and readout) with HSB sliders under it (#4367). fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + color_field(app, ui); + ui.add_space(8.0); + hsb_sliders(app, ui); +} + +/// Hue, Saturation and Brightness sliders for the foreground colour. +fn hsb_sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let fg = app.session.tools.foreground; // Keep the last edited HSB while it still gives the foreground: black and greys have no hue or // saturation of their own, so recomputing them from RGB would reset what was just typed to 0. @@ -1990,16 +1999,6 @@ fn color_picker(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, hsva)); crate::type_tool::foreground_changed(app); } - ui.horizontal(|ui| { - let (sw, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); - ui.painter().rect_filled(sw, 6.0, hsva_color32(hsva)); - let mut c = app.session.tools.foreground; - if color_readout(ui, ui.id().with("color-panel"), &mut c) { - app.session.tools.foreground = c; - ui.data_mut(|d| d.insert_temp(key, srgb_hsva(c))); - crate::type_tool::foreground_changed(app); - } - }); } // ----------------------------------------------------------------------------- layers @@ -3358,7 +3357,9 @@ fn color_field(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let strip_w = 14.0; // Photoshop's Color panel field fills the panel: take the height the dock offers, less the // hex/RGB readout (which folds onto a second line in a narrow dock). Without a dock, 120 pt. - let h = color_field_height(ui.data(|d| d.get_temp::(color_field_fill_id())), w); + // Only Photoshop's themes stretch it over the group: the other themes put sliders under it. + let fill = if t.pro { ui.data(|d| d.get_temp::(color_field_fill_id())) } else { None }; + let h = color_field_height(fill, w); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 8.0; let (chips, _) = ui.allocate_exact_size(vec2(38.0, h), Sense::hover()); @@ -4123,7 +4124,7 @@ mod color_tests { let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 300.0)).build_ui_state( |ui, app: &mut PhotocraftApp| { if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { - color_picker(app, ui); + hsb_sliders(app, ui); } }, app, From 3f2ac1601e2d8a6d7817362d94b2fdd47d5af5a5 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Mon, 12 Oct 2026 18:47:23 -0700 Subject: [PATCH 285/523] macOS: big brushes keep a native outline too, and big cursor bitmaps rasterise 7x faster (#1343) (#4401) On macOS the brush outline fell back to the app-drawn outline past 255 points, and that outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a 1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware cursor planes. A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted: a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn tip outlines likewise only write the pixels their segments splat. --- crates/ui-egui/src/tool_cursor.rs | 87 ++++++++++++++++++------- crates/ui-egui/src/tool_cursor/tests.rs | 30 ++++++++- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/tool_cursor.rs b/crates/ui-egui/src/tool_cursor.rs index da78f1160b..acf0374807 100644 --- a/crates/ui-egui/src/tool_cursor.rs +++ b/crates/ui-egui/src/tool_cursor.rs @@ -11,12 +11,14 @@ use egui::{CursorIcon, CustomCursorImage, Painter, Pos2, Stroke, Vec2, vec2}; mod hand; -/// The largest cursor bitmap, in physical pixels; a larger tip paints its outline instead (with a -/// small OS crosshair at the hotspot). Windows takes winit's limit. Elsewhere, X servers and -/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors -/// (or reject them) past that, so stay within it there (#968 used 128). Either way it keeps -/// allocations bounded for hostile brush sizes or zooms. -const MAX_SIDE: u16 = if cfg!(target_os = "windows") { 2048 } else { 255 }; +/// The largest cursor bitmap (physical pixels; points on macOS, see [`native_scale`]); a larger tip +/// paints its outline instead, with a small OS crosshair at the hotspot, and that outline trails +/// the pointer by a frame or two (26 ms on a 120 Hz Mac, #1343). Windows takes winit's limit, and +/// so does macOS: AppKit sets no size limit on an `NSCursor` image. On Linux, X servers and +/// hardware cursor planes commonly cap cursors at 256 px and fall back to slow software cursors (or +/// reject them) past that, so stay within it there (#968 used 128). Either way it keeps allocations +/// bounded for hostile brush sizes or zooms. +const MAX_SIDE: u16 = if cfg!(any(target_os = "windows", target_os = "macos")) { 2048 } else { 255 }; /// What a bitmap cursor shows. `Hand` is the Hand tool's open hand, or its fist while it drags. /// `Outline` is a brush tip outline: closed rings of logical-point offsets from the hotspot. @@ -308,13 +310,27 @@ fn splat_cross(dist: &mut [f32], side: usize, hot: f32, arm: f32) { } /// One bitmap pixel from its distance to the stroke centre, in straight RGBA (winit form). -fn push_stroke_pixel(rgba: &mut Vec, distance: f32, scale: f32, dark: egui::Color32, light: egui::Color32) { - let (fill, outline) = stroke_coverage(distance, scale); +/// A pixel with light `fill` over a dark `outline` (coverages 0 to 1), in straight RGBA as winit +/// requires (not egui's premultiplied). No coverage is `[0; 4]`. +fn stroke_pixel((fill, outline): (f32, f32), dark: egui::Color32, light: egui::Color32) -> [u8; 4] { let a_dark = outline * f32::from(dark.a()) / 255.0; let a_light = fill * f32::from(light.a()) / 255.0; let alpha = a_light + a_dark * (1.0 - a_light); let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + [white, white, white, (255.0 * alpha).round() as u8] +} + +/// The columns of row offset `dy` (from the hotspot, in pixels) that can be painted by a ring of +/// radius `r` drawn `reach` wide either side: the left and right arcs of the annulus, padded by +/// a pixel. Every other pixel of the row is transparent. +fn ring_spans(dy: f32, r: f32, reach: f32) -> Option<[(f32, f32); 2]> { + let (ro, ri) = (r + reach + 1.0, (r - reach - 1.0).max(0.0)); + if dy.abs() > ro { + return None; + } + let xo = (ro * ro - dy * dy).max(0.0).sqrt(); + let xi = if dy.abs() < ri { (ri * ri - dy * dy).sqrt() } else { 0.0 }; + Some([(-xo, -xi), (xi, xo)]) } fn rasterize(shape: &Shape, scale: f32) -> Option { @@ -331,13 +347,20 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { let hot = half as u16; let side = hot * 2 + 1; let bytes = usize::from(side).checked_mul(usize::from(side))?.checked_mul(4)?; - let mut rgba = Vec::with_capacity(bytes); + // Transparent to start; only pixels a shape can reach are evaluated below (a big tip's + // outline is a thin ring in a large square, #1343). + let mut rgba = vec![0u8; bytes]; let [dark, light] = crate::theme::Tokens::cursor_outline(); + let n = usize::from(side); + let hotf = f32::from(hot); + let mut put = |col: usize, row: usize, cov: (f32, f32)| { + if let Some(px) = row.checked_mul(n).and_then(|i| i.checked_add(col)).and_then(|i| rgba.get_mut(i * 4..i * 4 + 4)) { + px.copy_from_slice(&stroke_pixel(cov, dark, light)); + } + }; if let Shape::Outline { rings, centre } = shape { // Splat each segment into its bounding box instead of measuring every ring per pixel: // tip outlines may carry hundreds of points. - let hotf = f32::from(hot); - let n = usize::from(side); let reach = 1.5 * scale + 2.0; let mut dist = vec![f32::INFINITY; n * n]; for ring in rings.iter() { @@ -352,22 +375,36 @@ fn rasterize(shape: &Shape, scale: f32) -> Option { if *centre { splat_cross(&mut dist, n, hotf, 3.0 * scale); } - for &d in &dist { - push_stroke_pixel(&mut rgba, d, scale, dark, light); + for (i, &d) in dist.iter().enumerate() { + if d.is_finite() { + put(i % n, i / n, stroke_coverage(d, scale)); + } } return Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }); } - for row in 0..side { - let dy = f32::from(row) - f32::from(hot); - for col in 0..side { - let dx = f32::from(col) - f32::from(hot); - let (fill, outline) = coverage(shape, dx, dy, scale); - let a_dark = outline * f32::from(dark.a()) / 255.0; - let a_light = fill * f32::from(light.a()) / 255.0; - let alpha = a_light + a_dark * (1.0 - a_light); - // Straight RGBA, as required by winit, not egui's premultiplied representation. - let white = if alpha > 0.0 { (255.0 * a_light / alpha).round() as u8 } else { 0 }; - rgba.extend_from_slice(&[white, white, white, (255.0 * alpha).round() as u8]); + // Columns to evaluate in each row: the ring's two arcs (and the centre mark) for a circle, + // the whole row otherwise. + let last = f32::from(side - 1); + for row in 0..n { + let dy = row as f32 - hotf; + let mut spans: [Option<(f32, f32)>; 3] = [None; 3]; + if let Shape::Circle { radius, centre } = shape { + let reach = 1.5 * scale + 0.5; + if let Some([l, r]) = ring_spans(dy, radius * scale, reach) { + spans = [Some(l), Some(r), None]; + } + let cross = 3.0 * scale + reach + 1.0; + if *centre && dy.abs() <= cross { + spans[2] = Some((-cross, cross)); + } + } else { + spans[0] = Some((-hotf, hotf)); + } + for (a, b) in spans.into_iter().flatten() { + let (c0, c1) = ((hotf + a).floor().clamp(0.0, last) as usize, (hotf + b).ceil().clamp(0.0, last) as usize); + for col in c0..=c1 { + put(col, row, coverage(shape, col as f32 - hotf, dy, scale)); + } } } Some(CustomCursorImage { rgba: Arc::from(rgba), size: [side, side], hotspot: [hot, hot] }) diff --git a/crates/ui-egui/src/tool_cursor/tests.rs b/crates/ui-egui/src/tool_cursor/tests.rs index 3a8bd1f010..54168f537d 100644 --- a/crates/ui-egui/src/tool_cursor/tests.rs +++ b/crates/ui-egui/src/tool_cursor/tests.rs @@ -48,14 +48,17 @@ fn precise_and_tip_centre_crosshairs_have_distinct_hotspots() { assert!(alpha(&solid, solid.hotspot[0], solid.hotspot[1]) > 230); } -/// Off Windows a tip stays within what X servers and hardware cursor planes take (256 px); a -/// larger one falls back to the painted outline. Windows takes far larger cursors. +/// On Linux a tip stays within what X servers and hardware cursor planes take (256 px); a larger +/// one falls back to the painted outline. Windows and macOS take far larger cursors. #[test] fn cursor_bitmaps_stay_within_the_platform_limit() { let fits = |radius: f32| rasterize(&Shape::Circle { radius, centre: false }, 1.0).map(|i| i.size[0]); + let large = cfg!(any(target_os = "windows", target_os = "macos")); assert!(fits(100.0).is_some_and(|side| side <= 255)); - assert_eq!(fits(200.0).is_some(), cfg!(target_os = "windows")); + assert_eq!(fits(200.0).is_some(), large); + assert_eq!(fits(1000.0).is_some(), large, "a 2000 px brush at 100 % still has a native outline"); assert!(fits(200.0).is_none_or(|side| side <= 2048)); + assert!(fits(1100.0).is_none(), "nothing past 2048 px"); } #[test] @@ -340,3 +343,24 @@ fn painting_preference_and_zoom_change_the_native_cursor_without_changing_pointe h.run_steps(2); assert!(h.output().platform_output.cursor_image.as_ref().unwrap().size[0] > cross_size); } + +/// A big tip's outline is a thin ring in a large square: only the ring (and the centre mark) is +/// evaluated, and the bitmap is exactly what evaluating every pixel gives. +#[test] +fn circle_bitmaps_evaluate_only_the_ring_and_match_every_pixel() { + let [dark, light] = crate::theme::Tokens::cursor_outline(); + for (radius, scale, centre) in [(0.4, 1.0, true), (3.0, 1.0, false), (20.0, 1.25, true), (57.3, 2.0, false), (90.0, 1.0, true)] { + let shape = Shape::Circle { radius, centre }; + let image = rasterize(&shape, scale).unwrap(); + let [hot, _] = image.hotspot; + let side = usize::from(image.size[0]); + for row in 0..side { + for col in 0..side { + let (dx, dy) = (col as f32 - f32::from(hot), row as f32 - f32::from(hot)); + let want = stroke_pixel(coverage(&shape, dx, dy, scale), dark, light); + let i = (row * side + col) * 4; + assert_eq!(image.rgba[i..i + 4], want, "radius {radius} at {scale}×, pixel ({col}, {row})"); + } + } + } +} From 851916e0d7dfcc939c44d0b72a48eac2c5701685 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:27 +0200 Subject: [PATCH 286/523] Save As: offer ICO, GIF and BMP in the file type list (#4357) (#4405) --- apps/photocraft/src/services.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 7ec91954b1..f892de31cd 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -60,6 +60,10 @@ const SAVE_FILTERS: &[(&str, &[&str])] = &[ ("WebP", &["webp"]), ("TIFF", &["tif"]), ("Targa", &["tga"]), + // Also in Photoshop's Save As list; ICO is PhotoCraft's own (#4357): up to 256×256, with alpha. + ("BMP", &["bmp"]), + ("GIF", &["gif"]), + ("ICO", &["ico"]), ("OpenEXR", &["exr"]), ("FITS", &["fits", "fit"]), ("DPX", &["dpx"]), @@ -670,6 +674,33 @@ mod tests { assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default"); } + /// Every flat format Save As offers is one PhotoCraft writes, and ICO is among them (#4357). + #[test] + fn save_as_offers_only_writable_formats_including_ico() { + let layered = ["psd", "psb", "pcraft", "ora"]; + for (name, exts) in SAVE_FILTERS { + for ext in exts.iter().filter(|e| !layered.contains(e)) { + let format = photocraft_codecs::from_extension(ext).unwrap_or_else(|| panic!("{name}: .{ext} is no known format")); + assert!(format.caps().write, "{name}: .{ext} can't be written"); + } + } + for ext in ["ico", "gif", "bmp"] { + assert!(save_filters("Untitled-1.psd").iter().any(|(_, exts)| exts.iter().any(|e| e == ext)), ".{ext} is offered"); + } + assert_eq!(save_filters("icon.ico")[0].0, "ICO", "an .ico name leads with its own type"); + // What Save As writes there: an icon up to 256×256, and a clear error past that. + let doc = |side: u32| { + let mut s = Session::new(); + s.execute("file.new", json!({"width": side, "height": side})).unwrap(); + s.active().unwrap().doc.clone() + }; + let opts = export_options(&photocraft_ui_egui::ExportSettings::default()); + let icon = photocraft_io::export(&doc(256), "icon.ico", &opts).unwrap(); + assert_eq!(icon.bytes[..4], [0, 0, 1, 0], "an ICO header"); + let err = photocraft_io::export(&doc(300), "icon.ico", &opts).map(|_| ()).unwrap_err().to_string(); + assert!(err.contains("256"), "{err}"); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); From 6c2934ba797cb75b8832b6e31f4c2e9ffb0c65bb Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:32 +0200 Subject: [PATCH 287/523] Recent files: a saved document is listed, as an opened one is (#4400) (#4411) --- crates/ui-egui/src/file_open/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/lib.rs | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index d49ab58206..9a833b7ba1 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -776,3 +776,20 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { jobs_ui::finish_open_tab(&mut app, &late, &json!({"document": index})).unwrap(); assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } + +/// #4400: a new document saved under a name is listed in Open Recent (and on the Home screen), +/// as opening it is; a Save a Copy isn't, since the document doesn't live there. +#[test] +fn saving_a_new_document_lists_it_in_recent_files() { + let (mut app, _written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/new.psd".into())), Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 4, "height": 4})).unwrap(); + assert!(app.ui.recent_files.is_empty()); + menus::invoke(&mut app, &ctx, "file.saveAs", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"]); + assert_eq!(app.session.prefs().file_handling.recent_files, ["/pics/new.psd"], "kept for the next launch"); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + assert_eq!(app.ui.recent_files, ["/pics/new.psd"], "a copy isn't the document's file"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d038859375..b6d844b80f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1450,6 +1450,11 @@ impl PhotocraftApp { } Ok(()) }); + // The file the document now lives in goes to the top of Open Recent and the Home + // screen, as opening it does (#4400). Automation saves don't touch the user's list. + if hooks { + self.push_recent(path); + } } self.ui.status_error = false; let what = crate::i18n::fmt(tl!("Saved {name}"), &[("name", file_open::display_name(path).as_str())]); From 98ec507375ac36d9162502f6330e6ef49dd3cd87 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:47:36 -0600 Subject: [PATCH 288/523] Paths: curves stay smooth when zoomed in (#4084) (#4445) The path overlays sampled every Bezier segment at a fixed 16 points (24 for the Curvature Pen's preview) in document space, so zoomed in a curve showed as visible straight chords with corners. curve_points maps a segment's four points to the screen (the view is affine, so the curve maps exactly) and picks the number of chords from how much it bends there: the chord error stays within 0.3 px (the bound the vector crate's flattening uses), capped at 1024 chords. A straight segment, with handles retracted or along the line, is one chord. Used by path_lines (work path, shape and vector mask outlines, Direct Selection, the shape preview, Paths thumbnails), the Pen's path in progress and the Curvature Pen's preview. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/vector_ui.rs | 105 +++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index a13f8fa569..d4de249202 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -763,6 +763,45 @@ fn bezier(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], t: f64) -> [f6 [f(p0[0], c0[0], c1[0], p1[0]), f(p0[1], c0[1], c1[1], p1[1])] } +/// `q` lies on the chord from `a` to `d` (within a thousandth of a pixel). +fn on_chord(a: [f64; 2], q: [f64; 2], d: [f64; 2]) -> bool { + let (dx, dy, vx, vy) = (d[0] - a[0], d[1] - a[1], q[0] - a[0], q[1] - a[1]); + let len = dx.hypot(dy); + if len < 1e-9 { + return vx.hypot(vy) < 1e-3; + } + let t = (vx * dx + vy * dy) / (len * len); + (vx * dy - vy * dx).abs() / len < 1e-3 && (-1e-9..=1.0 + 1e-9).contains(&t) +} + +/// How far (in screen pixels) a drawn chord may stray from the true curve. +const CURVE_TOLERANCE: f64 = 0.3; +/// Pieces per segment at most: 0.3 px holds for curves bending up to about 1.4 million px on screen. +const MAX_CURVE_PIECES: usize = 1024; + +/// Screen points along one cubic segment (both ends included). The control points are mapped to +/// the screen first (`xf` is affine, so the curve maps exactly), and the number of chords follows +/// how much the curve bends there: a straight segment is one chord, and a curve zoomed in stays +/// smooth instead of showing a fixed 16 chords (#4084). Uses the bound +/// `max|B''| / 8n² <= tol` with `max|B''| <= 6·max(|p0−2c0+c1|, |c0−2c1+p1|)`. +pub(crate) fn curve_points(p0: [f64; 2], c0: [f64; 2], c1: [f64; 2], p1: [f64; 2], xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec { + let [a, b, c, d] = [p0, c0, c1, p1].map(|q| { + let s = xf(q); + [f64::from(s.x), f64::from(s.y)] + }); + let second = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (p[0] - 2.0 * q[0] + r[0]).hypot(p[1] - 2.0 * q[1] + r[1]); + let n = (0.75 * second(a, b, c).max(second(b, c, d)) / CURVE_TOLERANCE).sqrt().ceil(); + // A straight segment (controls on the chord, like the Pen's retracted handles) is one chord; + // NaN (a non-finite point) falls back to one too. + let n = if n.is_finite() && !(on_chord(a, b, d) && on_chord(a, c, d)) { (n as usize).clamp(1, MAX_CURVE_PIECES) } else { 1 }; + (0..=n) + .map(|k| { + let q = bezier(a, b, c, d, k as f64 / n as f64); + pos2(q[0] as f32, q[1] as f32) + }) + .collect() +} + /// Screen polylines for a path (one per subpath). pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec, bool)> { path.subpaths @@ -773,11 +812,7 @@ pub(crate) fn path_lines(path: &Path, xf: &dyn Fn([f64; 2]) -> Pos2) -> Vec<(Vec let segs = if s.closed { n } else { n.saturating_sub(1) }; for i in 0..segs { let (a, b) = (&s.knots[i], &s.knots[(i + 1) % n]); - for k in 0..=16 { - let q = - bezier([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], k as f64 / 16.0); - pts.push(xf(q)); - } + pts.extend(curve_points([a.anchor.x, a.anchor.y], [a.out_ctrl.x, a.out_ctrl.y], [b.in_ctrl.x, b.in_ctrl.y], [b.anchor.x, b.anchor.y], xf)); } if pts.is_empty() && let Some(k) = s.knots.first() @@ -866,9 +901,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform first[2] = out; } for w in knots.windows(2) { - for k in 0..=16 { - pts.push(to_scr(bezier(w[0][0], w[0][2], w[1][1], w[1][0], k as f64 / 16.0))); - } + pts.extend(curve_points(w[0][0], w[0][2], w[1][1], w[1][0], &to_scr)); } painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) @@ -881,7 +914,7 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform next.knots.push([h; 3]); curvature_refresh(&mut next, false); if let [.., a, b] = next.knots.as_slice() { - let pts: Vec = (0..=24).map(|k| to_scr(bezier(a[0], a[2], b[1], b[0], k as f64 / 24.0))).collect(); + let pts = curve_points(a[0], a[2], b[1], b[0], &to_scr); painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); } } else { @@ -1707,6 +1740,60 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #4084: zoomed in, a curve's chords stay within a fraction of a pixel of the true curve + /// (16 fixed chords showed corners), and a straight segment stays one chord. + #[test] + fn path_curves_stay_smooth_at_any_zoom() { + let (p0, c0, c1, p1) = ([0.0, 100.0], [20.0, -60.0], [80.0, -60.0], [100.0, 100.0]); + for zoom in [0.25, 1.0, 8.0, 64.0, 320.0] { + let xf = move |q: [f64; 2]| pos2((q[0] * zoom) as f32 + 10.0, (q[1] * zoom) as f32 - 5.0); + let pts = curve_points(p0, c0, c1, p1, &xf); + let n = pts.len() - 1; + assert_eq!((pts[0], pts[n]), (xf(p0), xf(p1)), "the ends are the anchors"); + for (k, w) in pts.windows(2).enumerate() { + let curve = xf(bezier(p0, c0, c1, p1, (k as f64 + 0.5) / n as f64)); + let chord = w[0] + (w[1] - w[0]) * 0.5; + assert!((chord - curve).length() <= 0.35, "zoom {zoom}: chord {k} of {n} is {} px off", (chord - curve).length()); + } + // Zoomed out it stays cheap. + if zoom <= 1.0 { + assert!(n <= 32, "zoom {zoom}: {n} chords"); + } + } + let xf = |q: [f64; 2]| pos2((q[0] * 320.0) as f32, (q[1] * 320.0) as f32); + // Retracted handles, and handles along the line: one chord. + assert_eq!(curve_points([0.0, 0.0], [0.0, 0.0], [50.0, 50.0], [50.0, 50.0], &xf).len(), 2); + assert_eq!(curve_points([0.0, 0.0], [10.0, 10.0], [40.0, 40.0], [50.0, 50.0], &xf).len(), 2); + // A handle past the end is a curve that doubles back, not a line. + assert!(curve_points([0.0, 0.0], [0.0, 0.0], [80.0, 80.0], [50.0, 50.0], &xf).len() > 2); + // Hostile coordinates: no panic, a bounded number of points. + for bad in [f64::NAN, f64::INFINITY, 1e300] { + assert!(curve_points([0.0, 0.0], [bad, 0.0], [0.0, bad], [1.0, 1.0], &xf).len() <= MAX_CURVE_PIECES + 1); + } + // Through path_lines too: a closed subpath of two curved segments. + let mut path = Path::default(); + path.subpaths.push(photocraft_doc::vector::Subpath { + knots: vec![ + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(0.0, 0.0), + in_ctrl: photocraft_geom::Point::new(0.0, 40.0), + out_ctrl: photocraft_geom::Point::new(0.0, -40.0), + smooth: true, + }, + photocraft_doc::vector::Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -40.0), + out_ctrl: photocraft_geom::Point::new(60.0, 40.0), + smooth: true, + }, + ], + closed: true, + ..Default::default() + }); + let lines = path_lines(&path, &xf); + assert!(lines[0].1 && lines[0].0.len() > 2 * 17, "zoomed in, more than the old 16 chords per segment: {}", lines[0].0.len()); + } + #[test] fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() { for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] { From 40a93b7dc44de2507356d5a40750c5629d3dc024 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:47:41 -0600 Subject: [PATCH 289/523] Toolbar: click and hold on Change Screen Mode opens its modes (#4444) (#4464) The button draws the flyout triangle, but its list was an egui context menu, which opens on right-click (or a touch long-press) only. Clicking and holding with the mouse did nothing until release, which then cycled the screen mode. A hold of 0.35 s, the same as the tool flyouts, now opens the list, and the release that ends the hold neither cycles the mode nor closes the list. A plain click still cycles (F), and right-click still opens it. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/panels.rs | 90 ++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7ee7be0844..1cc16a1fa3 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -298,11 +298,36 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); paint_toolbar_flyout_indicator(ui, &t, &sm); - if sm.clicked() { + // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, + // like Photoshop's flyout. The release that ends a hold doesn't cycle the mode. + let held_id = sm.id.with("held"); + let was_held = ui.data(|d| d.get_temp::(held_id)).unwrap_or(false); + let held_for = sm.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|t0| i.time - t0))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = !was_held && held_for.is_some_and(|seconds| seconds >= 0.35); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, true)); + } else if was_held && !sm.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::(held_id)); + } + let cycle = sm.clicked() && !was_held; + if cycle { let _ = crate::menus::invoke(app, &ctx, "view.screenMode.cycle", json!({})); } - // Right-click (or long-press) lists the modes, like Photoshop's flyout. - egui::Popup::context_menu(&sm).show(|ui| { + let open = if sm.secondary_clicked() || long_press { + Some(egui::SetOpenCommand::Bool(true)) + } else if cycle { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + // The release ending the hold counts as a click; it mustn't close the list it opened. + let close = if was_held || long_press { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClick }; + egui::Popup::menu(&sm).open_memory(open).close_behavior(close).at_pointer_fixed().show(|ui| { ui.set_min_width(220.0); for (id, label) in [ ("view.screenMode.standard", tl!("Standard Screen Mode")), @@ -4667,6 +4692,65 @@ mod type_flyout_tests { assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_none()); } + /// #4444: click and hold on Change Screen Mode opens its list like the tool flyouts, without + /// cycling the mode on release; a plain click still cycles, and right-click still opens it. + #[test] + fn long_press_screen_mode_opens_the_modes_without_cycling() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + // The toolbar's last button: Change Screen Mode, after Quick Mask. + let at = ctx + .viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .max_by(|a, b| a.top().total_cmp(&b.top()).then(a.left().total_cmp(&b.left()))) + }) + .unwrap() + .center(); + let standard = |app: &PhotocraftApp| crate::view_cmds::checked(app, "view.screenMode.standard") == Some(true); + let menu_open = |ctx: &egui::Context| egui::Popup::is_any_open(ctx); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(standard(&app)); + // Hold, then release: the list opens and stays open, and the mode is unchanged. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 1.2, vec![]); + assert!(!menu_open(&ctx), "not before the hold time"); + frame(&mut app, &ctx, 1.4, vec![]); + assert!(menu_open(&ctx), "open while still held"); + frame(&mut app, &ctx, 1.5, vec![pointer(at, false)]); + frame(&mut app, &ctx, 1.55, vec![]); + assert!(menu_open(&ctx), "the release keeps it open"); + assert!(standard(&app), "the release doesn't cycle the mode"); + // Escape closes it; a plain click cycles the mode and opens nothing. + frame( + &mut app, + &ctx, + 2.0, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + frame(&mut app, &ctx, 2.05, vec![]); + assert!(!menu_open(&ctx)); + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 3.1, vec![pointer(at, false)]); + frame(&mut app, &ctx, 3.15, vec![]); + assert!(!standard(&app), "a click cycles"); + assert!(!menu_open(&ctx)); + // Right-click opens it too. + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 4.0, vec![secondary(true)]); + frame(&mut app, &ctx, 4.05, vec![secondary(false)]); + frame(&mut app, &ctx, 4.1, vec![]); + assert!(menu_open(&ctx)); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From 1bc7d7baad2b7b4e63f678de19c80ac4f8571a3e Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:45 +0200 Subject: [PATCH 290/523] =?UTF-8?q?Puppet=20Warp:=20=E2=8C=98Z=20/=20?= =?UTF-8?q?=E2=8C=98=E2=87=A7Z=20undo=20and=20redo=20pin=20edits=20(#4440)?= =?UTF-8?q?=20(#4479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/distort_ui.rs | 7 +- crates/ui-egui/src/puppet_ui.rs | 122 ++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/distort_ui.rs b/crates/ui-egui/src/distort_ui.rs index 0a0d7914b0..9f0966b5c3 100644 --- a/crates/ui-egui/src/distort_ui.rs +++ b/crates/ui-egui/src/distort_ui.rs @@ -172,11 +172,16 @@ pub fn keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool { } return true; } - if app.distort.puppet.is_some() { + if let Some(p) = app.distort.puppet.as_mut() { + let cmd_shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; if enter(ctx) { crate::puppet_ui::commit(app); } else if esc(ctx) { app.distort.puppet = None; + } else if ctx.input_mut(|i| i.consume_key(cmd_shift, egui::Key::Z)) { + p.redo(); + } else if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Z)) { + p.undo(); } return true; } diff --git a/crates/ui-egui/src/puppet_ui.rs b/crates/ui-egui/src/puppet_ui.rs index 42791715d6..b08546a51d 100644 --- a/crates/ui-egui/src/puppet_ui.rs +++ b/crates/ui-egui/src/puppet_ui.rs @@ -1,6 +1,7 @@ //! Edit › Puppet Warp on the canvas: click on the mesh to add pins, drag pins to move them, //! ⌥-click a pin to remove it; the options bar sets mode, density, expansion, mesh visibility, -//! the selected pin's depth and rotation. ↩ / Commit runs `edit.puppetWarp` with the pins. +//! the selected pin's depth and rotation. ⌘Z / ⌘⇧Z undo and redo pin edits inside the session +//! (#4440). ↩ / Commit runs `edit.puppetWarp` with the pins. //! //! The preview is the layer texture on the deformed triangle mesh (an egui textured mesh, drawn //! by the GPU), re-solved with a few warm-started ARAP rounds per drag event (well under a @@ -37,9 +38,47 @@ pub struct PuppetSession { texture: TextureHandle, opacity: f32, pub preview_doc: Arc, + /// Pins before each edit of this session, newest last (⌘Z), and the edits undone (⌘⇧Z). + undo: Vec>, + redo: Vec>, + /// The pins when the current drag started: one undo step per drag that moved something. + drag_from: Option>, } impl PuppetSession { + /// Records `before` as one undoable pin edit. + fn record(&mut self, before: Vec) { + if before != self.warp.pins { + self.undo.push(before); + self.redo.clear(); + } + } + + /// Puts `pins` back (undo or redo), keeping the selection when that pin still exists. + fn restore(&mut self, pins: Vec) { + self.warp.pins = pins; + self.selected = self.selected.filter(|i| *i < self.warp.pins.len()); + self.drag = None; + self.drag_from = None; + self.rebind(); + } + + /// ⌘Z: the last pin edit undone. False when there is none. + pub fn undo(&mut self) -> bool { + let Some(pins) = self.undo.pop() else { return false }; + self.redo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + + /// ⌘⇧Z: the last undone pin edit redone. False when there is none. + pub fn redo(&mut self) -> bool { + let Some(pins) = self.redo.pop() else { return false }; + self.undo.push(std::mem::replace(&mut self.warp.pins, pins.clone())); + self.restore(pins); + true + } + pub fn describe(&self) -> Value { json!({ "layer": self.layer.0, @@ -162,6 +201,9 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context, command: &str) -> Res texture, opacity, preview_doc, + undo: Vec::new(), + redo: Vec::new(), + drag_from: None, }; s.rebuild_mesh(); if s.mesh.tris.is_empty() { @@ -192,6 +234,14 @@ pub fn control(app: &mut PhotocraftApp, ui: &Value) -> Result { return Ok(json!({"cancelled": true})); } let s = app.distort.puppet.as_mut().ok_or(tl!("Puppet Warp is not active"))?; + if ui.get("undo").and_then(Value::as_bool) == Some(true) { + s.undo(); + return Ok(s.describe()); + } + if ui.get("redo").and_then(Value::as_bool) == Some(true) { + s.redo(); + return Ok(s.describe()); + } let mut remesh = false; let (density, expansion) = (s.warp.density, s.warp.expansion); if let Some(m) = ui.get("mode").and_then(Value::as_str) { @@ -222,14 +272,17 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { match ev { ToolEvent::Down { x, y, .. } => { let p = [x, y]; + let before = s.warp.pins.clone(); if let Some(i) = s.pin_at(p, tol) { if mods.alt { s.warp.pins.remove(i); s.selected = None; s.rebind(); + s.record(before); } else { s.selected = Some(i); s.drag = Some(i); + s.drag_from = Some(before); } } else if !mods.alt && let Some(src) = s.rest_of(p) @@ -238,6 +291,8 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { s.selected = Some(s.warp.pins.len() - 1); s.drag = s.selected; s.rebind(); + // Placing a pin is one step; dragging it on from there is part of that step. + s.record(before); } } ToolEvent::Move { x, y, .. } => { @@ -255,6 +310,9 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) { pin.dst = [x, y]; } s.solve(true); + if let Some(before) = s.drag_from.take() { + s.record(before); + } } } } @@ -305,6 +363,8 @@ pub fn draw(s: &PuppetSession, painter: &egui::Painter, xf: &ViewXform) { pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let Some(s) = app.distort.puppet.as_mut() else { return }; + // Pin depth, rotation and Remove All Pins are undoable pin edits too. + let pins_before = s.warp.pins.clone(); let mut remesh = false; let mut resolve = false; let mut rebind = false; @@ -369,6 +429,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { s.selected = None; rebind = true; } + s.record(pins_before); if remesh { // A refused value snaps the fields back to the previous settings. let _ = s.remesh_or_restore(density, expansion); @@ -393,6 +454,65 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { mod tests { use super::*; + /// #4440: ⌘Z / ⌘⇧Z undo and redo pin edits while Puppet Warp is open: placing a pin, + /// dragging one and removing one are a step each; a click that moves nothing isn't one. + #[test] + fn pin_edits_undo_and_redo_inside_the_session() { + let ctx = egui::Context::default(); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", json!({"width": 100, "height": 80, "depth": 8})).unwrap(); + app.session.execute("layer.new.layer", json!({})).unwrap(); + app.session + .edit("paint", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(Rect::new(20, 30, 80, 50), &[1.0, 0.0, 0.0, 1.0]); + Ok(()) + }) + .unwrap(); + app.sync_views(); + crate::distort_ui::menu(&mut app, &ctx, "edit.puppetWarp", &json!({})).unwrap().unwrap(); + let ev = |app: &mut PhotocraftApp, e| crate::distort_ui::pointer(app, e, egui::Modifiers::NONE); + let pins = |app: &PhotocraftApp| app.distort.puppet.as_ref().unwrap().warp.pins.iter().map(|p| p.dst).collect::>(); + ev(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 25.0, y: 40.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 40.0 }); + // Drag the second pin up; then a click on it that moves nothing. + ev(&mut app, ToolEvent::Down { x: 75.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Move { x: 75.0, y: 30.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + ev(&mut app, ToolEvent::Down { x: 75.0, y: 20.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 75.0, y: 20.0 }); + crate::distort_ui::pointer(&mut app, ToolEvent::Down { x: 25.0, y: 40.0, pressure: 1.0 }, egui::Modifiers { alt: true, ..Default::default() }); + assert_eq!(pins(&app), [[75.0, 20.0]]); + // ⌘Z, through the keyboard: the removed pin comes back, then the drag is undone. + let press = |app: &mut PhotocraftApp, modifiers: egui::Modifiers| { + let ev = egui::Event::Key { key: egui::Key::Z, physical_key: None, pressed: true, repeat: false, modifiers }; + let input = egui::RawInput { events: vec![ev], ..Default::default() }; + ctx.run_ui(input, |ui| assert!(crate::distort_ui::keys(app, ui.ctx()))).textures_delta.clear(); + }; + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + press(&mut app, egui::Modifiers::COMMAND); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 40.0]], "the drag, not the click after it"); + // ⌘⇧Z redoes it; a new edit after an undo drops what was undone. + press(&mut app, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT); + assert_eq!(pins(&app), [[25.0, 40.0], [75.0, 20.0]]); + control(&mut app, &json!({"undo": true})).unwrap(); + control(&mut app, &json!({"undo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0]]); + ev(&mut app, ToolEvent::Down { x: 50.0, y: 40.0, pressure: 1.0 }); + ev(&mut app, ToolEvent::Up { x: 50.0, y: 40.0 }); + control(&mut app, &json!({"redo": true})).unwrap(); + assert_eq!(pins(&app), [[25.0, 40.0], [50.0, 40.0]], "nothing left to redo"); + // Undo past the first pin stops there, and the document's History is untouched. + let past = app.session.active().unwrap().history.past_len(); + for _ in 0..5 { + control(&mut app, &json!({"undo": true})).unwrap(); + } + assert!(pins(&app).is_empty()); + assert_eq!(app.session.active().unwrap().history.past_len(), past); + } + #[test] fn pins_drag_and_commit_through_the_engine() { let ctx = egui::Context::default(); From 2cb3b70d97fb039499db05d98ae074dad1b7e491 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:47:50 +0200 Subject: [PATCH 291/523] Crop tool: the drawing crosshair, not the move cursor, inside the untouched default frame (fixes #4461) (#4481) --- crates/ui-egui/src/crop_ui.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/crop_ui.rs b/crates/ui-egui/src/crop_ui.rs index df25c568ff..54ec3795d2 100644 --- a/crates/ui-egui/src/crop_ui.rs +++ b/crates/ui-egui/src/crop_ui.rs @@ -863,7 +863,8 @@ pub(crate) fn resize_ratio(app: &PhotocraftApp, rect: [f64; 4], mods: Modifiers) preset_ratio(app).or(if mods.shift { keep } else { None }) } -/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside. +/// Cursor over the canvas at document point `p`: resize arrows on the edges, move inside (the +/// crosshair inside the untouched default frame, where a drag draws a new one). pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { if app.ui.tool != Tool::Crop { return None; @@ -882,6 +883,8 @@ pub fn cursor(app: &PhotocraftApp, p: [f64; 2]) -> Option { Some(match h { // The arrow as the handle faces on screen: the frame's turn plus the view's. Hit::Handle(hx, hy) => resize_icon(hx, hy, deg + f64::from(crate::crop_mode::view_rotation(app))), + // Inside the untouched default frame a drag draws a new one (`pointer`), so nothing moves (#4461). + Hit::Inside if app.crop.default_frame => CursorIcon::Crosshair, Hit::Inside => CursorIcon::Move, // Photoshop's curved two-headed arrow (`draw_turn_cursor`, from `turn_cursor_dir`). Hit::Outside => CursorIcon::None, @@ -1787,6 +1790,20 @@ mod tests { assert_eq!(cursor(&app, [60.0, 40.0]), None); } + /// #4461: inside the untouched default frame a drag draws a new frame, so the pointer is the + /// drawing crosshair there, not the move cursor; once the frame is changed a drag moves it. + #[test] + fn cursor_inside_the_default_frame_draws() { + let mut app = app(SampleType::U8); + ensure_frame(&mut app); + assert!(app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 200.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Crosshair)); + assert_eq!(cursor(&app, [200.0, 50.0]), Some(CursorIcon::ResizeHorizontal), "its handles still resize"); + drag(&mut app, &[[200.0, 50.0], [150.0, 50.0]], NONE); + assert!(!app.crop.default_frame && app.ui.crop_rect == Some([0.0, 0.0, 150.0, 100.0])); + assert_eq!(cursor(&app, [100.0, 50.0]), Some(CursorIcon::Move)); + } + /// One key press through the app's keyboard handling (`shortcuts::handle`). fn key(app: &mut PhotocraftApp, key: egui::Key, modifiers: Modifiers) { let ctx = egui::Context::default(); From a9bb38c96e766fc6462aa4891540c79df4a451cc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 03:56:09 +0200 Subject: [PATCH 292/523] Clone Source: clip the overlay to a shaped brush tip (#4372) (#4376) * Clone Source: clip the overlay to a shaped brush tip (#4372) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/brush_cursor.rs | 54 +++++++++++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 54 ++++++++++++++++++++++++++++-- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/brush_cursor.rs b/crates/ui-egui/src/brush_cursor.rs index c53fc5de6b..49c444c154 100644 --- a/crates/ui-egui/src/brush_cursor.rs +++ b/crates/ui-egui/src/brush_cursor.rs @@ -73,7 +73,7 @@ pub(crate) fn cursor(painter: &Painter, at: Pos2, brush: &BrushSettings, radius: /// The tip outline in radius units, cached across frames; `None` when the plain circle says it all /// (a plain round tip) or the tip has no traceable shape. -fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { +pub(crate) fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option>>> { if matches!(brush.tip, TipShape::Round) && brush.angle == 0.0 && brush.roundness >= 1.0 { return None; } @@ -88,6 +88,43 @@ fn rings(ctx: &egui::Context, brush: &BrushSettings, full: bool) -> Option], at: [f64; 2], radius: f64, rect: [i32; 4]) -> Vec { + let [x0, y0, x1, y1] = rect; + let (w, h) = (usize::try_from(x1 - x0).unwrap_or(0), usize::try_from(y1 - y0).unwrap_or(0)); + let mut mask = vec![false; w.saturating_mul(h)]; + let mut hits: Vec = Vec::new(); + for row in 0..h { + let y = f64::from(y0) + row as f64 + 0.5; + hits.clear(); + for ring in rings { + for (i, a) in ring.iter().enumerate() { + let Some(b) = ring.get((i + 1) % ring.len()) else { continue }; + let (ax, ay) = (at[0] + f64::from(a[0]) * radius, at[1] + f64::from(a[1]) * radius); + let (bx, by) = (at[0] + f64::from(b[0]) * radius, at[1] + f64::from(b[1]) * radius); + if (ay <= y) != (by <= y) { + hits.push(ax + (y - ay) / (by - ay) * (bx - ax)); + } + } + } + hits.sort_by(f64::total_cmp); + for pair in hits.chunks(2) { + let [a, b] = pair else { continue }; + let (from, to) = (a - f64::from(x0) - 0.5, b - f64::from(x0) - 0.5); + let start = from.ceil().max(0.0) as usize; + let end = ((to.floor() + 1.0).max(0.0) as usize).min(w); + for x in start..end { + if let Some(m) = mask.get_mut(row * w + x) { + *m = true; + } + } + } + } + mask +} + #[cfg(test)] mod tests { use super::*; @@ -142,4 +179,19 @@ mod tests { assert!(ex.x.is_finite() && ex.y.is_finite() && ey.x.is_finite() && ey.y.is_finite(), "size {size}"); } } + + /// The overlay clip follows the tip: a square ring covers its own texels, a hole stays clear, + /// and a rotated flat tip leaves the corners of the brush square outside. + #[test] + fn tip_mask_follows_the_ring_shape() { + let square = vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]]; + let hole = vec![[-0.1, -0.1], [0.1, -0.1], [0.1, 0.1], [-0.1, 0.1]]; + let mask = tip_mask(&[square.clone(), hole], [10.0, 10.0], 10.0, [0, 0, 20, 20]); + let at = |x: usize, y: usize| mask[y * 20 + x]; + assert!(at(6, 10) && at(14, 10) && at(10, 6) && at(10, 14), "inside the square"); + assert!(!at(3, 10) && !at(10, 16) && !at(0, 0), "outside the square"); + assert!(!at(10, 10) && at(8, 10), "the hole is clear"); + assert_eq!(mask.iter().filter(|m| **m).count(), 10 * 10 - 2 * 2, "10x10 square minus the 2x2 hole"); + assert!(tip_mask(&[square], [10.0, 10.0], 10.0, [5, 5, 5, 9]).is_empty(), "an empty rect is harmless"); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0277fbdf43..d4a1cbb58b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -40,8 +40,17 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie 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:?}:{}:{}:{}:{}", + "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}", st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend ); if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { @@ -62,6 +71,14 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie *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]; + } + } + } let image = display_image(display.as_deref(), &buf); match app.clone_preview.as_mut() { Some(cache) => { @@ -80,7 +97,7 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie 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 { + 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))); } @@ -6137,6 +6154,39 @@ mod tests { assert_eq!(centre("lighten"), Color32::WHITE); } + /// A shaped tip clips the Clone Source overlay to its outline, not to the brush circle. + #[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; + 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::new("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"); + } + #[test] fn wayland_start_screen_hint_does_not_claim_file_drop_works() { let x11 = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); diff --git a/docs/scorecard.md b/docs/scorecard.md index d25198c082..712a17b611 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -137,7 +137,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | -| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing | +| TOOL-213-8 | Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298) | | TOOL-213-9 | Eyedropper comparison ring (new colour above, current below) while sampling | done | [#213](https://github.com/storytold/photocraft/issues/213) | eyedropper_ring (canvas.rs): the sampled colour on the upper arc, the foreground below, drawn for the Eyedropper and Alt-sampling while the button is held; the Precise-cursor preference keeps the crosshair | | TOOL-213-10 | Ctrl+Alt drag resizes brush size/hardness with an on-canvas readout | done | [#213](https://github.com/storytold/photocraft/issues/213) | brush_resize.rs (#297, #370): Alt+right-drag, or Control+Option-drag on macOS (Control+Alt everywhere), sets diameter horizontally and hardness vertically, circle plus Diameter/Hardness readout, one coalesced tools.setBrush, no paint or history; 9 tests there | | TOOL-213-11 | Editable pressure curve, global and per control | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Global: Preferences › Tools › Pressure Curve (prefs tools.pressureCurve; monotone cubic, engine prefs::PressureCurve; applied to every pen sample in ui-egui stylus.rs before any brush; editor pressure_curve_ui.rs with Linear/Soft/Hard presets). Missing: per-control curves (each brush dynamic's own response to pressure, tilt, rotation) | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 3f22fa9019..c090d4f897 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -59,7 +59,7 @@ id = "TOOL-213-8" target = "Clone Source: W/H link, drawn overlay (opacity/blend/clip/auto-hide), Alt target cursor" status = "partial" issue = 213 -note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); W/H link (cloneSource.set linked, panel chain button, #3298); custom-tip clipping remains missing" +note = "Clone Source overlay drawn with opacity, inversion, circular clipping, auto-hide and the Normal/Darken/Lighten/Difference blend against the image under the brush (canvas.rs draw_clone_preview, clone_source::overlay_blend); a shaped tip (sampled bitmap, rotation, roundness) clips the overlay to its outline (brush_cursor::tip_mask, #4372); W/H link (cloneSource.set linked, panel chain button, #3298)" [[item]] id = "TOOL-213-9" From c96842f7d94b87ba58a43f03fe94d176fe01ec57 Mon Sep 17 00:00:00 2001 From: MegaDon Date: Mon, 12 Oct 2026 21:56:14 -0400 Subject: [PATCH 293/523] The wheel steps the options bar's Mode dropdown for Brush, Pencil and Gradient (#2061) (#4447) The options bar's Mode dropdowns now use widgets::dropdown_wheel_hovered, the wheel stepping the Layers panel's Blend Mode already has (#1747): down picks the next mode, up the previous one, one per notch, stopping at the ends, over the button or the open list. The Brush and Pencil list keeps Photoshop's Clear after Dissolve. Adds my contributors/people.toml entry. Claude-Session: https://claude.ai/code/session_01RXND3Ywis6CuB5mv6fLYU7 Co-authored-by: megaDon101 <235516438+megaDon101@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- contributors/people.toml | 4 + crates/ui-egui/src/lib.rs | 3 + .../src/options_bar_mode_wheel_tests.rs | 83 +++++++++++++++++++ crates/ui-egui/src/panels.rs | 7 +- 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/src/options_bar_mode_wheel_tests.rs diff --git a/contributors/people.toml b/contributors/people.toml index f5b001204d..30191f52ae 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -143,3 +143,7 @@ sync_github_name = true [people.deadmeu] sync_github_name = true + +[people.megaDon101] +display_name = "megadon" +sync_github_name = false diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b6d844b80f..4a05a57282 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2401,6 +2401,9 @@ mod warp_text_dialog_tests; #[cfg(test)] mod blend_dropdown_wheel_tests; +#[cfg(test)] +mod options_bar_mode_wheel_tests; + #[cfg(test)] mod dropdown_press_tests; diff --git a/crates/ui-egui/src/options_bar_mode_wheel_tests.rs b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs new file mode 100644 index 0000000000..068561b4a0 --- /dev/null +++ b/crates/ui-egui/src/options_bar_mode_wheel_tests.rs @@ -0,0 +1,83 @@ +//! #2061: the scroll wheel over the options bar's Mode dropdown (Brush, Pencil, Gradient) steps +//! through the blend modes, as over the Layers panel's (#1747) and as in Photoshop. + +use egui::accesskit::Role; +use egui::{Pos2, Rect, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use photocraft_doc::BlendMode; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// The app with a new document and `tool` chosen. +fn harness(tool: Tool) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.ui.tool = tool; + app + }); + h.run_steps(8); + h +} + +/// The options bar's Mode dropdown: the highest one showing `Normal` (the Layers panel's is lower). +fn mode_dropdown(h: &Harness<'_, PhotocraftApp>) -> Rect { + h.query_all_by_role(Role::ComboBox).filter(|n| n.value().as_deref() == Some("Normal")).map(|n| n.rect()).min_by(|a, b| a.top().total_cmp(&b.top())).unwrap() +} + +/// `lines` wheel lines at `at` in one event: negative is down. +fn wheel(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, lines: f32) { + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, lines), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }); + h.run_steps(3); +} + +fn brush_mode(h: &Harness<'_, PhotocraftApp>) -> BlendMode { + h.state().session.tools.brush.mode +} + +#[test] +fn the_wheel_over_the_brush_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Brush); + let at = mode_dropdown(&h).center(); + assert_eq!(brush_mode(&h), BlendMode::Normal); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, -1.0); + assert!(h.state().ui.tool_options.brush_clear, "Clear follows Dissolve, as in Photoshop"); + wheel(&mut h, at, -1.0); + assert!(!h.state().ui.tool_options.brush_clear); + assert_eq!(brush_mode(&h), BlendMode::Darken); + wheel(&mut h, at, 3.0); + assert_eq!(brush_mode(&h), BlendMode::Normal, "up steps back and stops at the first mode"); +} + +#[test] +fn the_wheel_over_the_pencil_mode_dropdown_steps_the_painting_mode() { + let mut h = harness(Tool::Pencil); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -1.0); + assert_eq!(brush_mode(&h), BlendMode::Dissolve); + wheel(&mut h, at, 1.0); + assert_eq!(brush_mode(&h), BlendMode::Normal); +} + +#[test] +fn the_wheel_over_the_gradient_mode_dropdown_steps_its_blend_mode() { + let mut h = harness(Tool::Gradient); + let at = mode_dropdown(&h).center(); + wheel(&mut h, at, -2.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Darken); + wheel(&mut h, at, 1.0); + assert_eq!(h.state().ui.tool_options.gradient_blend_mode, BlendMode::Dissolve); +} diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1cc16a1fa3..585841042f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1226,7 +1226,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { widgets::vline(ui, 22.0); opt_label(ui, tl!("Mode")); let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect(); - widgets::dropdown(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); + // The wheel over it steps the mode, as over the Layers panel's (#2061). + widgets::dropdown_wheel_hovered(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0); opt_label(ui, tl!("Opacity")); widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0); widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, tl!("Reverse")); @@ -3576,13 +3577,13 @@ fn brush_tip(p: &egui::Painter, c: egui::Pos2, rad: f32, hardness: f32, color: C /// The Brush's or Pencil's Mode dropdown: the blend modes, with Photoshop's painting-only Clear /// after Dissolve (#3439). Clear erases with the brush (`clear`); it shows while chosen, or while -/// `~` is held (`held`). +/// `~` is held (`held`). The wheel over it steps the mode, as over the Layers panel's (#2061). fn paint_mode_dropdown(ui: &mut egui::Ui, id: &str, b: &mut photocraft_engine::BrushSettings, clear: &mut bool, held: bool) { let mut opts: Vec<(Option, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (Some(*m), m.label())).collect(); let at = opts.iter().position(|o| o.0 == Some(BlendMode::Dissolve)).map_or(opts.len(), |i| i + 1); opts.insert(at, (None, "Clear")); let mut mode = if *clear || held { None } else { Some(b.mode) }; - if widgets::dropdown(ui, id, &mut mode, &opts, 96.0) { + if !widgets::dropdown_wheel_hovered(ui, id, &mut mode, &opts, 96.0).0.is_empty() { *clear = mode.is_none(); if let Some(m) = mode { b.mode = m; From c1e3ae9f162b9b6eae37bbdbfb42cf94f3606ea4 Mon Sep 17 00:00:00 2001 From: baz <57290335+bazmarth@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:56:19 +0000 Subject: [PATCH 294/523] Pen: Auto Add/Delete anchors on the active path (#4144) (#4540) * Pen: Auto Add/Delete anchors on the active path (#4144) With the Pen's new Auto Add/Delete option (options bar, on by default as in Photoshop), a click on a segment of the active path adds an anchor there and a click on one of its anchors deletes it, one History step each, instead of starting a new path. The active path is the targeted vector mask or the active shape layer's path, then the work path. The end points of an open subpath, a path being drawn, and Shift, Alt or Cmd/Ctrl held leave the click to the Pen. Hovering highlights the segment or anchor and the cursor shows + or -, like the Add/Delete Anchor Point tools, which share the same code (anchor_tools::apply). Co-Authored-By: Claude Opus 5.5 * Pen: keep the close-drag test off Auto Add/Delete when it redraws over a finished path Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/anchor_tools.rs | 56 ++++++++++++++++- crates/ui-egui/src/anchor_tools_tests.rs | 76 ++++++++++++++++++++++++ crates/ui-egui/src/direct_select.rs | 13 ++-- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/pen_draw_tests.rs | 4 +- crates/ui-egui/src/state.rs | 5 ++ crates/ui-egui/src/tool_feedback.rs | 4 ++ crates/ui-egui/src/tool_reset.rs | 3 +- crates/ui-egui/src/vector_ui.rs | 6 ++ docs/ui-parity.md | 4 +- 28 files changed, 182 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/anchor_tools.rs b/crates/ui-egui/src/anchor_tools.rs index a314db5fc9..149792b39e 100644 --- a/crates/ui-egui/src/anchor_tools.rs +++ b/crates/ui-egui/src/anchor_tools.rs @@ -7,6 +7,9 @@ //! * Delete Anchor Point: hovering an anchor highlights it; a click removes it //! (`path.deleteAnchor`), joining its neighbours. //! * ⌥ swaps the two tools and ⌘/Ctrl is Direct Selection (`direct_select`) while held. +//! * The Pen does both with Auto Add/Delete on (its options bar, on by default, #4144), on the +//! active path only and while no path is being drawn: a click on a segment adds, a click on an +//! anchor deletes, except an open subpath's end points; ⇧ turns it off while held. //! //! Each click is one command and one history step; a click on nothing it can edit does nothing. @@ -43,8 +46,42 @@ fn target(app: &PhotocraftApp, tool: Tool, p: [f64; 2]) -> Option<(PathRef, Path } } -/// Canvas pointer events for the anchor tools; false when the current tool isn't one. +/// The Pen's Auto Add/Delete (#4144): what a Pen click at `p` does to the active path instead of +/// starting a new one, as the anchor tool that does it (Add Anchor Point on a segment, Delete +/// Anchor Point on an anchor), the path and the hit. `None` when the option is off, ⇧, ⌥ or ⌘ is +/// held, a path is being drawn, or the click hits nothing it edits. The end points of an open +/// subpath are left to the Pen, as in Photoshop. +pub fn pen_auto(app: &PhotocraftApp, tool: Tool, mods: Modifiers, p: [f64; 2]) -> Option<(Tool, PathRef, Path, Hit)> { + if tool != Tool::Pen || !app.ui.tool_options.pen_auto_add_delete || app.ui.pen.is_some() || mods.shift || mods.alt || mods.command { + return None; + } + let tol = 6.0 / f64::from(app.point_zoom().max(0.01)); + let (r, path, hit) = + crate::direct_select::active_paths(app).into_iter().find_map(|(r, path)| edit::hit(&path, Point::new(p[0], p[1]), tol, &[]).map(|h| (r, path, h)))?; + let tool = match hit { + Hit::Segment(..) => Tool::AddAnchorPoint, + Hit::Anchor([s, k]) => { + let sp = path.subpaths.get(s)?; + if !sp.closed && (k == 0 || k + 1 == sp.knots.len()) { + return None; + } + Tool::DeleteAnchorPoint + } + Hit::Handle(..) => return None, + }; + Some((tool, r, path, hit)) +} + +/// Canvas pointer events for the anchor tools, and a Pen press that Auto Add/Delete takes; false +/// when they aren't theirs. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool { + if let ToolEvent::Down { x, y, .. } = ev + && let Some((_, r, _, hit)) = pen_auto(app, app.ui.tool, mods, [x, y]) + { + // The release finds no Pen path to finish, so the Pen draws nothing. + apply(app, r, hit); + return true; + } let Some(tool) = effective(app.ui.tool, mods) else { return false }; if let ToolEvent::Down { x, y, .. } = ev { click(app, tool, [x, y]); @@ -54,6 +91,11 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool fn click(app: &mut PhotocraftApp, tool: Tool, p: [f64; 2]) { let Some((r, _, hit)) = target(app, tool, p) else { return }; + apply(app, r, hit); +} + +/// Add an anchor where `hit` is on a segment of `r`, or delete the anchor it is on: one command. +fn apply(app: &mut PhotocraftApp, r: PathRef, hit: Hit) { let (id, mut params) = match hit { Hit::Segment([s, k], t) => ("path.addAnchor", serde_json::json!({"subpath": s, "knot": k, "t": t})), Hit::Anchor([s, k]) => ("path.deleteAnchor", serde_json::json!({"subpath": s, "knot": k})), @@ -82,6 +124,18 @@ pub fn draw_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn( return; } let Some((_, path, hit)) = target(app, tool, p) else { return }; + draw_hit(painter, to_scr, accent, &path, hit); +} + +/// The same feedback for the Pen when Auto Add/Delete would act on a click. +pub fn draw_pen_hover(app: &PhotocraftApp, painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, mods: Modifiers) { + let Some(p) = app.hover_doc else { return }; + if let Some((_, _, path, hit)) = pen_auto(app, app.ui.tool, mods, p) { + draw_hit(painter, to_scr, accent, &path, hit); + } +} + +fn draw_hit(painter: &egui::Painter, to_scr: &dyn Fn([f64; 2]) -> Pos2, accent: Color32, path: &Path, hit: Hit) { match hit { Hit::Segment([s, k], t) => { let Some(seg) = path.subpaths.get(s).and_then(|sp| sp.segments().get(k).copied()) else { return }; diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index c88f1a2b19..87c045eba9 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -154,3 +154,79 @@ fn the_cursor_shows_plus_or_minus() { assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::NONE), Some(Badge::Subtract)); assert_eq!(badge(&app, Tool::DeleteAnchorPoint, Modifiers::COMMAND), None, "⌘ is Direct Selection"); } + +/// #4144: with Auto Add/Delete (on by default) a Pen click on a segment of the active path adds +/// an anchor and a click on one of its anchors deletes it, one step each, and no Pen path starts. +#[test] +fn the_pen_adds_and_deletes_anchors_on_the_active_path() { + let mut app = app(Tool::Pen); + assert!(app.ui.tool_options.pen_auto_add_delete, "on by default, as in Photoshop"); + with_path(&mut app); + let n = steps(&app); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 5); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Add Anchor Point")); + assert!(app.ui.pen.is_none(), "no Pen path was started"); + click(&mut app, [40.0, 120.0], Modifiers::NONE); + let p = work(&app); + assert_eq!(p.subpaths[0].knots.len(), 4); + assert!(!p.subpaths[0].knots.iter().any(|k| k.anchor == Point::new(40.0, 120.0))); + assert_eq!(app.session.active().unwrap().history.undo_label(), Some("Delete Anchor Point")); + assert_eq!(steps(&app), n + 2); + assert!(app.ui.pen.is_none()); + // Off the path the Pen draws. + click(&mut app, [170.0, 170.0], Modifiers::NONE); + assert_eq!(app.ui.pen.as_ref().map(|p| p.knots.len()), Some(1)); +} + +/// Auto Add/Delete off, or ⇧ held, or a path being drawn: the Pen click places an anchor. +#[test] +fn the_pen_draws_over_the_path_with_auto_add_delete_off_shift_or_while_drawing() { + let mut app = app(Tool::Pen); + with_path(&mut app); + let before = work(&app); + app.ui.tool_options.pen_auto_add_delete = false; + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1))); + app.ui.pen = None; + + app.ui.tool_options.pen_auto_add_delete = true; + click(&mut app, [80.0, 120.0], Modifiers::SHIFT); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before.clone(), Some(1)), "⇧ turns it off"); + app.ui.pen = None; + + click(&mut app, [170.0, 170.0], Modifiers::NONE); + click(&mut app, [80.0, 120.0], Modifiers::NONE); + assert_eq!((work(&app), app.ui.pen.as_ref().map(|p| p.knots.len())), (before, Some(2)), "a path being drawn takes the click"); +} + +/// The end points of an open subpath stay the Pen's; its middle anchors are deleted. +#[test] +fn the_pen_leaves_open_end_points_alone() { + let mut app = app(Tool::Pen); + let path = json!({"subpaths": [{"closed": false, "knots": [[40, 40], [120, 40], [120, 120]]}]}); + app.run("path.set", json!({"name": "work", "path": path})).unwrap(); + let before = work(&app); + for end in [[40.0, 40.0], [120.0, 120.0]] { + click(&mut app, end, Modifiers::NONE); + assert_eq!(work(&app), before, "{end:?}"); + app.ui.pen = None; + } + click(&mut app, [120.0, 40.0], Modifiers::NONE); + assert_eq!(work(&app).subpaths[0].knots.len(), 2); +} + +#[test] +fn the_pen_cursor_shows_what_auto_add_delete_would_do() { + use crate::tool_feedback::{Badge, badge}; + let mut app = app(Tool::Pen); + with_path(&mut app); + for (at, want) in [([80.0, 120.0], Some(Badge::Add)), ([40.0, 120.0], Some(Badge::Subtract)), ([80.0, 80.0], None)] { + app.hover_doc = Some(at); + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), want, "{at:?}"); + } + app.hover_doc = Some([80.0, 120.0]); + assert_eq!(badge(&app, Tool::Pen, Modifiers::SHIFT), None); + app.ui.tool_options.pen_auto_add_delete = false; + assert_eq!(badge(&app, Tool::Pen, Modifiers::NONE), None); +} diff --git a/crates/ui-egui/src/direct_select.rs b/crates/ui-egui/src/direct_select.rs index 01deb0a070..b18361828d 100644 --- a/crates/ui-egui/src/direct_select.rs +++ b/crates/ui-egui/src/direct_select.rs @@ -134,12 +134,17 @@ pub fn shows(app: &PhotocraftApp, mods: Modifiers) -> bool { || matches!(app.ui.tool, Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) } -/// The paths Direct Selection edits, front first: the targeted vector mask or the active shape -/// layer's path, then the work path. -pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { +/// The active paths, front first: the targeted vector mask or the active shape layer's path, then +/// the work path. The Pen's Auto Add/Delete edits these (`anchor_tools::pen_auto`). +pub(crate) fn active_paths(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { let layer = crate::vector_ui::targeted_vector_mask(app).or_else(|| crate::vector_ui::active_shape_path(app)); let work = app.session.active().and_then(|st| st.doc.work_path.clone()); - let mut out: Vec<_> = layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect(); + layer.map(|(id, p)| (PathRef::Layer(id), p)).into_iter().chain(work.map(|p| (PathRef::Work, p))).collect() +} + +/// The paths Direct Selection edits, front first: the active paths, then every visible shape layer. +pub(crate) fn candidates(app: &PhotocraftApp) -> Vec<(PathRef, Path)> { + let mut out = active_paths(app); if let Some(st) = app.session.active() { for (path, _, l) in st.doc.walk().iter().rev() { if !l.visible || !(1..=path.len()).all(|n| st.doc.layer_at(&path[..n]).is_some_and(|a| a.visible)) { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 2a9c4a9d5d..e6ebb2963c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3698,3 +3698,4 @@ navigator Zoom Zvětšení Action playback stopped Přehrávání akce zastaveno Step {step} is waiting for its dialog Krok {step} čeká na svůj dialog @plural {n} action|{n} actions {n} akce|{n} akce|{n} akcí + Auto Add/Delete Automaticky přidat/odstranit diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d209af709e..a8efbdd687 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2730,3 +2730,4 @@ curves Smooth Glätten Action playback stopped Aktionswiedergabe angehalten Step {step} is waiting for its dialog Schritt {step} wartet auf seinen Dialog @plural {n} action|{n} actions {n} Aktion|{n} Aktionen + Auto Add/Delete Autom. hinzuf./löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e27766fefa..ac29108ad3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3788,3 +3788,4 @@ curves Smooth Εξομάλυνση Action playback stopped Η αναπαραγωγή της ενέργειας σταμάτησε Step {step} is waiting for its dialog Το βήμα {step} περιμένει το παράθυρο διαλόγου του @plural {n} action|{n} actions {n} ενέργεια|{n} ενέργειες + Auto Add/Delete Αυτόματη προσθήκη/διαγραφή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 62886211e8..0a87b52435 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2771,3 +2771,4 @@ curves Smooth Suavizar Action playback stopped Reproducción de la acción detenida Step {step} is waiting for its dialog El paso {step} está esperando su cuadro de diálogo @plural {n} action|{n} actions {n} acción|{n} acciones + Auto Add/Delete Añadir/eliminar automáticamente diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 7789b2c02b..a8e9e2bf3b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3091,3 +3091,4 @@ curves Smooth Lisser Action playback stopped Exécution de l’action arrêtée Step {step} is waiting for its dialog L’étape {step} attend sa boîte de dialogue @plural {n} action|{n} actions {n} action|{n} actions + Auto Add/Delete Ajout/suppr. auto diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1c9f15f0ba..0775780a4f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4010,3 +4010,4 @@ cameraRaw Yellow Sárga Load settings from a Camera Raw .xmp file Beállítások betöltése Camera Raw .xmp-fájlból Loaded Camera Raw settings from {file} Camera Raw-beállítások betöltve innen: {file} Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw-beállítások betöltve innen: {file} ({n} nem támogatott) + Auto Add/Delete Automatikus hozzáadás/törlés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index cb77490c4b..f4c53e49b3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3078,3 +3078,4 @@ curves Smooth Haluskan Action playback stopped Pemutaran tindakan dihentikan Step {step} is waiting for its dialog Langkah {step} menunggu dialognya @plural {n} action|{n} actions {n} tindakan + Auto Add/Delete Tambah/Hapus Otomatis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 288353a61b..5b12254b11 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4054,3 +4054,4 @@ navigator Zoom Zoom Action playback stopped Esecuzione dell’azione interrotta Step {step} is waiting for its dialog Il passaggio {step} attende la sua finestra di dialogo @plural {n} action|{n} actions {n} azione|{n} azioni + Auto Add/Delete Aggiungi/elimina automaticamente diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 26a31ce4de..c6a1ac7778 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3700,3 +3700,4 @@ curves Smooth スムーズ Action playback stopped アクションの再生を停止しました Step {step} is waiting for its dialog ステップ {step} はダイアログを待っています @plural {n} action|{n} actions アクション {n} 個 + Auto Add/Delete 自動追加・削除 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b6a95907d3..166a2c7942 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3260,3 +3260,4 @@ curves Smooth 매끄럽게 Action playback stopped 액션 재생이 중지되었습니다 Step {step} is waiting for its dialog {step}단계가 대화 상자를 기다리고 있습니다 @plural {n} action|{n} actions 액션 {n}개 + Auto Add/Delete 자동 추가/삭제 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 1ec6d64b0f..10ebe0fd50 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3850,3 +3850,4 @@ curves Smooth Vloeiend maken Action playback stopped Afspelen van de handeling gestopt Step {step} is waiting for its dialog Stap {step} wacht op zijn dialoogvenster @plural {n} action|{n} actions {n} handeling|{n} handelingen + Auto Add/Delete Automatisch toevoegen/verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fab352b28a..4bf1a55886 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3687,3 +3687,4 @@ curves Smooth Wygładź Action playback stopped Odtwarzanie operacji zatrzymane Step {step} is waiting for its dialog Krok {step} czeka na swoje okno dialogowe @plural {n} action|{n} actions {n} operacja|{n} operacje|{n} operacji + Auto Add/Delete Automatyczne dodawanie/usuwanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b7c0def324..f562d57389 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3880,3 +3880,4 @@ layerLabel No Color Sem cor Action playback stopped Reprodução da ação interrompida Step {step} is waiting for its dialog A etapa {step} está aguardando sua caixa de diálogo @plural {n} action|{n} actions {n} ação|{n} ações + Auto Add/Delete Adicionar/excluir automaticamente diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2f174fd7b9..3e1fc3c399 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3035,3 +3035,4 @@ curves Smooth Сгладить Action playback stopped Воспроизведение операции остановлено Step {step} is waiting for its dialog Шаг {step} ожидает своего диалога @plural {n} action|{n} actions {n} операция|{n} операции|{n} операций + Auto Add/Delete Автодобавление/удаление diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e9dc17d8d4..c67ccd0138 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3822,3 +3822,4 @@ cameraData Lens Lens Load settings from a Camera Raw .xmp file Ayarları bir Camera Raw .xmp dosyasından yükle Loaded Camera Raw settings from {file} Camera Raw ayarları {file} dosyasından yüklendi Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw ayarları {file} dosyasından yüklendi ({n} desteklenmiyor) + Auto Add/Delete Otomatik Ekle/Sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0facb9e9cc..87562df818 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3788,3 +3788,4 @@ curves Smooth Згладити Action playback stopped Відтворення операції зупинено Step {step} is waiting for its dialog Крок {step} очікує на свій діалог @plural {n} action|{n} actions {n} операція|{n} операції|{n} операцій + Auto Add/Delete Автододавання/видалення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 03ad654f76..5157c3d46d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3574,3 +3574,4 @@ curves Smooth Làm mượt Gradient Preset Options Tùy chọn thiết lập sẵn chuyển sắc Import .GRD Nhập .GRD Choose a gradient file (.grd). Chọn tệp chuyển sắc (.grd). + Auto Add/Delete Tự động thêm/xóa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 080a5ac03d..4fb92c2ebd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2695,3 +2695,4 @@ curves Smooth 平滑 Action playback stopped 动作播放已停止 Step {step} is waiting for its dialog 步骤 {step} 正在等待其对话框 @plural {n} action|{n} actions {n} 个动作 + Auto Add/Delete 自动添加/删除 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3ed91649c2..c3010b7edf 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2680,3 +2680,4 @@ curves Smooth 平滑 Action playback stopped 動作播放已停止 Step {step} is waiting for its dialog 步驟 {step} 正在等待其對話框 @plural {n} action|{n} actions {n} 個動作 + Auto Add/Delete 自動增加/刪除 diff --git a/crates/ui-egui/src/pen_draw_tests.rs b/crates/ui-egui/src/pen_draw_tests.rs index 229d69995a..2b3819cedd 100644 --- a/crates/ui-egui/src/pen_draw_tests.rs +++ b/crates/ui-egui/src/pen_draw_tests.rs @@ -139,7 +139,9 @@ fn dragging_on_the_first_anchor_closes_with_its_handles_in_one_step() { assert!(near([k.in_ctrl.x, k.in_ctrl.y], [0.0, 40.0]) && near([k.out_ctrl.x, k.out_ctrl.y], [40.0, 0.0]), "{k:?}"); assert!(k.smooth); - // A plain click on a smooth first anchor closes without touching its handles. + // A plain click on a smooth first anchor closes without touching its handles. This path is + // drawn over the anchors of the one just finished, which Auto Add/Delete would delete (#4144). + h.state_mut().ui.tool_options.pen_auto_add_delete = false; drag(&mut h, &[[20.0, 20.0], [40.0, 20.0]], None); click(&mut h, [120.0, 20.0]); click(&mut h, [120.0, 100.0]); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 620adf8d23..0e3424f25d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -588,6 +588,10 @@ pub struct ToolOptions { /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. #[serde(default = "default_curve_fit")] pub curve_fit: f32, + /// Pen › Auto Add/Delete: a Pen click on a segment of the active path adds an anchor there, + /// on one of its anchors deletes it (#4144). + #[serde(default = "yes")] + pub pen_auto_add_delete: bool, /// Marquee options bar: "normal" | "fixedRatio" | "fixedSize", with the ratio or size. #[serde(default = "default_marquee_style")] pub marquee_style: String, @@ -800,6 +804,7 @@ impl Default for ToolOptions { polygon_sides: 5, line_weight: 3.0, curve_fit: default_curve_fit(), + pen_auto_add_delete: true, marquee_style: default_marquee_style(), marquee_width: 1.0, marquee_height: 1.0, diff --git a/crates/ui-egui/src/tool_feedback.rs b/crates/ui-egui/src/tool_feedback.rs index bc993a7ae6..eda03b305e 100644 --- a/crates/ui-egui/src/tool_feedback.rs +++ b/crates/ui-egui/src/tool_feedback.rs @@ -105,6 +105,10 @@ pub fn badge(app: &PhotocraftApp, tool: Tool, m: egui::Modifiers) -> Option reset!(strength, sample_all_layers, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), - Tool::Pen | Tool::CurvaturePen => reset!(vector_mode), + Tool::Pen => reset!(vector_mode, pen_auto_add_delete), + Tool::CurvaturePen => reset!(vector_mode), Tool::FreeformPen => reset!(vector_mode, curve_fit), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index d4de249202..63d12fb198 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -887,6 +887,8 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some((_, p)) = targeted_vector_mask(app) { draw_path(&p, tool == Tool::PathSelection); } + // The segment the Pen's Auto Add/Delete would add an anchor to, or the anchor it would delete. + crate::anchor_tools::draw_pen_hover(app, painter, &to_scr, accent, painter.ctx().input(|i| i.modifiers)); } // Pen path in progress, with handles of the last knot and a rubber band to the pointer. if let Some(pen) = &app.ui.pen { @@ -1000,6 +1002,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.stroke_width = enabled_stroke_width(o.stroke_width); } } + if tool == Tool::Pen { + crate::widgets::vline(ui, 22.0); + crate::widgets::checkbox(ui, &mut o.pen_auto_add_delete, tl!("Auto Add/Delete")); + } let enter = crate::shortcuts::pretty("Enter"); match tool { Tool::FreeformPen => { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 33fb8b319f..d48af3bfcd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -118,6 +118,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme |---|---|---| | Click corner, drag smooth, ⌥ breaks a handle, ⌘ direct-selects, ⇧ 45° | partial | TOOL-214-10 | | Handle on the last point, close-drag handle | partial | #1482 | +| Pen Auto Add/Delete: click a segment of the active path to add, an anchor to delete; ⇧ suspends it | done | #4144 | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | missing | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | @@ -222,3 +223,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Tool keys from Photoshop's shortcut set: ← / → rotate the brush tip, ⇧-click for new type, Space+F canvas colour | | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | +| 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | From 875e17ea338abb1b31050503920d01a37e4743ea Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:56:24 -0600 Subject: [PATCH 295/523] Save As: on Windows, picking another file type changes the extension (#4394) (#4407) With File name extensions shown in Explorer, the Windows save dialog keeps a visible extension when the user picks another type: image.psd saved as PhotoCraft stayed image.psd. Given a bare name, the dialog adds the chosen type's own extension (rfd sets the default extension, which Windows updates on every type change). On Windows the dialog now starts with the stem, and the suggested type leads the type list with the suggested extension first (so .psb, .fit and .jpeg stay what they were). Names with a second dot or an upper-case extension keep the whole name. macOS and Linux are unchanged. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- apps/photocraft/src/services.rs | 56 ++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index f892de31cd..891e251bf1 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -82,8 +82,11 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { let mut v: Vec<(String, Vec)> = SAVE_FILTERS.iter().map(|(name, exts)| (name.to_string(), exts.iter().map(|e| e.to_string()).collect())).collect(); match v.iter().position(|(_, exts)| exts.contains(&ext)) { Some(i) => { - let f = v.remove(i); - v.insert(0, f); + let (name, mut exts) = v.remove(i); + // Windows adds the first type's first extension to a bare name: `.psb`, not `.psd`. + exts.retain(|e| *e != ext); + exts.insert(0, ext); + v.insert(0, (name, exts)); } None if !ext.is_empty() => v.insert(0, (ext.to_ascii_uppercase(), vec![ext])), None => {} @@ -91,6 +94,21 @@ fn save_filters(suggested: &str) -> Vec<(String, Vec)> { v } +/// The name the save dialog starts with. On Windows a visible extension stays put when the user +/// picks another type (with File name extensions shown, `image.psd` saved as PhotoCraft stayed +/// `image.psd`, #4394), while a bare name gets the chosen type's extension, so there the dialog +/// gets the stem and the suggested type (listed first by [`save_filters`]) supplies `.psd`. A +/// stem that still has a dot (`scan.jpg.psd`: Windows could keep `scan.jpg`) or an upper-case +/// extension (the user's spelling) keeps the whole name. Elsewhere the whole name is kept: the +/// macOS panel has no type list, and on Linux rfd doesn't report which type was picked. +fn dialog_file_name(suggested: &str, windows: bool) -> Option { + let path = Path::new(suggested); + let name = path.file_name()?.to_string_lossy().into_owned(); + let (Some(stem), Some(ext)) = (path.file_stem(), path.extension().and_then(|e| e.to_str())) else { return Some(name) }; + let bare = windows && Path::new(stem).extension().is_none() && !ext.is_empty() && ext == ext.to_ascii_lowercase(); + Some(if bare { stem.to_string_lossy().into_owned() } else { name }) +} + /// Shows an Open or Save dialog without blocking the event loop (#673, #574): rfd's async dialog /// is created here, on the main thread, with the window as its parent (a sheet on macOS, an owned /// modal window elsewhere), and a helper thread waits for it and hands the answer to the app. @@ -132,8 +150,8 @@ fn show_file_dialog(request: FileDialogRequest, parent: Option<&eframe::Frame>, for (name, exts) in save_filters(&suggested) { dialog = dialog.add_filter(name, &exts); } - if let Some(name) = Path::new(&suggested).file_name() { - dialog = dialog.set_file_name(name.to_string_lossy()); + if let Some(name) = dialog_file_name(&suggested, cfg!(windows)) { + dialog = dialog.set_file_name(name); } if let Some(dir) = Path::new(&suggested).parent().filter(|p| !p.as_os_str().is_empty()) { dialog = dialog.set_directory(dir); @@ -669,7 +687,8 @@ mod tests { .join(" "); assert_eq!(exts, want.split(' ').collect::>()); for (name, ext) in asked.iter().zip(exts) { - assert!(save_filters(name)[0].1.iter().any(|e| e == ext), "{name}: {:?}", save_filters(name)[0]); + // The first type's first extension is the one Windows adds to the bare name. + assert_eq!(save_filters(name)[0].1.first().map(String::as_str), Some(ext), "{name}: {:?}", save_filters(name)[0]); } assert_eq!(save_filters("Untitled")[0].0, "PSD Document", "no extension keeps the default"); } @@ -701,6 +720,33 @@ mod tests { assert!(err.contains("256"), "{err}"); } + /// #4394: with File name extensions shown, Windows kept `image.psd` when the type was changed to + /// PhotoCraft. Given the bare `image`, it adds the extension of whichever type is picked + /// (checked by hand with rfd 0.17.2 on Windows 11), defaulting to the first type's. + #[test] + fn the_windows_save_dialog_gets_a_bare_name_so_the_type_picks_the_extension() { + for (suggested, name, first) in [ + ("/pics/image.psd", "image", "psd"), + ("/pics/image.psb", "image", "psb"), + ("/pics/image.pcraft", "image", "pcraft"), + ("/pics/sky.fit", "sky", "fit"), + ("cat.jpeg", "cat", "jpeg"), + ("swatches.aco", "swatches", "aco"), + ] { + assert_eq!(dialog_file_name(suggested, true).as_deref(), Some(name), "{suggested}"); + assert_eq!(save_filters(suggested)[0].1[0], first, "{suggested}"); + // Elsewhere the dialogs keep the whole name (see `dialog_file_name`). + let whole = Path::new(suggested).file_name().unwrap().to_string_lossy().into_owned(); + assert_eq!(dialog_file_name(suggested, false), Some(whole), "{suggested}"); + } + // Kept whole: a second dot (Windows could keep `scan.jpg`), the user's upper case, no + // extension, and a dotfile. + for name in ["scan.jpg.psd", "my.photo.psd", "transparent.PNG", "Untitled", ".psd"] { + assert_eq!(dialog_file_name(name, true).as_deref(), Some(name)); + } + assert_eq!(dialog_file_name("", true), None); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); From b1b44595b7baac4149cb89f7f79b31d6edbf3004 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 19:56:29 -0600 Subject: [PATCH 296/523] Toolbar: no tooltips over an open tool flyout (#4450) (#4469) Holding a tool button to open its flyout showed that button's tooltip on top of the flyout, and other toolbar buttons kept showing theirs while it was open. The flyout is our own Area, so egui's rule that hides tooltips while a popup is open didn't apply. The toolbar's buttons (the tools, Quick Mask, Change Screen Mode) now skip their tooltips while a tool flyout is open or the mouse button is held. icons::button_with_icon_size_tip keeps the tooltip text as the button's label when the tooltip is hidden. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/icons.rs | 8 +++- crates/ui-egui/src/panels.rs | 81 ++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 390b378716..215e5994e8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -177,6 +177,12 @@ pub fn button(ui: &mut egui::Ui, name: &str, box_size: f32, selected: bool, tool /// Square icon button with an explicit glyph size, independent of its hit area. pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str) -> Response { + button_with_icon_size_tip(ui, name, box_size, icon_size, selected, tooltip, true) +} + +/// [`button_with_icon_size`] whose tooltip shows only when `show_tip`; the tooltip text still +/// names the button (for screen readers and tests) when it is hidden. +pub fn button_with_icon_size_tip(ui: &mut egui::Ui, name: &str, box_size: f32, icon_size: f32, selected: bool, tooltip: &str, show_tip: bool) -> Response { let t = Tokens::get(ui.ctx()); let style = IconButtonStyle { normal: ButtonVisuals { fill: Color32::TRANSPARENT, stroke: egui::Stroke::NONE, icon: t.icon }, @@ -190,7 +196,7 @@ pub fn button_with_icon_size(ui: &mut egui::Ui, name: &str, box_size: f32, icon_ let resp = IconButton::new(label, Vec2::splat(box_size), &style).selected(selected).show(ui, |ui, rect, tint| { paint(ui, rect, name, icon_size, tint); }); - if tooltip.is_empty() { resp } else { resp.on_hover_text(label) } + if tooltip.is_empty() || !show_tip { resp } else { resp.on_hover_text(label) } } /// Rail toggle: "on" gets a quiet filled background and full-strength icon (no accent). diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 585841042f..95bf65068f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -123,6 +123,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } + // No tool tooltips over an open flyout, or while a button is held to open one (#4450): + // Photoshop shows none there. (The flyout is our own Area, so egui's rule for popups + // doesn't hide them.) + let tool_tips = ui.data(|d| d.get_temp::<(egui::Id, Rect)>(flyout_id)).is_none() && !ui.input(|i| i.pointer.primary_down()); let ctx = ui.ctx().clone(); let footer = (if t.pro { 8.0 } else { 14.0 }) + chips_height(t.pro, t.round_chips, t.round_chips && double) @@ -155,7 +159,8 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let sel = slot.contains(&app.ui.tool); let tip = if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; - let resp = icons::button_with_icon_size(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip); + let resp = + icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); if slot.len() > 1 { paint_toolbar_flyout_indicator(ui, &t, &resp); } @@ -284,18 +289,20 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.with_layout(layout, |ui| { let quick_mask = app.session.active().is_some_and(|s| s.doc.quick_mask.is_some()); - if icons::button( + if icons::button_with_icon_size_tip( ui, "square-dashed", bx, + (bx * 0.52).round(), quick_mask, if quick_mask { tl!("Edit in Standard Mode (Q)") } else { tl!("Edit in Quick Mask Mode (Q)") }, + tool_tips, ) .clicked() { let _ = crate::menus::invoke(app, &ctx, "select.editInQuickMaskMode", json!({})); } - let sm = icons::button(ui, "app-window", bx, false, tl!("Change Screen Mode (F)")); + let sm = icons::button_with_icon_size_tip(ui, "app-window", bx, (bx * 0.52).round(), false, tl!("Change Screen Mode (F)"), tool_tips); paint_toolbar_flyout_indicator(ui, &t, &sm); // Right-click, or click and hold like the tool flyouts (#4444), lists the modes, @@ -4752,6 +4759,74 @@ mod type_flyout_tests { assert!(menu_open(&ctx)); } + /// Every text drawn in one toolbar frame. + fn frame_texts(app: &mut PhotocraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let mut out = ctx.run_ui( + egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 1800.0))), ..Default::default() }, + |ui| toolbar(app, ui), + ); + out.textures_delta.clear(); + let mut texts = Vec::new(); + out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); + texts + } + + /// #4450: no tool tooltip while a flyout is open or a tool button is held to open one, for the + /// held tool or any other; a plain hover still shows its tooltip. + #[test] + fn no_tooltips_over_an_open_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let slot_of = |tool: Tool| TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&tool)).unwrap(); + let (eyedropper, brush) = (buttons[slot_of(Tool::Eyedropper)].center(), buttons[slot_of(Tool::Brush)].center()); + let tip = |tool: Tool| format!("{} ({})", tl!(tool.label()), tool.key()); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // A plain hover shows the tooltip. + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(brush)]); + let mut texts = Vec::new(); + for k in 1..=10 { + texts = frame_texts(&mut app, &ctx, 1.0 + 0.1 * f64::from(k), vec![]); + } + assert!(texts.contains(&tip(Tool::Brush)), "hovered long enough: {texts:?}"); + // Held on the Eyedropper: no tooltip before or after its flyout opens. + frame(&mut app, &ctx, 3.0, vec![egui::Event::PointerMoved(eyedropper), pointer(eyedropper, true)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, 3.0 + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(Tool::Eyedropper)), "held {k}: {texts:?}"); + } + assert!(ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(), "the flyout is open"); + // Released, with the flyout still open: none for the held tool or another one. + frame(&mut app, &ctx, 4.1, vec![pointer(eyedropper, false)]); + for (start, at, tool) in [(4.5, eyedropper, Tool::Eyedropper), (6.0, brush, Tool::Brush)] { + frame(&mut app, &ctx, start, vec![egui::Event::PointerMoved(at)]); + for k in 1..=10 { + let texts = frame_texts(&mut app, &ctx, start + 0.1 * f64::from(k), vec![]); + assert!(!texts.contains(&tip(tool)), "{tool:?} under the flyout: {texts:?}"); + } + } + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From 1cb3eaa5d240cafce4cbf110dc0febff385574d0 Mon Sep 17 00:00:00 2001 From: Ian Swope Date: Mon, 12 Oct 2026 20:57:47 -0500 Subject: [PATCH 297/523] Free Transform: apply a large layer's warp in the background, with progress and Esc to cancel (#1015) (#4218) Committing Free Transform on a 300-550 MP layer ran the bicubic warp on the UI thread: several seconds of a frozen window with no feedback. On a 20000x16000 layer the warp alone took about 6.6 s. edit.transform is now a background job (edit_job): the warp reports progress per tile and stops when cancelled. The transform session stays open while it runs, showing the moving pixels without the box, so the layer doesn't jump back to its old place until the result lands. The modal progress dialog shows after the usual delay, and Esc or Cancel cancels the job. A copy or placed layer the session made is folded into the step, or taken back, in its own document. Undo and Redo are refused while the commit runs. Tests and scripts run inline as before. Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/algo/src/transform.rs | 59 +++++++++++- crates/engine/src/jobs_tests.rs | 16 ++++ crates/engine/src/transform_cmds.rs | 138 +++++++++++++++++---------- crates/ui-egui/src/jobs_ui.rs | 2 + crates/ui-egui/src/jobs_ui_tests.rs | 62 ++++++++++++ crates/ui-egui/src/transform_tool.rs | 99 +++++++++++++++++-- 6 files changed, 317 insertions(+), 59 deletions(-) diff --git a/crates/algo/src/transform.rs b/crates/algo/src/transform.rs index 2584a877df..ba75a65336 100644 --- a/crates/algo/src/transform.rs +++ b/crates/algo/src/transform.rs @@ -9,7 +9,7 @@ use photocraft_color::PixelFormat; use photocraft_geom::Rect; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde::{Deserialize, Serialize}; use crate::resample::{Resample, resize_surface}; @@ -143,14 +143,26 @@ fn catmull_rom(t: f64) -> [f64; 4] { /// The result has the same format as `src`, with alpha added if `src` had none; pixels outside /// the warped quad are transparent. pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result { - warp(src, src_rect, h, interp, None) + warp(src, src_rect, h, interp, None, &Interrupt::NONE) +} + +/// [`warp_surface`] reporting progress per destination tile; `Ok(None)` once `ctl` is cancelled. +pub fn warp_surface_with( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + ctl: &Interrupt, +) -> Result, photocraft_raster::AllocationError> { + let out = warp(src, src_rect, h, interp, None, ctl)?; + Ok((!ctl.cancelled()).then_some(out)) } /// [`warp_surface`] keeping only the destination pixels inside `clip`. The work is bounded by /// `clip` even when part of the source lies at or beyond the map's horizon (a Perspective Crop of a /// layer that reaches far past the crop quad), which would make the full warped extent unbounded. pub fn warp_surface_clipped(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Rect) -> Result { - warp(src, src_rect, h, interp, Some(clip)) + warp(src, src_rect, h, interp, Some(clip), &Interrupt::NONE) } /// Is `q` (TL, TR, BR, BL) a usable Perspective Crop quad: finite, strictly convex with every @@ -216,7 +228,15 @@ fn corner_bounds(corners: &[(f64, f64); 4]) -> Option { Some(Rect::new(bx0, by0, bx1, by1)) } -fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Option) -> Result { +/// A cancelled `ctl` skips the remaining tiles; the caller discards the result. +fn warp( + src: &Surface, + src_rect: Rect, + h: &Homography, + interp: Interp, + clip: Option, + ctl: &Interrupt, +) -> Result { let mut fmt = src.format(); let converted; let mut src = if fmt.alpha { @@ -277,7 +297,16 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?; tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty())); let src_ref: &Surface = src; + // Tiles finished so far, for progress reported per tile (from any worker thread). + let finished = std::sync::atomic::AtomicUsize::new(0); + let total = tiles.len().max(1); let work = |t: &Rect| -> Result)>, photocraft_raster::AllocationError> { + if ctl.cancelled() { + return Ok(None); + } + let k = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + // The last few percent are the writes. + ctl.progress(0.98 * k as f32 / total as f32); // Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter. let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64)); let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]); @@ -413,6 +442,9 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt } } let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); + if ctl.cancelled() { + return Ok(out); + } for (r, v) in done { out.try_write_region(r, &v)?; } @@ -424,6 +456,25 @@ fn warp(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp, clip: Opt mod tests { use super::*; + #[test] + fn the_interruptible_warp_reports_progress_and_stops_when_cancelled() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 600, 400), &[0.2, 0.4, 0.6, 1.0]); + let r = s.content_bounds(); + let h = Homography::rect_to_quad([0.0, 0.0, 600.0, 400.0], [[10.0, 0.0], [590.0, 20.0], [600.0, 400.0], [0.0, 380.0]]).unwrap(); + let reported = std::sync::Mutex::new(0.0f32); + let progress = |p: f32| { + let mut m = reported.lock().unwrap(); + *m = m.max(p); + }; + let never = || false; + let out = warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::new(&never, &progress)).unwrap().expect("not cancelled"); + assert_eq!(out.read_region(r), warp_surface(&s, r, &h, Interp::Bicubic).unwrap().read_region(r)); + assert!(*reported.lock().unwrap() > 0.9); + let always = || true; + assert!(warp_surface_with(&s, r, &h, Interp::Bicubic, &Interrupt::cancel_only(&always)).unwrap().is_none()); + } + /// Perspective Crop's map lands each quad corner exactly on its rectangle corner; degenerate, /// self-crossing, counter-clockwise and non-finite quads have none. #[test] diff --git a/crates/engine/src/jobs_tests.rs b/crates/engine/src/jobs_tests.rs index 4f631e82ef..a40b712c81 100644 --- a/crates/engine/src/jobs_tests.rs +++ b/crates/engine/src/jobs_tests.rs @@ -78,6 +78,22 @@ fn a_job_applies_as_one_undo_step_and_matches_the_inline_result() { assert!(s.jobs().is_empty()); } +#[test] +fn free_transform_runs_as_a_job_with_the_inline_result() { + let quad = json!({"quad": [[20, 10], [280, 30], [260, 190], [40, 170]]}); + let mut inline = session(300, 200); + inline.execute("edit.transform", quad.clone()).unwrap(); + + let mut s = session(300, 200); + let steps = s.active().unwrap().history.past_len(); + let id = job(s.start("edit.transform", quad).unwrap()); + let e = wait_event(&mut s, id); + assert!(matches!(e.outcome, JobOutcome::Done(_)), "{e:?}"); + assert_eq!(e.label, "Free Transform"); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one undo step"); + assert_eq!(pixels(&s), pixels(&inline), "same pixels as the synchronous command"); +} + #[test] fn cancel_leaves_the_document_byte_identical() { let mut s = session(1600, 1200); diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index a09366e6fd..bcf97126cc 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -1,10 +1,10 @@ //! Edit › Free Transform / Transform: projective transforms of layers (and the selection). -use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface}; +use photocraft_algo::transform::{Homography, Interp, warp_output_bounds, warp_surface, warp_surface_with}; use photocraft_color::PixelFormat; use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks}; use photocraft_geom::{Affine, Rect}; -use photocraft_raster::Surface; +use photocraft_raster::{Interrupt, Surface}; use serde_json::{Value, json}; use crate::commands::CommandSpec; @@ -304,11 +304,33 @@ fn check_layer_budget(doc_sel: Option<&Surface>, l: &Layer, h: &Homography) -> R /// is turned into a normal layer by the caller first ([`crate::extra_cmds::background_to_layer`]). /// Refused, with `l` untouched, when a result would exceed [`MAX_TRANSFORM_PIXELS`]. pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { - check_layer_budget(doc_sel, l, h)?; - apply_transform(doc_sel, group, l, h, affine, interp) + transform_layer_with(doc_sel, group, l, h, affine, interp, &Interrupt::NONE) } -fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { +/// [`transform_layer`] with progress and cancellation for the pixel warp (the slow part on a +/// large layer, #1015). +fn transform_layer_with( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + check_layer_budget(doc_sel, l, h)?; + apply_transform(doc_sel, group, l, h, affine, interp, ctl) +} + +fn apply_transform( + doc_sel: Option<&Surface>, + group: Locks, + l: &mut Layer, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { crate::allocation::checkpoint("transforming pixels")?; let locks = transform_locks(l, group, false); if locks.position || locks.all { @@ -317,7 +339,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H if h.is_identity() { if let LayerContent::Group(g) = &mut l.content { for c in g.children.iter_mut() { - apply_transform(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } return Ok(()); @@ -328,7 +350,7 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { - apply_transform(None, locks, c, h, affine, interp)?; + apply_transform(None, locks, c, h, affine, interp, ctl)?; } } LayerContent::Text(t) => { @@ -366,12 +388,12 @@ fn apply_transform(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, h: &H // Only the selected pixels move: lift them, clear them, warp and paste back. Some(sel) => { let (lifted, mut rest) = split_selected(surf, sel)?; - let moved = warp_surface(&lifted, src, h, interp)?; + let moved = warp_surface_with(&lifted, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?; composite_over(&mut rest, &moved); rest.prune(); rest } - None => warp_surface(surf, src, h, interp)?, + None => warp_surface_with(surf, src, h, interp, ctl)?.ok_or(EngineError::Cancelled)?, }; } } @@ -571,49 +593,69 @@ fn transform(s: &mut Session, p: &Value) -> Result { (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(); - s.edit("Free Transform", |doc, _| { - let sel = doc.selection.clone(); - // The selection outline moves too: price it before anything is built. - if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { - check_budget(sel.content_bounds(), &h, "selection")?; - } - if lone { - // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. - if !h.is_identity() { - let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; - let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); - check_budget(src, &h, "mask or channel")?; - *surf = match &sel { - Some(sel) => warp_gray_selected(surf, sel, &h, interp)?, - None => warp_gray(surf, &h, interp)?, - }; - if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); - } - } - 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))?; - transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?; + 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}), + ) +} + +/// The edit of [`transform`]: the layer (or a lone mask or channel) and the selection outline. +#[allow(clippy::too_many_arguments)] +fn transform_doc( + doc: &mut Document, + id: Option, + p: &Value, + lone: bool, + h: &Homography, + affine: Option, + interp: Interp, + ctl: &Interrupt, +) -> Result<()> { + let sel = doc.selection.clone(); + // The selection outline moves too: price it before anything is built. + if let Some(sel) = sel.as_ref().filter(|_| !h.is_identity()) { + check_budget(sel.content_bounds(), h, "selection")?; + } + if lone { + // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. 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); - } - // The selection outline moves with the pixels. + let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + let src = sel.as_ref().map_or_else(|| surf.content_bounds(), Surface::content_bounds); + check_budget(src, h, "mask or channel")?; + *surf = match &sel { + Some(sel) => warp_gray_selected(surf, sel, h, interp)?, + None => warp_gray(surf, h, interp)?, + }; if let Some(sel) = &doc.selection { - doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); } } - Ok(()) - })?; - Ok(json!({"rect": rect})) + 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))?; + transform_layer_with(if is_group { None } else { sel.as_ref() }, 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); + } + // The selection outline moves with the pixels. + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_gray(sel, h, Interp::Bilinear)?).filter(|s| !s.content_bounds().is_empty()); + } + } + Ok(()) } pub fn specs() -> Vec { diff --git a/crates/ui-egui/src/jobs_ui.rs b/crates/ui-egui/src/jobs_ui.rs index 5dc1cba41d..83218402d6 100644 --- a/crates/ui-egui/src/jobs_ui.rs +++ b/crates/ui-egui/src/jobs_ui.rs @@ -449,6 +449,8 @@ fn on_event(app: &mut PhotocraftApp, ctx: &egui::Context, e: JobEvent) { true } }); + // A Free Transform commit's session ends with its job (then reported like any job). + crate::transform_tool::on_job_event(app, &e); if crate::prefs_ui::on_recovery_event(app, &e) { return; } diff --git a/crates/ui-egui/src/jobs_ui_tests.rs b/crates/ui-egui/src/jobs_ui_tests.rs index bc29064512..3e2750f481 100644 --- a/crates/ui-egui/src/jobs_ui_tests.rs +++ b/crates/ui-egui/src/jobs_ui_tests.rs @@ -704,3 +704,65 @@ fn printing_keeps_drawing_and_reports_only_the_final_spooler_result() { } } } + +fn layer_count(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +/// Free Transform the active layer to the right half of the canvas, then ↩. +fn transform_and_commit(h: &mut Harness<'static, PhotocraftApp>, copy: bool) { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + if copy { crate::transform_tool::begin_copy(app, &ctx) } else { crate::transform_tool::begin(app, &ctx) }.unwrap(); + app.ui.transform.as_mut().unwrap().quad = [[160.0, 0.0], [320.0, 0.0], [320.0, 120.0], [160.0, 120.0]]; + crate::transform_tool::commit(app); +} + +#[test] +fn a_free_transform_commit_runs_in_the_background_and_keeps_its_preview() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + transform_and_commit(&mut h, true); + let app = h.state(); + // The warp runs as a job; the moving pixels stay on screen until it lands, and the box's + // history is closed meanwhile. + let job = crate::transform_tool::committing(app).expect("the commit runs as a job"); + assert!(app.session.job(job).is_some_and(|j| j.label == "Free Transform")); + assert!(app.ui.transform.is_some() && app.transform_preview.is_some()); + assert_eq!(crate::transform_tool::is_enabled(app, "edit.undo"), Some(false)); + assert!(h.state_mut().run("edit.undo", json!({})).is_err()); + crate::transform_tool::commit(h.state_mut()); + let t = Instant::now(); + while h.state().session.has_jobs() && t.elapsed() < Duration::from_secs(10) { + h.step(); + } + h.run_steps(2); + let app = h.state(); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none(), "the session ends with the job"); + // The copy and its transform are one step, as when the commit ran inline. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers + 1, steps + 1)); + assert_eq!(st.history.undo_label(), Some("Free Transform")); + let moved = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds(); + assert_eq!((moved.x0, moved.y0, moved.x1, moved.y1), (160, 0, 320, 120)); +} + +#[test] +fn esc_cancels_a_background_free_transform_commit() { + let mut h = app_harness(); + let (layers, steps) = (layer_count(h.state()), h.state().session.active().unwrap().history.past_len()); + let before = h.state().session.active().unwrap().doc.clone(); + transform_and_commit(&mut h, true); + let job = crate::transform_tool::committing(h.state()).expect("the commit runs as a job"); + // Esc reaches the transform tool first (shortcuts), which cancels the job. + crate::transform_tool::cancel(h.state_mut()); + h.run_steps(3); + let app = h.state(); + assert!(app.session.job(job).is_none()); + assert!(app.ui.transform.is_none() && app.transform_preview.is_none()); + // The copy is taken back with the transform, leaving nothing to redo. + let st = app.session.active().unwrap(); + assert_eq!((layer_count(app), st.history.past_len()), (layers, steps)); + assert!(!st.history.can_redo()); + assert_eq!(st.doc.layers.len(), before.layers.len()); +} diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 0af28a81c5..b2ff21d531 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -14,6 +14,7 @@ use std::sync::Arc; use egui::{Color32, CursorIcon, Pos2, Stroke, pos2, vec2}; use photocraft_algo::transform::Homography; use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use photocraft_engine::jobs::{JobEvent, JobId, JobOutcome}; use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; use serde_json::json; @@ -85,6 +86,9 @@ pub struct TransformPreview { /// A layer's session keeps `doc` and `opacity` up to date with edits made while the box is /// open ([`follow_edits`]); `None` for a lone mask or channel and Transform Selection. follow: Option, + /// The commit running as a background job (#1015): the moving pixels stay on screen, without + /// the box, until it lands; Esc cancels it. The session ends with the job. + committing: Option, /// Content-Aware Scale's live preview (cas_transform.rs). pub(crate) content_aware: Option, } @@ -229,6 +233,7 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> above: Some(above), styled: styled.then(|| crate::transform_fx::StyledPreview::new(format!("transform-{session}-styled"))), follow: Some(Follow { revision, hidden: lifted.is_none() }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -327,6 +332,7 @@ pub(crate) fn begin_content_aware( opacity: layer.opacity * layer.fill_opacity, above: Some(above), follow: Some(Follow { revision, hidden: false }), + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -385,13 +391,20 @@ fn take_back_made(app: &mut PhotocraftApp, document: DocId, layer: LayerId) { /// After the transform: the step that made the layer and the transform become one history step, /// the transform's for a copy and Place Embedded for a placed file. -fn fold_made(app: &mut PhotocraftApp, made: MadeLayer) { +fn fold_made(app: &mut PhotocraftApp, document: DocId, made: MadeLayer) { + // A background commit lands in its own document, which need not be the active one by then. + let Some(index) = app.session.documents().iter().position(|st| st.doc.id == document) else { return }; + let active = app.session.active_index(); + app.session.set_active(index); if let Some(st) = app.session.active_mut() { st.history.purge_last(); if made == MadeLayer::Place { st.history.set_current_label(photocraft_engine::file_cmds::PLACE_EMBEDDED); } } + if let Some(active) = active { + app.session.set_active(active); + } } /// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the @@ -440,6 +453,7 @@ fn begin_lone( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -501,6 +515,7 @@ fn begin_path(app: &mut PhotocraftApp, ctx: &egui::Context, params: serde_json:: content_aware: None, above: None, follow: None, + committing: None, }); app.ui.transform = Some(TransformSession { session, @@ -574,6 +589,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( above: None, styled: None, follow: None, + committing: None, gesture: None, warp_drag: None, split_tool: None, @@ -949,7 +965,13 @@ fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { l.id == id || matches!(&l.content, LayerContent::Group(g) if g.children.iter().any(|c| contains(c, id))) } +/// Undo and Redo while the commit runs in the background. +const STILL_APPLYING: &str = "Free Transform is still being applied"; + pub fn commit(app: &mut PhotocraftApp) { + if committing(app).is_some() { + return; + } let document = app.transform_preview.as_ref().map(|pv| pv.doc.id); if document.is_some_and(|id| app.session.active().is_none_or(|st| st.doc.id != id)) { cancel(app); @@ -957,7 +979,7 @@ pub fn commit(app: &mut PhotocraftApp) { } let type_layer = warps_type(app); let Some(t) = app.ui.transform.take() else { return }; - let preview = app.transform_preview.take(); + let mut preview = app.transform_preview.take(); if let Some(options) = &t.content_aware { crate::cas_transform::commit(app, &t, options); return; @@ -1012,16 +1034,54 @@ pub fn commit(app: &mut PhotocraftApp) { } app.run("edit.transform", p) }; + // A large layer's warp runs in the background: the session stays until the job ends. + if let Ok(v) = &r + && let Some(job) = v.get("pending").and_then(serde_json::Value::as_bool).filter(|p| *p).and(v.get("job")).and_then(serde_json::Value::as_u64) + && let Some(pv) = preview.as_mut() + { + pv.committing = Some(JobId(job)); + app.ui.transform = Some(t); + app.transform_preview = preview; + return; + } match r { - Ok(_) => { - if let Some(made) = made { - fold_made(app, made); - } - } + Ok(_) => finish(app, document, LayerId(t.layer), made, true), Err(e) => keep_open(app, t, preview, e), } } +/// After the transform landed or not: fold the layer the session made into its step, or take it +/// back. +fn finish(app: &mut PhotocraftApp, document: Option, layer: LayerId, made: Option, landed: bool) { + let (Some(made), Some(document)) = (made, document) else { return }; + if landed { + fold_made(app, document, made); + } else { + take_back_made(app, document, layer); + } +} + +/// The job applying the transform, while it runs. +pub(crate) fn committing(app: &PhotocraftApp) -> Option { + app.transform_preview.as_ref().and_then(|pv| pv.committing) +} + +/// A job ended (see [`crate::jobs_ui`]): when it was the commit, the session ends with it. Its +/// status and errors are reported like any job's. +pub fn on_job_event(app: &mut PhotocraftApp, e: &JobEvent) { + if committing(app) != Some(e.id) { + return; + } + let (Some(t), Some(mut pv)) = (app.ui.transform.take(), app.transform_preview.take()) else { return }; + // A refused commit (a result over the pixel budget, #3773) keeps the box open, as inline. + if let JobOutcome::Failed(msg) = &e.outcome { + pv.committing = None; + keep_open(app, t, Some(pv), msg.clone()); + return; + } + finish(app, Some(pv.doc.id), LayerId(t.layer), t.made, matches!(e.outcome, JobOutcome::Done(_))); +} + /// A commit the engine refused (a result over the pixel budget, #3773): the box stays open as it /// was, with the reason in the status bar, so the transform can be changed or cancelled. A copy or /// a placed layer it made goes only when it is cancelled. @@ -1038,6 +1098,9 @@ fn keep_open(app: &mut PhotocraftApp, t: TransformSession, preview: Option Option { let t = app.ui.transform.as_ref()?; + if committing(app).is_some() { + return box_command(id).then_some(false); + } let steps = app.transform_preview.as_ref().map(|pv| &pv.steps); match id { // Content-Aware Scale's box only scales: no switching it to another transform or Warp. @@ -1168,8 +1239,16 @@ fn box_preset(id: &str) -> Option<(f64, f64, f64)> { /// document under the box, is refused. Edit › Transform's Rotate and Flip turn the box itself, as /// in Photoshop, instead of the layer under it; committing stays with ↩ and the options bar. /// `None` for other commands or when not transforming. +/// The commands the open box answers itself ([`is_enabled`], [`intercept`]). +fn box_command(id: &str) -> bool { + matches!(id, "edit.undo" | "edit.redo" | "edit.toggleLastState" | "edit.contentAwareScale" | "edit.puppetWarp") || box_preset(id).is_some() +} + pub fn intercept(app: &mut PhotocraftApp, id: &str) -> Option> { let warping = app.ui.transform.as_ref()?.warp.is_some(); + if committing(app).is_some() { + return box_command(id).then(|| Err(STILL_APPLYING.into())); + } match id { "edit.undo" => Some(Ok(step(app, false))), "edit.redo" => Some(Ok(step(app, true))), @@ -1259,6 +1338,9 @@ fn accepts_outside_click(g: Gesture, end: [f64; 2], tol: f64) -> bool { /// Pointer input while transforming. Returns false when no transform is active. pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) -> bool { let Some(t) = app.ui.transform.clone() else { return false }; + if committing(app).is_some() { + return true; + } let tol = handle_tolerance(app); if t.warp.is_some() { warp_pointer(app, ev, tol, mods); @@ -1991,6 +2073,9 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform if let Some(a) = &pv.above { a.draw(painter, xf); } + if pv.committing.is_some() { + return; + } let accent = crate::theme::Tokens::get(painter.ctx()).accent; if let (Some(path), Some(h)) = (&pv.path, Homography::rect_to_quad(t.rect, t.quad)) { crate::vector_ui::draw_outline( From 4d4103460f2f606ebd10dc17cd9c6ce77d452164 Mon Sep 17 00:00:00 2001 From: Dan Hiorns Date: Mon, 12 Oct 2026 20:01:17 -0600 Subject: [PATCH 298/523] Toolbar: a press outside a tool flyout closes it (#4460) (#4480) An open tool flyout closed only on a click outside it. Dragging on the canvas (cropping, painting) isn't a click, so the flyout stayed open over the work. Any press that starts outside the flyout and its button now closes it, as in Photoshop. A press on the flyout itself still keeps it open for the row it picks on release, and Escape still closes it. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/ui-egui/src/panels.rs | 58 +++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 95bf65068f..6134274d4a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -264,10 +264,13 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); }, ); - let held_release = resp.clicked() && ui.data(|d| d.get_temp::(held_id)) == Some(key); - let clicked_outside = - ui.input(|i| i.pointer.any_click()) && !held_release && !area.response.hovered() && !resp.hovered(); - if clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + // Any press outside the flyout and its button closes it, as in Photoshop: a + // drag on the canvas (cropping, painting) isn't a click (#4460). + let pressed_outside = ui.input(|i| { + i.pointer.any_pressed() + && i.pointer.press_origin().is_some_and(|p| !area.response.rect.contains(p) && !resp.rect.contains(p)) + }); + if pressed_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.remove::<(egui::Id, Rect)>(flyout_id)); } } @@ -4827,6 +4830,53 @@ mod type_flyout_tests { } } + /// #4460: a press outside an open flyout closes it, even when it becomes a drag (cropping or + /// painting on the canvas) rather than a click; a press on the flyout keeps it for its pick. + #[test] + fn a_drag_outside_closes_the_tool_flyout() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let flyout = |ctx: &egui::Context| ctx.data(|d| d.get_temp::<(egui::Id, Rect)>(egui::Id::new("tool-flyout"))).is_some(); + let secondary = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + let primary = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let open = |app: &mut PhotocraftApp, t: f64| { + frame(app, &ctx, t, vec![egui::Event::PointerMoved(at), secondary(true)]); + frame(app, &ctx, t + 0.05, vec![secondary(false)]); + frame(app, &ctx, t + 0.1, vec![]); + assert!(flyout(&ctx), "right-click opens it"); + }; + // A press on the flyout itself keeps it open (the row picks on release). + open(&mut app, 1.0); + let menu = ctx.memory(|m| m.area_rect(egui::Id::new(("tool-slot", index)).with("flyout"))).unwrap(); + frame(&mut app, &ctx, 1.5, vec![egui::Event::PointerMoved(menu.center()), primary(menu.center(), true)]); + assert!(flyout(&ctx), "a press on the flyout keeps it"); + frame(&mut app, &ctx, 1.55, vec![primary(menu.center(), false)]); + // A drag far from the toolbar (the canvas) closes it as soon as it starts. + open(&mut app, 2.0); + let canvas = egui::pos2(900.0, 900.0); + frame(&mut app, &ctx, 2.5, vec![egui::Event::PointerMoved(canvas), primary(canvas, true)]); + frame(&mut app, &ctx, 2.55, vec![egui::Event::PointerMoved(canvas + vec2(80.0, 60.0))]); + assert!(!flyout(&ctx), "a press outside closes it, even one that becomes a drag"); + frame(&mut app, &ctx, 2.6, vec![primary(canvas + vec2(80.0, 60.0), false)]); + assert!(!flyout(&ctx)); + } + #[test] fn red_eye_is_in_the_j_flyout() { let j = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::SpotHealing)).expect("J group"); From cee010ef5a60e392dddc611e7312c35871b36df5 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:03:15 +0200 Subject: [PATCH 299/523] Replace Color: a canvas eyedropper picks the From colour (#4143) (#4247) The dialog had no colour control at all: the engine defaulted the From colour to the foreground and the spec's `color` param rendered nothing. It now shows a Color row (swatch opens the Color Picker, seeded from the foreground) plus a pipette that arms a canvas eyedropper: the next click on the image samples the merged composite into the field, like the Curves picker and Photoshop's Replace Color. Co-authored-by: highperfocused Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/adjust_cmds.rs | 2 +- crates/ui-egui/src/canvas.rs | 9 ++- crates/ui-egui/src/filter_dialog.rs | 90 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + 6 files changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/adjust_cmds.rs b/crates/engine/src/adjust_cmds.rs index 15c3af2060..738ef921f4 100644 --- a/crates/engine/src/adjust_cmds.rs +++ b/crates/engine/src/adjust_cmds.rs @@ -454,7 +454,7 @@ pub fn specs() -> Vec { "image.adjustments.replaceColor", "Replace Color…", ["Image", "Adjustments"], - r##"{"color":json,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, + r##"{"color":color,"fuzziness":0..200=40,"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0} (color: "#rrggbb", default the foreground colour)"##, has_pixels, replace_color ), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d4a1cbb58b..4f78b853f2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3022,10 +3022,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: if under_dialog { // With a colour dialog picker armed the image is its eyedropper, whatever the tool; Space - // and the middle button still pan. Curves uses the same merged-composite sampler as the - // Color Picker, never the reduced adjustment preview. + // and the middle button still pan. Curves and Replace Color use the same merged-composite + // sampler as the Color Picker, never the reduced adjustment preview. let curves_picking = crate::adjust_dialog::picker_armed(app); - let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking); + let replace_picking = crate::filter_dialog::color_eyedropper_armed(app); + let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking || replace_picking); let range_picking = primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling); // Select and Mask's Refine Edge Brush paints on the image. @@ -3049,6 +3050,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let d = xf.to_doc(p); if curves_picking { crate::adjust_dialog::sample_at(app, d[0], d[1]); + } else if replace_picking { + crate::filter_dialog::color_eyedropper_sample(app, d[0], d[1]); } else { crate::color_picker_ui::sample_at(app, d[0], d[1]); } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 8f588fbc99..31ef157110 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -343,6 +343,11 @@ fn default_value(app: &PhotocraftApp, command: &str, p: &Param) -> Option // Identity kernel: 1 in the centre. Kind::Grid(n) => json!((0..*n).map(|i| i64::from(i == *n / 2)).collect::>()), Kind::Color(Some(rgb)) => json!(crate::color_picker_ui::hex(*rgb)), + // Replace Color starts from the foreground colour and can sample a new one off the canvas. + Kind::Color(None) if command == REPLACE_COLOR => { + let fg = app.session.tools.foreground; + json!(crate::color_picker_ui::hex([fg[0], fg[1], fg[2]])) + } Kind::Color(None) => return None, // Colour inputs come from the current swatches, so the proxy preview matches the result. Kind::Json if p.key == "foreground" => json!(app.session.tools.foreground), @@ -542,6 +547,38 @@ fn uses_angle_dial(command: &str, key: &str) -> bool { key == "angle" && ANGLE_DIAL.contains(&command) } +/// Replace Color (#4143): the dialog's "From" colour, sampled from the image with a canvas +/// eyedropper like Photoshop's. While armed, the next click on the image writes the merged +/// composite colour into the `color` field (the same sampler the Color Picker uses). +const REPLACE_COLOR: &str = "image.adjustments.replaceColor"; +const COLOR_EYEDROPPER: &str = "__colorEyedropper"; + +fn color_eyedropper_owns(f: &Map) -> bool { + f.get("__filter").and_then(Value::as_bool) == Some(true) + && f.get("__command").and_then(Value::as_str) == Some(REPLACE_COLOR) + && f.get(COLOR_EYEDROPPER).and_then(Value::as_bool) == Some(true) +} + +/// Whether the top dialog is a Replace Color with its canvas eyedropper armed. +pub(crate) fn color_eyedropper_armed(app: &PhotocraftApp) -> bool { + app.ui.dialogs.last().is_some_and(|d| color_eyedropper_owns(&d.fields)) +} + +/// Sample the merged image at document point (x, y) into the armed dialog's "From" colour. +/// Returns true when the click was consumed, even off-image or over transparency, where nothing +/// changes and the eyedropper stays armed (like the Curves picker). +pub(crate) fn color_eyedropper_sample(app: &mut PhotocraftApp, x: f64, y: f64) -> bool { + let Some(id) = app.ui.dialogs.last().filter(|d| color_eyedropper_owns(&d.fields)).map(|d| d.id) else { + return false; + }; + if let Some(rgb) = crate::canvas::composite_color(app, x, y) + && let Some(d) = app.ui.dialog_mut(id) + { + d.fields.insert("color".into(), json!(crate::color_picker_ui::hex(rgb))); + } + true +} + /// Dialog body for filter commands. pub fn body(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); @@ -684,11 +721,18 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map) { let bytes = rgb.map(q); let name = label(&p.key); let mut clicked = false; + let eyedropper = cmd == REPLACE_COLOR && p.key == "color"; ui.horizontal(|ui| { ui.label(egui::RichText::new(&name).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // PhotoCraft's Color Picker, as everywhere else (#2144). clicked = crate::widgets::color_swatch_button(ui, egui::Color32::from_rgb(bytes[0], bytes[1], bytes[2]), &name).clicked(); + if eyedropper { + let armed = f.get(COLOR_EYEDROPPER).and_then(Value::as_bool).unwrap_or(false); + if crate::icons::button(ui, "pipette", 24.0, armed, "Pick Color From Image").clicked() { + f.insert(COLOR_EYEDROPPER.into(), json!(!armed)); + } + } ui.label(egui::RichText::new(crate::color_picker_ui::hex(rgb)).color(t.text_dim).monospace()); }); }); @@ -1911,4 +1955,50 @@ mod tests { let fields = &app.ui.dialogs.last().unwrap().fields; assert_eq!(fields["direction"], json!("cw"), "Explicit default matching first option is preserved"); } + + /// #4143: Replace Color shows the From colour (defaulting to the foreground) and its canvas + /// eyedropper samples the merged image into the dialog. + #[test] + fn replace_color_eyedropper_samples_the_image() { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16, "background": "#00ff00"})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + app.run("tools.setColors", json!({"foreground": "#ff0000"})).unwrap(); + let id = open(&mut app, REPLACE_COLOR).unwrap(); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#ff0000"), "the dialog starts from the foreground colour"); + assert!(!color_eyedropper_armed(&app)); + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(true)); + assert!(color_eyedropper_armed(&app)); + assert!(color_eyedropper_sample(&mut app, 4.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + // Off-image: the click is consumed, nothing changes and the eyedropper stays armed. + assert!(color_eyedropper_sample(&mut app, -1.0, 4.0)); + assert_eq!(app.ui.dialogs.last().unwrap().fields["color"], json!("#00ff00")); + assert!(color_eyedropper_armed(&app)); + // Disarmed, the dialog never claims the click. + app.ui.dialog_mut(id).unwrap().fields.insert(COLOR_EYEDROPPER.into(), json!(false)); + assert!(!color_eyedropper_armed(&app)); + assert!(!color_eyedropper_sample(&mut app, 4.0, 4.0)); + } + + /// #4143: the eyedropper button arms and disarms the canvas picker. + #[test] + fn replace_color_dialog_arms_the_eyedropper_button() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let mut app = PhotocraftApp::new(s, crate::Services::default()); + let mut h = Harness::builder().with_size(egui::vec2(900.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), &mut app); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + open(h.state_mut(), REPLACE_COLOR).unwrap(); + h.run_steps(3); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(color_eyedropper_armed(h.state())); + h.get_by_label("Pick Color From Image").click(); + h.run_steps(2); + assert!(!color_eyedropper_armed(h.state())); + } } diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0775780a4f..9476ed77a2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3396,6 +3396,7 @@ cameraRaw Yellow Sárga Photomerge Fotóegyesítés Pick a screen pixel or press Esc to cancel Válassz egy pixelt a képernyőn, vagy nyomd meg az Esc billentyűt a megszakításhoz Pick screen color Szín kiválasztása a képernyőről + Pick Color From Image Szín felvétele a képről Pins Tűk Pixel Layer Pixelréteg Place Scale Marker Méretarány-jelölő elhelyezése diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c67ccd0138..c34e673367 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2316,6 +2316,7 @@ Color Picker (Stroke Color) Renk Seçici (Kontur Rengi) Stroke color Kontur rengi Pick screen color Ekran rengini seç + Pick Color From Image Görüntüden Renk Seç Sample a pixel anywhere on the screen Ekranın herhangi bir yerinde bir pikseli örnekleyin Click to pick · Esc to cancel Seçmek için tıklayın · İptal etmek için Esc Pick a screen pixel or press Esc to cancel Bir ekran pikseli seçin veya iptal etmek için Esc tuşuna basın diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5157c3d46d..9fc122a766 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2033,6 +2033,7 @@ layers Fill: Độ tô: Option-click to define a source point to clone from Option+nhấp để chỉ định điểm nguồn nhân bản Out of gamut for printing: click for the nearest printable colour Ngoài gamut in: nhấp để lấy màu in được gần nhất Pick screen color Lấy màu màn hình + Pick Color From Image Lấy màu từ hình ảnh Point Sample Mẫu điểm Pupil Size Kích thước đồng tử Quick Selection found nothing there Chọn nhanh không tìm thấy gì From 575f8eb2bc54eeeeba27c4c5349f2b7002deb68b Mon Sep 17 00:00:00 2001 From: Jannik Wappler <167129173+RavoxX@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:16:06 +0300 Subject: [PATCH 300/523] Allow toolbar tool shortcuts to be remapped (#4194) Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 8 ++ crates/ui-egui/src/panels.rs | 10 +- crates/ui-egui/src/prefs_ui.rs | 8 ++ crates/ui-egui/src/shortcut_capture_tests.rs | 88 ++++++++++++++++++ crates/ui-egui/src/shortcut_dispatch.rs | 1 + crates/ui-egui/src/shortcuts.rs | 8 +- crates/ui-egui/src/tool_shortcuts.rs | 96 ++++++++++++++++++++ docs/gaps.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/tool_shortcuts.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4a05a57282..873437b260 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -199,6 +199,7 @@ mod tool_cursor; pub mod tool_feedback; pub(crate) mod tool_memory; mod tool_reset; +mod tool_shortcuts; pub mod transform_fx; pub mod transform_tex; pub mod transform_tool; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 161c64c0c2..71fab42239 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -161,6 +161,10 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Va /// [`invoke`] without the unsaved-changes prompt, for once the user has already answered it. pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { + if let Some(tool) = crate::tool_shortcuts::tool(id) { + app.ui.tool = tool; + return Ok(json!({"tool": tool})); + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::begin(app).map(|_| Value::Null); } @@ -660,6 +664,9 @@ fn open_path(app: &mut PhotocraftApp, path: &str) -> Result { } pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool { + if crate::tool_shortcuts::tool(id).is_some() { + return true; + } if id == "ui.symmetryTransform" { return crate::symmetry_ui::can_transform(app); } @@ -890,6 +897,7 @@ pub struct MenuItem { pub fn is_live(id: &str) -> bool { static UI_IDS: std::sync::OnceLock> = std::sync::OnceLock::new(); photocraft_engine::commands::find(id).is_some() + || crate::tool_shortcuts::tool(id).is_some() || UI_IDS.get_or_init(|| UI_COMMANDS.iter().map(|c| c.0).collect()).contains(id) || panel_alias(id).is_some() || workspace_name(id).is_some() diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6134274d4a..78f1715c98 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -157,8 +157,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let key = egui::Id::new(("tool-slot", *slot_index)); let tool = slot_tool(ui, app.ui.tool, slot, key); let sel = slot.contains(&app.ui.tool); - let tip = - if tool.key() == '\0' { tl!(tool.label()).to_string() } else { format!("{} ({})", tl!(tool.label()), tool.key()) }; + let tip = match crate::tool_shortcuts::shortcut(app, tool) { + Some(sc) => format!("{} ({})", tl!(tool.label()), crate::shortcuts::pretty(&sc)), + None => tl!(tool.label()).to_string(), + }; let resp = icons::button_with_icon_size_tip(ui, icons::tool_icon(tool), bx, toolbar_icon_size(bx, t.pro), sel, &tip, tool_tips); if slot.len() > 1 { @@ -247,11 +249,11 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { egui::FontId::proportional(12.5), t.text, ); - if item.key() != '\0' { + if let Some(sc) = crate::tool_shortcuts::shortcut(app, item) { ui.painter().text( pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, - item.key().to_string(), + crate::shortcuts::pretty(&sc), egui::FontId::proportional(12.0), t.text_dim, ); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 202944b58a..8260fed722 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -765,6 +765,11 @@ pub fn shortcut_items(app: &PhotocraftApp) -> Vec<(String, String, Vec, let at = out.iter().rposition(|i| i.2.first().map(String::as_str) == Some("Layer")).map_or(out.len(), |i| i + 1); out.splice(at..at, layer); out.extend(tools); + out.extend( + crate::state::Tool::ALL + .into_iter() + .map(|tool| (crate::tool_shortcuts::id(tool), tool.label().to_string(), vec!["Tools".into()], crate::tool_shortcuts::default(tool))), + ); for (id, label, def) in prefs::TEMPORARY_TOOLS { if seen.insert(id.to_string()) { out.push((id.to_string(), label.to_string(), vec!["Tools".into(), "Temporary".into()], Some(def.to_string()))); @@ -1670,6 +1675,9 @@ fn shortcuts_body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map, id: u64, name: &str) -> Value { h.state().ui.dialogs.iter().find(|d| d.id == id).unwrap().fields.get(name).cloned().unwrap_or(Value::Null) } +#[test] +fn toolbar_shortcut_can_be_captured_and_restored_on_restart() { + let (mut h, id) = dialog(1.0, "tools.select.pencil", "Pencil Tool"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "B").click(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(field(&h, id, "overrides")["tools.select.pencil"], json!("F6")); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + h.key_press(Key::F6); + h.run_steps(2); + assert_eq!(h.state().ui.tool, crate::state::Tool::Pencil); + + let saved = h.state().session.prefs_to_json(); + let mut session = photocraft_engine::Session::new(); + session.load_prefs_json(&saved).unwrap(); + let mut restarted = PhotocraftApp::new(session, crate::Services::default()); + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + events: vec![Event::Key { key: Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], + ..Default::default() + }); + crate::shortcuts::handle(&mut restarted, &ctx); + ctx.end_pass().textures_delta.clear(); + assert_eq!(restarted.ui.tool, crate::state::Tool::Pencil); +} + +#[test] +fn toolbar_shortcut_overrides_remove_old_keys_and_skip_removed_group_members() { + use crate::state::Tool; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let press = |app: &mut PhotocraftApp, key, modifiers| { + ctx.begin_pass(egui::RawInput { + // A tap: press and release, so the next press isn't a held key's repeat. + events: vec![ + Event::ModifiersChanged(modifiers), + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + ..Default::default() + }); + crate::shortcuts::handle(app, &ctx); + ctx.end_pass().textures_delta.clear(); + }; + app.run("edit.keyboardShortcuts", json!({"set": {"tools.select.move": "F6", "tools.select.pencil": ""}, "allowUnknown": true})).unwrap(); + app.ui.tool = Tool::Zoom; + press(&mut app, Key::V, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Artboard, "the old key only selects unchanged tools"); + press(&mut app, Key::F6, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Move); + + app.ui.tool = Tool::Pencil; + press(&mut app, Key::V, Modifiers::NONE); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::Brush, "a removed tool cannot return through group memory"); + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::MixerBrush, "Shift cycling skips a removed tool"); + + app.run("edit.keyboardShortcuts", json!({"reset": true})).unwrap(); + app.ui.tool = Tool::Brush; + press(&mut app, Key::B, Modifiers::SHIFT); + assert_eq!(app.ui.tool, Tool::Pencil, "reset restores the default group"); + app.session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = false); + press(&mut app, Key::B, Modifiers::NONE); + assert_eq!(app.ui.tool, Tool::MixerBrush, "plain-key cycling still follows the preference"); +} + +#[test] +fn toolbar_shortcuts_participate_in_conflict_resolution() { + let (mut h, id) = dialog(1.0, "edit.search", "search…"); + capture(&mut h, &crate::shortcuts::pretty("Cmd+F"), Key::ControlLeft, Modifiers::COMMAND, Key::B); + // Remove Ctrl from the captured binding: a plain B belongs to the Brush group by default. + h.state_mut().ui.dialog_mut(id).unwrap().fields["overrides"]["edit.search"] = json!("B"); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + for tool in ["brush", "pencil", "mixerBrush", "colorReplacement"] { + assert_eq!(h.state().session.prefs().shortcuts.get(&format!("tools.select.{tool}")).map(String::as_str), Some("")); + } + let ctx = h.ctx.clone(); + h.state_mut().ui.tool = crate::state::Tool::Move; + key(&mut h, Key::B, true, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().ui.palette_open, "B now runs Search"); + assert_eq!(h.state().ui.tool, crate::state::Tool::Move); + assert_eq!(crate::shortcut_dispatch::take_log(&ctx).last().map(|i| i.0.as_str()), Some("edit.search")); +} + /// Click the selected command's shortcut button, then press `modifier` and `k` like a keyboard: /// the modifier key goes down first (its own key event), then the key with the modifier held. fn capture(h: &mut Harness<'_, PhotocraftApp>, button: &str, modifier: Key, held: Modifiers, k: Key) { diff --git a/crates/ui-egui/src/shortcut_dispatch.rs b/crates/ui-egui/src/shortcut_dispatch.rs index 17607c479f..1a6980077d 100644 --- a/crates/ui-egui/src/shortcut_dispatch.rs +++ b/crates/ui-egui/src/shortcut_dispatch.rs @@ -302,6 +302,7 @@ fn label(id: &str) -> String { .map(|c| c.1) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) .or_else(|| photocraft_engine::commands::find(id).map(|c| c.label)) + .or_else(|| crate::tool_shortcuts::tool(id).map(|tool| tool.label())) .unwrap_or(id) .trim_end_matches('…') .to_string() diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6e592385a1..c8528e1aa5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -108,6 +108,7 @@ pub fn default_shortcut(id: &str) -> Option { .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).and_then(|c| c.2)) .or_else(|| photocraft_engine::prefs::TEMPORARY_TOOLS.iter().find(|t| t.0 == id).map(|t| t.2)) .map(str::to_string) + .or_else(|| crate::tool_shortcuts::tool(id).and_then(crate::tool_shortcuts::default)) } /// With ⇧ held the OS reports the shifted character as the logical key (US layout): ⇧; is `:`. @@ -580,15 +581,18 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key, pressed: true, repeat: true, .. } if crate::spring_tools::is_tool_key(*key)))); let shift_switch = app.session.prefs().tools.use_shift_key_for_tool_switch; for t in Tool::ALL { + if !crate::tool_shortcuts::uses_default(app, t) { + continue; + } let Some(k) = crate::spring_tools::key_of(t.key()) else { continue }; let cycle_shift = shift_switch && ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, k)); if cycle_shift || pressed(k) { let before = app.ui.tool; - let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key()).collect(); + let group: Vec = Tool::ALL.iter().copied().filter(|x| x.key() == t.key() && crate::tool_shortcuts::uses_default(app, *x)).collect(); app.ui.tool = match group.iter().position(|x| *x == app.ui.tool) { Some(i) if shift_switch && !cycle_shift => group[i], Some(i) => group[(i + 1) % group.len()], - None => app.ui.group_tool(t.key()).unwrap_or(group[0]), + None => app.ui.group_tool(t.key()).filter(|last| group.contains(last)).unwrap_or(group[0]), }; // `consume_key` ignores extra ⇧ and ⌥: a ⇧/⌥ switch is never spring-loaded. let (plain, now) = ctx.input(|i| (!cycle_shift && !i.modifiers.shift && !i.modifiers.alt, i.time)); diff --git a/crates/ui-egui/src/tool_shortcuts.rs b/crates/ui-egui/src/tool_shortcuts.rs new file mode 100644 index 0000000000..345b7f57b3 --- /dev/null +++ b/crates/ui-egui/src/tool_shortcuts.rs @@ -0,0 +1,96 @@ +//! Toolbar tools in Edit › Keyboard Shortcuts (#4086). Explicit assignments select an +//! individual tool; unchanged tools keep Photoshop's shared keys and Shift cycling. + +use crate::{PhotocraftApp, state::Tool}; + +pub(crate) fn id(tool: Tool) -> String { + let mut name = format!("{tool:?}"); + if let Some(first) = name.get_mut(..1) { + first.make_ascii_lowercase(); + } + format!("tools.select.{name}") +} + +pub(crate) fn tool(id: &str) -> Option { + let name = id.strip_prefix("tools.select.")?; + let tool = Tool::from_name(name)?; + (id == self::id(tool)).then_some(tool) +} + +pub(crate) fn default(tool: Tool) -> Option { + (tool.key() != '\0').then(|| tool.key().to_string()) +} + +pub(crate) fn shortcut(app: &PhotocraftApp, tool: Tool) -> Option { + crate::shortcuts::effective_shortcut(app, &id(tool), default(tool).as_deref()) +} + +pub(crate) fn uses_default(app: &PhotocraftApp, tool: Tool) -> bool { + !app.session.prefs().shortcuts.contains_key(&id(tool)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn every_toolbar_tool_has_a_unique_live_shortcut_row() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let items = crate::prefs_ui::shortcut_items(&app); + let mut ids = std::collections::HashSet::new(); + for t in Tool::ALL { + let id = id(t); + assert!(ids.insert(id.clone())); + assert_eq!(tool(&id), Some(t)); + assert!(crate::menus::is_live(&id)); + assert!(crate::menus::is_enabled(&app, &id)); + let rows: Vec<_> = items.iter().filter(|i| i.0 == id).collect(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].1, t.label()); + assert_eq!(rows[0].2, ["Tools"]); + assert_eq!(rows[0].3, default(t)); + assert_eq!(crate::shortcuts::default_shortcut(&id), default(t)); + crate::menus::invoke(&mut app, &ctx, &id, json!({})).unwrap(); + assert_eq!(app.ui.tool, t); + } + for id in ["tools.select.", "tools.select.missing", "tools.select.Brush"] { + assert!(!crate::menus::is_live(id)); + assert!(crate::menus::invoke(&mut app, &ctx, id, json!({})).is_err()); + } + } + + #[test] + fn toolbar_hints_follow_assignments_removal_and_reset() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + assert_eq!(shortcut(&app, Tool::SingleRowMarquee), None); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "F6"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("F6")); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": ""})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil), None); + app.run("prefs.reset", json!({"path": "shortcuts.tools.select.pencil"})).unwrap(); + assert_eq!(shortcut(&app, Tool::Pencil).as_deref(), Some("B")); + } + + #[test] + fn a_remapped_tool_letter_does_not_steal_text_input() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("prefs.set", json!({"path": "shortcuts.tools.select.pencil", "value": "Q"})).unwrap(); + let mut h = egui_kittest::Harness::builder().build_ui_state( + |ui, app: &mut PhotocraftApp| { + ui.text_edit_singleline(&mut app.ui.status).request_focus(); + crate::shortcuts::handle(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + assert!(h.ctx.text_edit_focused()); + h.key_press(egui::Key::Q); + h.event(egui::Event::Text("q".into())); + h.run_steps(2); + assert_eq!(h.state().ui.tool, Tool::Brush); + assert_eq!(h.state().ui.status, "q"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 8e75167810..70b2dde405 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -170,6 +170,8 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), + with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't float or tear off into OS windows (UI-217-5, #1828, #1213, #2305); Color panel lacks wheel and slider modes (UI-217-17, #2569); more themes (UI-217-11, #2661); layer clipboard @@ -240,6 +242,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | G9: toolbar tool shortcut editing (#4086); the remaining workspace gaps stay open | | 2026-10-12 | minor | G1: #2465 (CS2 `PlLd`-only smart objects) marked fixed | | 2026-10-12 | minor | G5: spring-loaded tool letters done (#2672) | | 2026-10-12 | minor | G10: DPX read and write (#1049); Cineon remains open | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d48af3bfcd..8c0e0d2580 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -183,6 +183,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Shortcut audit | 214 → 0 failures (2026-10-07) | measured then; not re-run here | | `.kys` import | done (`engine/src/kys.rs`) | | | Shortcut glyphs per platform | done (UI-217-19) | | +| Toolbar tool shortcut editing | done (#4086): each tool has a row under Tools; assignments persist, removed tools leave the default key group, and unchanged tools keep Shift cycling. Toolbar and flyout hints follow the binding | `tool_shortcuts.rs`, `shortcut_capture_tests.rs` | | Brush size/hardness keys on non-US layouts | done (#3109): `[` `]` need AltGr on many layouts, so Photoshop uses other keys there (German: Ö / #, ⇧ for hardness). `layout_keys.rs` has a data table of such layouts (`LAYOUTS`, add rows to support one more) and a fallback for all others: the keys at the US `[` / `]` positions act as `[` / `]` when they don't type brackets. Off once a real bracket is typed; Edit › Keyboard Shortcuts overrides still apply | | | Layer and selection keys: ⌘, toggles visibility, ⌘\ targets the mask, / locks transparency, ⌘⌥2 loads luminosity, ⌘⌥⌫ fills from history, Tab / ⇧Tab rename the next layer, ⌥-click a thumbnail fits the layer | done (2026-10-11) | | | Tool keys: ← / → turn the brush tip (1°, ⇧ 15°), ⇧-click with Type starts new text over old, Space+F / ⇧Space+F cycle the pasteboard colour | done (2026-10-11) | | @@ -224,3 +225,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Spring-loaded tool letters done (#2672): holding any tool letter (Z included) past 300 ms, or using the canvas while it is down, makes the tool temporary until release | | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | +| 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | From f80e57fb8a2e0c24b7a65f4b3787399701d999fc Mon Sep 17 00:00:00 2001 From: Minischatten <61423557+Minischatten@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:16:46 +0200 Subject: [PATCH 301/523] Fix macOS system dialog localization (#4261) * Fix macOS system dialog localization * Info.plist: declare the Hungarian and Turkish localizations too Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- packaging/macos/Info.plist.in | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 5dec67ee2e..3a190a4b17 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -6,6 +6,31 @@ CFBundleDevelopmentRegion en + + CFBundleLocalizations + + en + ja + zh-Hans + zh-Hant + es + ru + uk + cs + fr + id + ko + pl + de + pt-BR + el + nl + it + hu + tr + vi + CFBundleDisplayName PhotoCraft CFBundleName From ee8aca562b4bd4af7afd42d19c5c48c7e1f71573 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:23:46 +0200 Subject: [PATCH 302/523] Engine: reject unknown params for layer.hideLayers/showLayers via an opt-in registry check (#4336) (#4364) Co-authored-by: Claude Opus 5.5 --- crates/engine/src/jobs.rs | 1 + crates/engine/src/lib.rs | 1 + crates/engine/src/param_check.rs | 138 +++++++++++++++++++++++++++++++ docs/control-protocol.md | 1 + 4 files changed, 141 insertions(+) create mode 100644 crates/engine/src/param_check.rs diff --git a/crates/engine/src/jobs.rs b/crates/engine/src/jobs.rs index 42c7fa4862..9c8cb9cdd0 100644 --- a/crates/engine/src/jobs.rs +++ b/crates/engine/src/jobs.rs @@ -632,6 +632,7 @@ impl Session { if id == "file.new" { crate::document_preset_cmds::validate_new_params(self, ¶ms)?; } + crate::param_check::check(spec, ¶ms)?; crate::filters::validate_params(id, ¶ms)?; // A floating selection drops before any other command (Undo puts it back instead). if let Some(v) = crate::float_cmds::before_command(self, id)? { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1cc8eb692b..f1bd9bd16e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -76,6 +76,7 @@ pub mod multichannel_cmds; pub mod new_layer; pub mod notes_cmds; pub mod paint_cmds; +mod param_check; mod path_edit_cmds; pub mod pattern_cmds; pub mod pattern_fill_cmds; diff --git a/crates/engine/src/param_check.rs b/crates/engine/src/param_check.rs new file mode 100644 index 0000000000..a59236d1d1 --- /dev/null +++ b/crates/engine/src/param_check.rs @@ -0,0 +1,138 @@ +//! Reject params a command does not document (#4336). +//! +//! Commands read the keys they know from a JSON object and used to ignore the rest, so a +//! misspelled key (`{"ids":[4]}` for `{"layer":4}`) ran the command on its defaults and reported +//! success. For the commands in [`STRICT`] the dispatcher now checks the keys against the +//! command's `params` doc once, for every transport (CLI, MCP, control channel, UI, actions). +//! +//! This is opt-in because `params` docs are free-form text and many are not complete yet (the +//! engine's own tests pass undocumented keys to `paint.pencil`, `timeline.create`, +//! `layer.new.artboard`, the Selective Color and Color Lookup adjustments and more). To opt a +//! command in: make sure its `params` doc names every key the command reads as `"key":...`, and +//! add its id to [`STRICT`]; `strict_commands_have_closed_docs` checks the doc is parseable. +//! A doc with a top-level `…` is open-ended and is never checked. + +use serde_json::Value; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result}; + +/// Keys any command accepts: the history-coalescing key every command honours (see +/// `Session::execute`) and the Channels-panel `"target"` that pixel commands follow. +const UNIVERSAL: &[&str] = &["coalesce", "target"]; + +/// The commands whose unknown params are rejected: their `params` doc is complete. +const STRICT: &[&str] = &["layer.hideLayers", "layer.showLayers"]; + +/// The keys a `params` doc names, or `None` when the doc is open-ended. +fn documented_keys(doc: &str) -> Option> { + let bytes = doc.as_bytes(); + let (mut depth, mut in_str, mut start) = (0usize, false, 0usize); + let mut quoted: Vec<(usize, usize)> = Vec::new(); + for (i, &b) in bytes.iter().enumerate() { + if in_str { + if b == b'"' { + in_str = false; + quoted.push((start, i)); + } + continue; + } + match b { + b'"' => { + in_str = true; + start = i + 1; + } + b'{' | b'[' => depth += 1, + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + // `…` / `...` straight inside the top-level braces: the list is not complete. + if depth == 1 + && (doc.get(i..).is_some_and(|r| r.starts_with('…') || r.starts_with("..."))) + && doc.get(..i).is_some_and(|l| l.trim_start().starts_with('{')) + { + return None; + } + } + let mut keys = Vec::new(); + for (n, &(a, b)) in quoted.iter().enumerate() { + let Some(word) = doc.get(a..b) else { continue }; + if word.is_empty() || !word.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') { + continue; + } + // A key is followed by `:`, or by `,"other"` pairs that end in one (`"all","pixels":bool`). + let mut end = b + 1; + let mut k = n; + loop { + let rest = doc.get(end..).unwrap_or(""); + if rest.starts_with(':') { + keys.push(word); + break; + } + let Some(after) = rest.strip_prefix(',') else { break }; + let Some(&(na, nb)) = quoted.get(k + 1) else { break }; + if na != end + 2 || after.as_bytes().first() != Some(&b'"') { + break; + } + end = nb + 1; + k += 1; + } + } + Some(keys) +} + +/// Err when `params` has a key the command does not document (only for commands in [`STRICT`]). +pub(crate) fn check(spec: &CommandSpec, params: &Value) -> Result<()> { + if !STRICT.contains(&spec.id) { + return Ok(()); + } + let Some(map) = params.as_object() else { return Ok(()) }; + let Some(keys) = documented_keys(spec.params) else { return Ok(()) }; + for key in map.keys() { + if UNIVERSAL.contains(&key.as_str()) || keys.contains(&key.as_str()) { + continue; + } + let expected = if keys.is_empty() { "no parameters".to_string() } else { format!("expected: {}", keys.join(", ")) }; + return Err(EngineError::BadParams { cmd: spec.id.to_string(), msg: format!("unknown parameter `{key}` ({expected})") }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn documented_keys_reads_keys_and_stops_at_open_docs() { + assert_eq!(documented_keys(r##"{"layer":id?} (shows them instead)"##), Some(vec!["layer"])); + assert_eq!(documented_keys(r##"{"locks":{"all","pixels":bool}?,"name":str?}"##), Some(vec!["locks", "all", "pixels", "name"])); + assert_eq!(documented_keys("{}"), Some(vec![])); + assert_eq!(documented_keys(r##"{"layer":id?, …params of that kind}"##), None); + } + + #[test] + fn strict_commands_have_closed_docs() { + for id in STRICT { + let spec = crate::commands::find(id).expect("strict command exists"); + assert!(documented_keys(spec.params).is_some_and(|k| !k.is_empty()), "{id}: params doc must list its keys"); + } + } + + #[test] + fn unknown_param_is_an_error_naming_the_key_and_the_accepted_ones() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + let a = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let visible = |s: &Session| s.active().unwrap().doc.layer(photocraft_doc::LayerId(a)).unwrap().visible; + for cmd in ["layer.hideLayers", "layer.showLayers"] { + let err = s.execute(cmd, json!({"ids": [a]})).unwrap_err().to_string(); + assert!(err.contains("unknown parameter `ids`") && err.contains("expected: layer"), "{err}"); + assert!(visible(&s), "{cmd}: nothing ran"); + } + s.execute("layer.hideLayers", json!({"layer": a, "coalesce": "k"})).unwrap(); + assert!(!visible(&s)); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 166a61210c..e571e94f8b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -75,6 +75,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `ui.click {x, y, button?, count?}` / `ui.move {x, y}`: synthetic pointer input in screen points (`button`: `left` (default), `right` (or `secondary`) or `middle`). `count` (default 1, `2` for a double-click) is at most 256; a larger one is an error and queues nothing - `ui.key {key, command?, shift?, alt?, ctrl?}` (flags may also be grouped under `modifiers`): press and release a key, e.g. `{"key": "ArrowLeft", "shift": true}` - `ui.pointer`, `ui.click`, `ui.move`, `ui.key` and `ui.screenshot` validate every parameter before acting, as `ui.set` does: an unknown field (top-level, under `modifiers` or in an event), a value of the wrong JSON type (`"x": "abc"`, `"ctrl": "yes"`), a `button`, `kind`, `tool` or `key` outside the accepted set, or a number outside its range (`pressure`, tilt, `rotation`, `count`) is an error naming the parameter, and nothing is queued or applied. A `null` field counts as omitted and takes its default +- Engine commands (`engine.execute`, `photocraft-cli run`, MCP `command_run`) reject a param key their `params` doc does not name for the commands listed in `STRICT` (`crates/engine/src/param_check.rs`, today `layer.hideLayers` and `layer.showLayers`): `{"ids":[4]}` is the error `unknown parameter \`ids\` (expected: layer)`, and nothing runs. Other commands still ignore unknown keys until their `params` doc is complete and they are added to that list (`coalesce` and `target` are always accepted). - `ui.type {text}`: type text (goes to the focused widget, or to the canvas while the Type tool is editing) - `ui.resize {width, height}`: resize the main window - `ui.gpu.simulateLoss {error?}`: act as if the wgpu device was lost (or reported an error with `error: true`). The app switches to the CPU image compositor for the rest of the session and shows the same recovery warning as on a real loss. `gpuFallbackNotice` in `ui.inspect` contains the reason while the warning is open. Keep Using CPU saves CPU compatibility for the next launch; Retry GPU saves GPU mode and asks the user to save and restart. The window renderer still requires a working graphics or software adapter. Returns `wasActive` and `gpuInfo`. For testing the fallback From 4543ef35b975b2c177ff55b3acd5d92fea12387c Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Tue, 13 Oct 2026 10:23:50 +0800 Subject: [PATCH 303/523] Preserve signed HDR colors in float text rasterization (#4379) Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/text/src/render.rs | 7 +++++-- crates/text/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 44165461aa..b50a63fe0a 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -1,6 +1,6 @@ //! Rasterizes a [`TextLayout`] into a document-space [`Surface`] of any pixel format. -use photocraft_color::{Color, PixelFormat}; +use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::text::AntiAlias; use photocraft_geom::{Affine, Rect}; use photocraft_raster::Surface; @@ -267,7 +267,10 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo let a = px[n]; if a > 0.0 { for v in &mut px[..n] { - *v = (*v / a).clamp(0.0, 1.0); + let straight = *v / a; + // Float text can carry HDR and signed colours. Keep integer output bounded + // and retain the old handling of non-finite intermediate values. + *v = if format.sample == SampleType::F32 && straight.is_finite() { straight } else { straight.clamp(0.0, 1.0) }; } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index c21d5c2682..148a84c5e8 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -13,6 +13,44 @@ fn styled(text: &str, style: CharStyle) -> TextLayer { TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() } } +#[test] +fn raster_preserves_hdr_and_signed_float_text_colors() { + let mut e = TextEngine::new(); + for alpha in [1.0, 0.4] { + let color = Color::rgba(2.5, -0.25, 0.5, alpha); + let style = CharStyle { size_pt: 48.0, color, ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, PixelFormat::RGBA32F); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + for channel in 0..3 { + assert!((pixel[channel] - color.c[channel]).abs() < 1e-5, "alpha={alpha}, pixel={pixel:?}"); + } + assert!(pixel[3] <= alpha + 1e-6); + } + assert!(covered > 100); + } +} + +#[test] +fn raster_integer_text_colors_stay_bounded() { + let mut e = TextEngine::new(); + for format in [PixelFormat::RGBA8, PixelFormat::RGBA16] { + let style = CharStyle { size_pt: 48.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }; + let (_, rendered) = e.render(&styled("HDR", style), 72.0, format); + let pixels = rendered.surface.read_region(rendered.rect); + let mut covered = 0; + for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { + covered += 1; + assert_eq!(&pixel[..2], &[1.0, 0.0]); + assert!((pixel[2] - 0.5).abs() < 1.0 / 255.0); + assert!(pixel[3] <= 0.4 + 1.0 / 255.0); + } + assert!(covered > 100); + } +} + fn with_para(mut t: TextLayer, p: ParagraphStyle) -> TextLayer { t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: p }]; t From 20e8f949f9cb9bf0445721c26319f56872b44fdc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:23:55 +0200 Subject: [PATCH 304/523] Type: fall back to installed Indic fonts for document text (#4383) (#4386) * Type: fall back to installed Indic fonts for document text (#4383) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/text/src/fonts.rs | 42 ++++++++++++++++++++++++++++++- crates/text/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++++ docs/scorecard.md | 2 +- scorecard/type.toml | 2 +- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 433baee074..54cfeb471b 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -59,7 +59,7 @@ fn inflate_font(deflated: &[u8]) -> Vec { /// Families tried (if installed) after the requested one, for missing glyphs. The Southeast Asian /// and CJK families ([`THAI_FAMILIES`], [`KHMER_FAMILIES`], [`LAO_FAMILIES`], [`MYANMAR_FAMILIES`], -/// [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. +/// [`INDIC_FAMILIES`], [`crate::cjk::families`]) go between these two lists, in the UI locale's script order. const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"]; /// Thai-capable families: Windows (Leelawadee UI since Windows 8; Tahoma and Leelawadee on /// older systems), macOS (Thonburi, Sukhumvit Set) and Linux (Noto). Text in a font without Thai @@ -76,6 +76,38 @@ pub const LAO_FAMILIES: &[&str] = &["Lao Sangam MN", "Lao MN", "Lao UI", "Noto S /// Myanmar/Burmese-capable families: macOS (Myanmar Sangam MN, Myanmar MN), Windows (Myanmar Text) /// and Linux (Noto). pub const MYANMAR_FAMILIES: &[&str] = &["Myanmar Sangam MN", "Myanmar MN", "Myanmar Text", "Noto Sans Myanmar"]; +/// Indic-script families for document text. Windows has `Nirmala UI` for all nine scripts (from +/// Windows 8); macOS has the Kohinoor and Sangam MN families; Linux has Noto Sans. Indic text in a +/// font without these glyphs had no candidate to fall back to and drew .notdef boxes (#218). +pub const DEVANAGARI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Devanagari", "Devanagari Sangam MN", "Noto Sans Devanagari", "Noto Sans Devanagari UI"]; +/// Bengali-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const BENGALI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Bangla", "Bangla Sangam MN", "Noto Sans Bengali", "Noto Sans Bengali UI"]; +/// Gurmukhi-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GURMUKHI_FAMILIES: &[&str] = &["Nirmala UI", "Gurmukhi Sangam MN", "Noto Sans Gurmukhi", "Noto Sans Gurmukhi UI"]; +/// Gujarati-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const GUJARATI_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Gujarati", "Gujarati Sangam MN", "Noto Sans Gujarati", "Noto Sans Gujarati UI"]; +/// Tamil-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TAMIL_FAMILIES: &[&str] = &["Nirmala UI", "Tamil Sangam MN", "Noto Sans Tamil", "Noto Sans Tamil UI"]; +/// Telugu-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const TELUGU_FAMILIES: &[&str] = &["Nirmala UI", "Kohinoor Telugu", "Telugu Sangam MN", "Noto Sans Telugu", "Noto Sans Telugu UI"]; +/// Kannada-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const KANNADA_FAMILIES: &[&str] = &["Nirmala UI", "Kannada Sangam MN", "Noto Sans Kannada", "Noto Sans Kannada UI"]; +/// Malayalam-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const MALAYALAM_FAMILIES: &[&str] = &["Nirmala UI", "Malayalam Sangam MN", "Noto Sans Malayalam", "Noto Sans Malayalam UI"]; +/// Sinhala-capable families (see [`DEVANAGARI_FAMILIES`]). +pub const SINHALA_FAMILIES: &[&str] = &["Nirmala UI", "Sinhala Sangam MN", "Noto Sans Sinhala", "Noto Sans Sinhala UI"]; +/// All Indic-script families, in the order they join the fallback candidates. +pub const INDIC_FAMILIES: &[&[&str]] = &[ + DEVANAGARI_FAMILIES, + BENGALI_FAMILIES, + GURMUKHI_FAMILIES, + GUJARATI_FAMILIES, + TAMIL_FAMILIES, + TELUGU_FAMILIES, + KANNADA_FAMILIES, + MALAYALAM_FAMILIES, + SINHALA_FAMILIES, +]; /// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get /// the locale's forms instead of Arial Unicode's. const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"]; @@ -87,6 +119,14 @@ pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static s v.extend_from_slice(KHMER_FAMILIES); v.extend_from_slice(LAO_FAMILIES); v.extend_from_slice(MYANMAR_FAMILIES); + for fams in INDIC_FAMILIES { + for f in *fams { + // Nirmala UI covers all nine scripts and is listed by each; probe it once. + if !v.contains(f) { + v.push(f); + } + } + } for s in order { // craft-fonts' Japanese fonts (if built in) go ahead of the installed Japanese fonts, in // the Japanese slot of the locale order, so shared Han keeps the locale's forms. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 148a84c5e8..832f0f0d85 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1655,6 +1655,59 @@ fn southeast_asian_fallback_candidates_cover_every_platform() { } } +/// The Indic scripts of document text, with conjuncts, matras and viramas (#218). +const INDIC: &[(&str, &str, &[&str])] = &[ + ("Devanagari", "हिन्दी भाषा क्षत्रिय श्री", fonts::DEVANAGARI_FAMILIES), + ("Bengali", "বাংলা ভাষা ক্ষত্রিয় শ্রী", fonts::BENGALI_FAMILIES), + ("Gurmukhi", "ਪੰਜਾਬੀ ਭਾਸ਼ਾ ਸਤਿ ਸ੍ਰੀ", fonts::GURMUKHI_FAMILIES), + ("Gujarati", "ગુજરાતી ભાષા ક્ષત્રિય શ્રી", fonts::GUJARATI_FAMILIES), + ("Tamil", "தமிழ் மொழி ஸ்ரீ கொடை", fonts::TAMIL_FAMILIES), + ("Telugu", "తెలుగు భాష క్షత్రియ శ్రీ", fonts::TELUGU_FAMILIES), + ("Kannada", "ಕನ್ನಡ ಭಾಷೆ ಕ್ಷತ್ರಿಯ ಶ್ರೀ", fonts::KANNADA_FAMILIES), + ("Malayalam", "മലയാളം ഭാഷ ക്ഷത്രിയ ശ്രീ", fonts::MALAYALAM_FAMILIES), + ("Sinhala", "සිංහල භාෂාව ක්‍ෂත්‍රිය ශ්‍රී", fonts::SINHALA_FAMILIES), +]; + +#[test] +fn indic_scripts_fall_back_to_installed_fonts() { + for (name, sample, families) in INDIC { + let mut e = TextEngine::with_system_fonts(); + let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else { + eprintln!("skipped: no {name}-capable font installed"); + continue; + }; + let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() }; + let l = e.layout(&direct, 72.0); + assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}"); + let mixed = format!("Latin {sample}"); + let l = e.layout(&point(&mixed, 24.0), 72.0); + assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed"); + let latin = l.glyphs.first().map(|g| g.face); + assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face"); + } +} + +#[test] +fn indic_fallback_candidates_cover_every_platform() { + // Logic-level half (runs without these fonts): every script has a Windows, macOS and Linux + // family in the candidates, ahead of the broad last-resort fonts; the shared Nirmala UI is + // probed once. + for order in [crate::cjk::script_order(None), crate::cjk::script_order(Some("ja"))] { + let fb = fonts::fallback_candidates(&order); + let last = fb.iter().position(|f| *f == "Arial Unicode MS").unwrap(); + assert_eq!(fb.iter().filter(|f| **f == "Nirmala UI").count(), 1, "{fb:?}"); + for (name, _, families) in INDIC { + assert!(families.contains(&"Nirmala UI"), "{name}"); + assert!(families.iter().any(|f| f.ends_with("Sangam MN") || f.starts_with("Kohinoor")), "{name}: no macOS family"); + assert!(families.iter().any(|f| f.starts_with("Noto Sans ")), "{name}: no Linux family"); + for fam in *families { + let i = fb.iter().position(|f| f == fam).unwrap_or_else(|| panic!("{fam} missing from {fb:?}")); + assert!(i < last, "{fam} after the last-resort fonts"); + } + } + } +} + #[test] fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else { diff --git a/docs/scorecard.md b/docs/scorecard.md index 712a17b611..db11f4050b 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -212,7 +212,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w |---|---|---|---|---| | TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | -| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test) | +| TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | | TYPE-218-5 | OpenType UI: Character panel buttons, Numerals and Stylistic Sets submenus | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Type > OpenType toggles (type_extra_cmds.rs:512). Character panel (and the Properties Character section) has Photoshop's eight OpenType buttons: Standard Ligatures, Contextual Alternates, Discretionary Ligatures, Swash, Stylistic Alternates, Titling Alternates, Ordinals, Fractions, pressed while the feature is on at the selection and dispatching the type.openType.* commands (type_tool.rs opentype_row; character_panel_opentype_buttons_toggle_and_show_the_feature, #3752). Still missing: the Numerals and Stylistic Sets submenus, and greying out features the font lacks | | TYPE-218-6 | Manual pair kerning (Alt+arrows), optical kerning rendered, both round-trip PSD (#206) | done | [#218](https://github.com/storytold/photocraft/issues/218) | Alt+Left/Right at a collapsed caret kerns the pair (type_tool.rs kern_pair, 20 per step and 100 with Cmd; type_tool_alt_arrows_kern_the_pair). Optical kerning is computed from the glyph outlines in text/src/optical.rs and applied by layout.rs when the run's mode is Optical (optical_matches_photoshop, optical_kerning_tightens_open_pairs). Both modes and manual values round-trip through PSD (io/tests/psd_kerning.rs; the optical mode travels in Txt2, txt2_carries_optical_kerning) | diff --git a/scorecard/type.toml b/scorecard/type.toml index 81064ce8d3..078c956a3f 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -23,7 +23,7 @@ id = "TYPE-218-3" target = "Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL" status = "partial" issue = 218 -note = "Thai is covered: THAI_FAMILIES (fonts.rs) lists the Windows, macOS and Linux families and thai_in_latin_font_falls_back_to_installed_thai_font checks a sample with above and below marks (#1909); Arabic and Hebrew fall back too. Khmer and Indic have no fallback list, and there are no per-script golden shaping tests (only a bidi test)" +note = "Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test)" [[item]] id = "TYPE-218-4" From 88f84aab59b2bf4b2acd959129600bedddbb1a77 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:23:59 +0200 Subject: [PATCH 305/523] Raw: treat DNG samples piled up below the stated white level as clipped (fixes #4348) (#4389) Some DNG writers state a white level the sensor never reaches (an iPhone 15 Pro Max Bayer DNG states 4095 and clips at 3567). Development clips each channel at 1 after white balance, so red and blue reached 1 while green stayed at 0.85 and blown highlights turned magenta. On decode, a per-colour pile-up clearly below the white level is now raised to it, as in storytold/lightcraft#586. --- crates/raw/src/lib.rs | 10 +- crates/raw/src/saturation.rs | 265 +++++++++++++++++++++++++++++++++++ crates/raw/tests/dng.rs | 25 ++++ 3 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 crates/raw/src/saturation.rs diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index dc1805d478..d5a3e987c7 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -60,6 +60,7 @@ mod par; mod preview; mod raf; mod rw2; +mod saturation; mod sensor; mod sony; mod tiff; @@ -230,7 +231,7 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { _ => { let t = Tiff::new(bytes).ok_or(RawError::NotRaw)?; match format { - RawFormat::Dng => dng::decode(&t, limits), + RawFormat::Dng => dng::decode(&t, limits).map(lift_clipped), RawFormat::Cr2 => cr2::decode(&t, limits), RawFormat::Rw2 => rw2::decode(&t, limits), RawFormat::Orf => orf::decode(&t, limits), @@ -240,6 +241,13 @@ pub fn decode(bytes: &[u8], limits: &Limits) -> Result { } } +/// DNG writers can state a white level the sensor never reaches; samples stuck at the real saturation +/// point are raised to it so they clip together (see `saturation.rs`). +fn lift_clipped(mut s: Sensor) -> Sensor { + s.lift_clipped_samples(); + s +} + /// Decodes a lossless JPEG (T.81 process 14) stream into `(width, height, /// components, samples)`; at most `max_samples` samples are allocated. /// Exposed for fuzzing and tools. diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs new file mode 100644 index 0000000000..a36692a3f6 --- /dev/null +++ b/crates/raw/src/saturation.rs @@ -0,0 +1,265 @@ +//! Sensors that clip below the stated white level. +//! +//! A DNG's `WhiteLevel` is what the writer says the samples top out at, not always where the sensor +//! really saturates: a camera app can state 4095 for 12-bit data that piles up at 3567 (85 % of the +//! range above black, storytold/photocraft#4348, as storytold/lightcraft#548). Development clips each +//! channel at 1 after white balance, so samples stuck 15 % below the white level are not clipped +//! together: red and blue (multiplied by more than green) reach 1, green stays at 0.85, and blown +//! highlights turn magenta. +//! +//! [`Sensor::lift_clipped_samples`] finds the real saturation point of each colour from the samples +//! (a pile-up of identical values near the top of the range) and raises the samples at or above it to +//! the stated white level. Unclipped samples keep their values, and a file whose data reaches its white +//! level, or has no pile-up, is left alone. Same detector as storytold/lightcraft#586. + +use crate::sensor::Sensor; + +/// A saturation plateau must hold at least this share of its colour's samples (and 100 of them), as +/// many must lie below it (a scene, not a flat field)... +const MIN_SHARE: f64 = 1e-4; +/// ...be at least this many times as populated as the average value just below it... +const MIN_SPIKE: f64 = 16.0; +/// ...and have next to nothing above it (a real clip point is the top of the data; a popular value in +/// the middle of sparse or curve-coded data has plenty of samples above): at most this share of the +/// plateau lies more than `ABOVE_MARGIN` of the range over it (noise spilling past the plateau). +const MAX_ABOVE: f64 = 0.02; +const ABOVE_MARGIN: f32 = 0.01; +/// Plateaus within this share of the range from the white level are the white level (no lift needed). +const NEAR_WHITE: f32 = 0.03; +/// How far below the plateau the "average value just below it" reaches. +const NEIGHBOURHOOD: usize = 64; + +impl Sensor { + /// The value each colour's samples saturate at, when that is clearly below the stated white level + /// (see the module docs). Only CFA data without a linearization table is examined: with a table the + /// stored values are not on the white level's scale. + pub(crate) fn saturation_points(&self) -> [Option; 3] { + let none = [None; 3]; + let Some(cfa) = &self.cfa else { return none }; + if self.samples != 1 || self.linearization.is_some() || self.width.checked_mul(self.height) != Some(self.data.len()) { + return none; + } + let white = self.white[0]; + let black = if self.black.values.is_empty() { 0.0 } else { self.black.values.iter().sum::() / self.black.values.len() as f32 }; + // a hostile file can state any levels: NaN, negative or inverted ones are left alone + if !(black.is_finite() && white.is_finite() && black >= 0.0 && white > black && white <= 65535.0) { + return none; + } + let a = self.active; + let fits = |start: usize, len: usize, max: usize| start.checked_add(len).is_some_and(|end| end <= max); + if !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { + return none; + } + let lo = (black + 0.5 * (white - black)).ceil() as usize; + let hi = white as usize; + if lo + NEIGHBOURHOOD >= hi { + return none; + } + let mut hist = vec![[0u32; 3]; 65536]; + let mut total = [0u64; 3]; + for y in a.y..a.y + a.height { + let row = &self.data[y * self.width + a.x..][..a.width]; + for (x, &v) in (a.x..).zip(row) { + let c = usize::from(cfa.color(x, y)).min(2); + total[c] += 1; + hist[usize::from(v)][c] += 1; + } + } + let mut out = none; + for c in 0..3 { + // the most populated value of the upper half of the range + let Some((v, n)) = (lo..hi).map(|v| (v, hist[v][c])).max_by_key(|&(v, n)| (n, v)) else { continue }; + let min_n = (total[c] as f64 * MIN_SHARE).max(100.0); + if f64::from(n) < min_n || v as f32 >= white - NEAR_WHITE * (white - black) { + continue; + } + // a colour whose samples all sit on one value is a flat field, not a scene that clipped + let darker: u64 = hist[..v].iter().map(|h| u64::from(h[c])).sum(); + if (darker as f64) < min_n { + continue; + } + let below: u64 = (v.saturating_sub(NEIGHBOURHOOD)..v).map(|u| u64::from(hist[u][c])).sum(); + let from = (v + ((ABOVE_MARGIN * (white - black)) as usize).max(1) + 1).min(hist.len()); + let above: u64 = hist[from..].iter().map(|h| u64::from(h[c])).sum(); + if f64::from(n) >= MIN_SPIKE * (below as f64 / NEIGHBOURHOOD as f64).max(1.0) && above as f64 <= MAX_ABOVE * f64::from(n) { + out[c] = u16::try_from(v).ok(); + } + } + out + } + + /// Raises samples at or above their colour's real saturation point ([`Self::saturation_points`]) to + /// the stated white level, so development clips them together. Returns how many samples changed + /// (0: the data is untouched). + pub(crate) fn lift_clipped_samples(&mut self) -> usize { + let points = self.saturation_points(); + if points.iter().all(Option::is_none) { + return 0; + } + let Some(cfa) = self.cfa.clone() else { return 0 }; + let (a, w) = (self.active, self.width); + // the white level itself, rounded up: (white - black) / (white - black) is exactly 1 + let white = self.white[0].ceil() as u16; + let mut changed = 0; + for y in a.y..a.y + a.height { + let row = &mut self.data[y * w + a.x..][..a.width]; + for (x, v) in (a.x..).zip(row.iter_mut()) { + if let Some(p) = points[usize::from(cfa.color(x, y)).min(2)] + && *v >= p + && *v < white + { + *v = white; + changed += 1; + } + } + } + changed + } +} + +#[cfg(test)] +mod tests { + use crate::*; + + /// A 64 x 64 RGGB mosaic with a gradient, black 528, white `white`, whose samples above `clip` (when + /// given) are stuck at `clip` like a sensor that saturates early. + fn sensor_with(clip: Option, white: f32) -> Sensor { + let data = (0..64 * 64) + .map(|i| { + let v = 528 + ((i % 64) * 60 + (i / 64) * 7) as u16 % 4000; + clip.map_or(v, |c| v.min(c)) + }) + .collect(); + Sensor { + format: RawFormat::Dng, + make: None, + model: None, + width: 64, + height: 64, + samples: 1, + data, + cfa: Cfa::bayer("RGGB"), + linearization: None, + black: BlackLevels::uniform(528.0), + white: [white; 3], + active: Rect::new(0, 0, 64, 64), + crop: Rect::new(0, 0, 64, 64), + color: ColorInfo::default(), + camera_wb: None, + orientation: 1, + baseline_exposure: 0.0, + gain_maps: Vec::new(), + tone_curve: Vec::new(), + black_point: 0.0, + warnings: Vec::new(), + } + } + + /// The gradient of `sensor_with`, wrapped so it never gets near the top of the range. + fn without_top(s: &mut Sensor) { + for v in s.data.iter_mut() { + *v = 528 + (*v - 528) % 3000; + } + } + + #[test] + fn a_pile_up_below_the_white_level_is_found_and_lifted() { + let mut s = sensor_with(Some(3567), 4095.0); + assert_eq!(s.saturation_points(), [Some(3567); 3]); + let before = s.data.clone(); + assert!(s.lift_clipped_samples() > 100); + for (x, y) in before.iter().zip(&s.data) { + // plateau samples go to the white level, everything else is untouched + assert_eq!(*y, if *x >= 3567 { 4095 } else { *x }); + } + assert_eq!(s.lift_clipped_samples(), 0, "idempotent"); + } + + #[test] + fn data_that_reaches_its_white_level_is_left_alone() { + let mut s = sensor_with(Some(4095), 4095.0); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + #[test] + fn data_without_a_pile_up_is_left_alone() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + let before = s.data.clone(); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + assert_eq!(s.data, before); + } + + /// Sparse or curve-coded data has popular values with plenty of samples above them: not a clip point. + #[test] + fn a_popular_value_with_data_above_it_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for (i, v) in s.data.iter_mut().enumerate() { + // a third of the samples sit on one code in the upper half, a few hundred are brighter + if i % 3 == 0 { + *v = 3000; + } else if i % 11 == 0 { + *v = 3000 + (i % 700) as u16 + 1; + } + } + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + /// A uniform patch (a flat-field test frame) has nothing darker: not a scene that clipped. + #[test] + fn a_flat_field_is_not_a_clip_point() { + let mut s = sensor_with(None, 4095.0); + s.data.fill(3000); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn a_few_stuck_samples_are_not_a_plateau() { + let mut s = sensor_with(None, 4095.0); + without_top(&mut s); + for v in s.data.iter_mut().take(20) { + *v = 3800; + } + assert_eq!(s.saturation_points(), [None; 3]); + } + + /// With a linearization table the stored codes are not on the white level's scale. + #[test] + fn linearized_data_is_left_alone() { + let mut s = sensor_with(Some(3567), 4095.0); + s.linearization = Some((0..4096).collect()); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + + #[test] + fn hostile_levels_are_left_alone() { + for black in [-5000.0, f32::NAN] { + let mut s = sensor_with(Some(40), 4095.0); + s.black = BlackLevels::uniform(black); + assert_eq!(s.saturation_points(), [None; 3]); + assert_eq!(s.lift_clipped_samples(), 0); + } + for white in [f32::NAN, f32::INFINITY, 1e9, 100.0] { + let mut s = sensor_with(Some(3567), 4095.0); + s.white = [white; 3]; + assert_eq!(s.saturation_points(), [None; 3], "{white}"); + } + // an active area that runs past the data (or overflows when added up) is refused + let mut s = sensor_with(Some(3567), 4095.0); + s.active = Rect::new(usize::MAX, 0, 2, 64); + assert_eq!(s.saturation_points(), [None; 3]); + s.active = Rect::new(0, 0, 64, 65); + assert_eq!(s.lift_clipped_samples(), 0); + let mut s = sensor_with(Some(3567), 4095.0); + s.data.pop(); + assert_eq!(s.lift_clipped_samples(), 0); + } +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index 9dfab5dfd7..e13fb920dd 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -425,6 +425,31 @@ fn highlights_clip_to_white_and_exposure_applies() { assert!((lin(up) / lin(base) - 2.0).abs() < 0.02, "{} vs {}", lin(up), lin(base)); } +/// storytold/photocraft#4348 (as storytold/lightcraft#548): a 12-bit DNG that states WhiteLevel 4095 +/// but whose sensor saturates at 3567 in every colour. Under the camera's white balance (red x2.46, +/// blue x1.59) the stuck red and blue samples clip at 1 while green stays at 0.85, so a blown wall +/// rendered magenta. The wall must come out neutral, and the unclipped ramp beside it unchanged. +#[test] +fn highlights_that_clip_below_the_stated_white_level_stay_neutral() { + let (w, h) = (64, 32); + let data: Vec = (0..w * h).map(|i| if i % w < w / 2 { 528 + ((i % w) * 90 + (i / w) * 3) as u16 } else { 3567 }).collect(); + let mut spec = DngSpec::cfa(w, h, data.clone()); + spec.bits = 12; + spec.black = vec![528]; + spec.white = 4095; + spec.as_shot_neutral = Some([1.0 / 2.46, 1.0, 1.0 / 1.59]); + let b = spec.build(); + let d = develop(&b, &DevelopOptions::default()).unwrap(); + let px = &d.rgb[(16 * w + 48) * 3..(16 * w + 48) * 3 + 3]; + let lin = |v: u16| (f64::from(v) / 65535.0).powf(1.8); + let tint = (lin(px[0]) / lin(px[1]) - 1.0).abs().max((lin(px[2]) / lin(px[1]) - 1.0).abs()); + assert!(tint < 0.01, "the clipped wall is tinted: {px:?}"); + let s = sensor(&b); + for (i, (&got, &was)) in s.data.iter().zip(&data).enumerate() { + assert_eq!(got, if was == 3567 { 4095 } else { was }, "sample {i}"); + } +} + #[test] fn gain_map_opcode_is_applied() { // Uniform 0.25 grey; a gain map that doubles everything over the whole image. From 5aa6e33adf03b42620341178aa16b18679d5f633 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:24:03 +0200 Subject: [PATCH 306/523] Content-Aware Move: live preview of the result (#4385) (#4393) * Content-Aware Move: live preview of the result while the pointer rests (#4385) A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box. Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/retouch_cmds.rs | 1 + .../src/retouch_cmds/content_aware_move.rs | 58 +++- crates/engine/src/retouch_cmds/tests.rs | 25 ++ crates/ui-egui/src/cam_preview.rs | 285 ++++++++++++++++++ crates/ui-egui/src/cam_transform.rs | 18 +- crates/ui-egui/src/canvas.rs | 7 +- crates/ui-egui/src/lib.rs | 4 + docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 9 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/cam_preview.rs diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index cba7a22976..315a8d3929 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -27,6 +27,7 @@ mod content_aware_move; mod patch; mod pattern_stamp; mod remove; +pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; pub use patch::preview as patch_preview; fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/retouch_cmds/content_aware_move.rs b/crates/engine/src/retouch_cmds/content_aware_move.rs index 120c8b7c02..ddca9c6e0f 100644 --- a/crates/engine/src/retouch_cmds/content_aware_move.rs +++ b/crates/engine/src/retouch_cmds/content_aware_move.rs @@ -96,6 +96,8 @@ struct Plan { mode: Mode, structure: u8, color: u8, + /// Most pixels the fills may sample around the content ([`MAX_MARGIN`], less for a preview). + margin: i32, } /// Transform On Drop's `scaleX` / `scaleY` (%) and `rotate` (degrees): the map from a source point @@ -193,7 +195,7 @@ fn landed_mask(cov: &[f32], area: Rect, inv: &Homography, rect: Rect) -> Vec Result { +fn plan(s: &Session, p: &Value, margin: i32) -> Result { let mode = match string(p, "mode", "move") { "move" => Mode::Move, "extend" => Mode::Extend, @@ -242,7 +244,7 @@ fn plan(s: &Session, p: &Value) -> Result { } }; let all_layers = flag(p, "sampleAllLayers", false) && targets_pixels(p); - Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color }) + Ok(Plan { target: Target { id, all_layers }, canvas, area, offset: (dx, dy), map, dest, mode, structure, color, margin }) } /// Width in pixels of the edge band re-synthesised at the new place, for Structure 1..7 and content @@ -293,7 +295,12 @@ fn erode(w: usize, h: usize, m: &[bool], r: usize) -> Vec { /// Where a fill samples from around `r`: Photoshop's Content-Aware window /// ([`crate::fill_cmds::sampling_window`]), at most [`MAX_MARGIN`] pixels around `r`. pub(super) fn window(r: Rect, canvas: Rect) -> Rect { - crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(MAX_MARGIN)) + window_within(r, canvas, MAX_MARGIN) +} + +/// [`window`] with at most `margin` pixels around `r`. +fn window_within(r: Rect, canvas: Rect, margin: i32) -> Rect { + crate::fill_cmds::sampling_window(r, canvas).intersect(&r.inflate(margin)) } /// The pixels a step works on: the targeted surface, or with Sample All Layers the visible @@ -433,7 +440,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 1. The content at its new place: the content with its original surroundings laid over the // window there; never sampling the content's old place. - let win = window(plan.dest, plan.canvas); + let win = window_within(plan.dest, plan.canvas, plan.margin); let (content, moved) = match &inv { None => { let mut content = read(doc, plan.target, p, win.translate(-dx, -dy))?; @@ -443,7 +450,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra Some(inv) => { // Only the content and a ring around it (the colour fit's reference) matter: read // the source behind that, a few pixels past the area, and resample it. - let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(MAX_MARGIN)).intersect(&plan.canvas); + let src = mapped_bounds(inv, plan.dest.inflate(8)).intersect(&plan.area.inflate(plan.margin)).intersect(&plan.canvas); let src = if src.is_empty() { plan.area } else { src }; (resample(&read(doc, plan.target, p, src)?, inv, win), landed_mask(&cov, plan.area, inv, win)) } @@ -454,7 +461,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra // 2. Move: fill the place the content left (where the content didn't land on it). if plan.mode == Mode::Move { ctx.check()?; - let win = window(plan.area, plan.canvas); + let win = window_within(plan.area, plan.canvas, plan.margin); let (w, h) = (win.width() as usize, win.height() as usize); let img = read(doc, plan.target, p, win)?; let landed = match &inv { @@ -478,7 +485,7 @@ fn run_move(doc: &mut Document, sel: &Surface, plan: &Plan, p: &Value, ctx: &cra } pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { - let plan = plan(s, p)?; + let plan = plan(s, p, MAX_MARGIN)?; let label = if plan.mode == Mode::Move { "Content-Aware Move" } else { "Content-Aware Extend" }; let d = s.active().ok_or(EngineError::NoDocument)?; let doc = d.doc.clone(); @@ -520,3 +527,40 @@ pub(super) fn content_aware_move(s: &mut Session, p: &Value) -> Result { }, ) } + +/// Largest selection (bounding box, in pixels) the live preview computes. +const PREVIEW_MAX_PIXELS: u64 = 256 * 256; +/// Sampling margin of a preview: a fraction of the command's, so a preview is a small solve. +const PREVIEW_MARGIN: i32 = 48; + +/// A live preview of `paint.contentAwareMove` that can run on any thread: the document as of +/// [`preview`], with the move's params. Cheaper than the command (a small sampling margin, and +/// only small selections), so the result is close to, not the same as, what the command makes. +pub struct ContentAwarePreview { + doc: std::sync::Arc, + plan: Plan, + params: Value, +} + +impl ContentAwarePreview { + /// The active document with the move applied (nothing is recorded, the session is not + /// touched) and the rectangle it changed. Stops with `Cancelled` once `ctx` is cancelled. + pub fn run(&self, ctx: &crate::jobs::JobCtx) -> Result<(Document, Rect)> { + let mut work = (*self.doc).clone(); + let sel = self.doc.selection.as_ref().ok_or_else(|| bad(CMD, "select the area to move first"))?; + let (_, damage, _) = run_move(&mut work, sel, &self.plan, &self.params, ctx, "Content-Aware Move preview")?; + Ok((work, damage)) + } +} + +/// Prepare the preview of `paint.contentAwareMove` with params `p` on the active document. Errors +/// like the command does, and for a selection larger than a preview takes. +pub fn preview(s: &Session, p: &Value) -> Result { + let plan = plan(s, p, PREVIEW_MARGIN)?; + let pixels = u64::from(plan.area.width()) * u64::from(plan.area.height()); + if pixels > PREVIEW_MAX_PIXELS { + return Err(bad(CMD, format!("the selection is too large to preview ({pixels} pixels, at most {PREVIEW_MAX_PIXELS})"))); + } + let d = s.active().ok_or(EngineError::NoDocument)?; + Ok(ContentAwarePreview { doc: d.doc.clone(), plan, params: p.clone() }) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index b566196738..625e702989 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -917,6 +917,31 @@ fn content_aware_move_moves_the_object_and_fills_its_place_at_all_depths() { } } +#[test] +fn content_aware_move_preview_shows_the_result_without_touching_the_session() { + use crate::jobs::JobCtx; + let mut s = session(128, 64, 8, "rgb"); + paint_layer(&mut s, with_object(20, 26)); + s.execute("select.rect", json!({"x": 16, "y": 22, "width": 20, "height": 20})).unwrap(); + let before = s.active().unwrap().revision; + let p = json!({"offset": [70, 0]}); + let (doc, damage) = content_aware_move_preview(&s, &p).unwrap().run(&JobCtx::new()).unwrap(); + let px = |d: &Document, x, y| d.layers[0].surface().unwrap().rgba(x, y); + assert!(is_object(px(&doc, 95, 31)) && !is_object(px(&doc, 25, 31)), "the preview shows the move"); + assert!(damage.contains(95, 31) && damage.contains(25, 31), "{damage:?}"); + // The session is untouched. + assert!(is_object(rgba(&s, 25, 31)) && !is_object(rgba(&s, 95, 31))); + assert_eq!(s.active().unwrap().revision, before); + // A cancelled preview stops; a selection past the preview's size and bad params are errors. + let ctx = JobCtx::new(); + ctx.cancel(); + assert!(matches!(content_aware_move_preview(&s, &p).unwrap().run(&ctx), Err(EngineError::Cancelled))); + assert!(content_aware_move_preview(&s, &json!({"offset": [0, 0]})).is_err()); + assert!(content_aware_move_preview(&s, &json!({"offset": [70, 0], "rotate": "x"})).is_err()); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap(); + assert!(content_aware_move_preview(&s, &json!({"offset": [1, 0]})).is_err()); +} + #[test] fn content_aware_move_transform_on_drop_scales_and_rotates_the_content() { // A horizontal 16×6 bar in a 36×36 selection, dropped 80 px right and turned 90° with 125% diff --git a/crates/ui-egui/src/cam_preview.rs b/crates/ui-egui/src/cam_preview.rs new file mode 100644 index 0000000000..4065e3353c --- /dev/null +++ b/crates/ui-egui/src/cam_preview.rs @@ -0,0 +1,285 @@ +//! Content-Aware Move drags and its Transform On Drop box shown with the result: once the pointer +//! (or the box) rests for a moment, the move is computed as a background job +//! ([`photocraft_engine::retouch_cmds::content_aware_move_preview`]: a smaller sampling window than +//! the command and only small selections) and the canvas shows the document with it. Any change of +//! the offset, scale or rotation cancels the job and starts the wait again, so the UI never waits +//! for the blend. Releasing (or Enter) runs the exact `paint.contentAwareMove`; the document is +//! untouched until then. Not available on wasm (no threads). + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use photocraft_doc::{DocId, Document}; +use photocraft_engine::jobs::JobCtx; +use serde_json::Value; + +use crate::PhotocraftApp; +use crate::state::Tool; + +/// Preview keys: `BASE + n`, one per result shown (see `canvas::display_doc`). +const BASE: u64 = 1 << 45; +/// How long the move must stay unchanged before a preview is computed. +const DEBOUNCE_MS: f64 = 150.0; +/// How often a running job is polled for its result. +const POLL_MS: u64 = 40; + +struct Running { + key: String, + ctx: JobCtx, + rx: Receiver>, +} + +pub(crate) struct CamPreview { + doc: DocId, + revision: u64, + /// The params the move has now, and when they last changed (ms). + key: String, + since: f64, + /// Params whose preview failed (too large a selection, say): not tried again. + failed: Option, + running: Option, + /// The latest finished preview, with the params it shows. + shown: Option<(String, Arc)>, + serial: u64, +} + +impl CamPreview { + fn stop(&mut self) { + if let Some(r) = self.running.take() { + r.ctx.cancel(); + } + } +} + +impl Drop for CamPreview { + fn drop(&mut self) { + self.stop(); + } +} + +/// `paint.contentAwareMove`'s params for what the pointer or the box has now; `None` without a +/// move to preview. +fn wanted(app: &mut PhotocraftApp) -> Option { + if app.ui.tool != Tool::ContentAwareMove || cfg!(target_arch = "wasm32") { + return None; + } + if let Some(t) = &app.ui.cam_transform { + let place = crate::cam_transform::placement(t)?; + if place.offset == [0, 0] && place.is_plain_move() { + return None; + } + return Some(crate::cam_transform::params(app, &place)); + } + let d = app.drag.as_ref().filter(|d| d.tool == Tool::ContentAwareMove)?; + let (start, mods) = (d.start, d.modifiers); + let end = d.points.last().map_or(start, |p| [p[0], p[1]]); + if !crate::retouch_ui::patch_drags_selection(app, start, mods) { + return None; + } + let off = crate::retouch_ui::patch_offset(app, start, end); + (off != [0, 0]).then(|| crate::retouch_ui::content_aware_move_params(app, off.map(i64::from))) +} + +/// The document to show for the move on document `idx`: with the move applied, once its preview +/// has been computed for the current offset and transform. +pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc, u64)> { + if app.session.active_index() != Some(idx) { + return None; + } + step(app, crate::gpu_canvas::now_ms()) +} + +fn step(app: &mut PhotocraftApp, now: f64) -> Option<(Arc, u64)> { + let Some(params) = wanted(app) else { + app.cam_preview = None; + return None; + }; + let st = app.session.active()?; + let (doc, revision) = (st.doc.id, st.revision); + let key = params.to_string(); + if app.cam_preview.as_ref().is_some_and(|p| p.doc != doc || p.revision != revision) { + app.cam_preview = None; + } + let p = app.cam_preview.get_or_insert_with(|| CamPreview { + doc, + revision, + key: String::new(), + since: now, + failed: None, + running: None, + shown: None, + serial: 0, + }); + if p.key != key { + p.key = key.clone(); + p.since = now; + // What is computing is already out of date. + if p.running.as_ref().is_some_and(|r| r.key != key) { + p.stop(); + } + } + if let Some(r) = &p.running { + match r.rx.try_recv() { + Ok(result) => { + let r = p.running.take(); + match (result, r) { + (Ok(d), Some(r)) => { + // Duotone documents display through their inks. + let d = photocraft_engine::mode_cmds::display_document(&d).unwrap_or(d); + p.shown = Some((r.key, Arc::new(d))); + p.serial += 1; + } + (_, Some(r)) => p.failed = Some(r.key), + _ => {} + } + } + Err(TryRecvError::Disconnected) => { + if let Some(r) = p.running.take() { + p.failed = Some(r.key); + } + } + Err(TryRecvError::Empty) => {} + } + } + let settled = now - p.since >= DEBOUNCE_MS; + let done = p.shown.as_ref().is_some_and(|s| s.0 == key); + if settled && !done && p.running.is_none() && p.failed.as_deref() != Some(&key) { + match start(app, ¶ms, key.clone()) { + Some(r) => app.cam_preview.as_mut()?.running = Some(r), + None => app.cam_preview.as_mut()?.failed = Some(key.clone()), + } + } + let p = app.cam_preview.as_ref()?; + // Only a preview of the move as it is now: an older one would show a wrong place. + let (_, d) = p.shown.as_ref().filter(|s| s.0 == key)?; + Some((d.clone(), BASE + p.serial)) +} + +/// Compute the preview of `params` on a worker thread. +fn start(app: &PhotocraftApp, params: &Value, key: String) -> Option { + let job = photocraft_engine::retouch_cmds::content_aware_move_preview(&app.session, params).ok()?; + let ctx = JobCtx::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = ctx.clone(); + std::thread::Builder::new() + .name("cam-preview".into()) + .spawn(move || { + let _ = tx.send(job.run(&worker).map(|(d, _)| d)); + }) + .ok()?; + Some(Running { key, ctx, rx }) +} + +/// Keep frames coming while a preview waits out its debounce or computes. +pub(crate) fn wake(app: &PhotocraftApp, ctx: &egui::Context) { + let Some(p) = &app.cam_preview else { return }; + if p.running.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(POLL_MS)); + } else if p.shown.as_ref().is_none_or(|s| s.0 != p.key) && p.failed.as_deref() != Some(&p.key) { + let left = (DEBOUNCE_MS - (crate::gpu_canvas::now_ms() - p.since)).max(1.0); + ctx.request_repaint_after(std::time::Duration::from_millis(left as u64)); + } +} + +/// Whether the canvas shows a preview. +pub(crate) fn showing(app: &PhotocraftApp) -> bool { + app.cam_preview.as_ref().is_some_and(|p| p.shown.as_ref().is_some_and(|s| s.0 == p.key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + use serde_json::json; + + fn blue(d: &Document, x: i32, y: i32) -> bool { + d.layers[0].surface().unwrap().rgba(x, y)[0] < 0.5 + } + + #[test] + fn the_drag_previews_the_move_after_it_rests_and_never_touches_the_document() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + let steps = app.session.active().unwrap().history.past_len(); + + // Nothing to preview before the patch moves. + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none() && app.cam_preview.is_none()); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + // Still moving: the wait starts and nothing is computed or shown yet. + assert!(step(&mut app, 1000.0).is_none()); + assert!(step(&mut app, 1000.0 + DEBOUNCE_MS / 2.0).is_none() && app.cam_preview.as_ref().unwrap().running.is_none()); + // At rest: the job runs, off this thread, and its result shows once it arrives. + let mut now = 1000.0 + DEBOUNCE_MS; + let mut shown = step(&mut app, now); + for _ in 0..600 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, key) = shown.expect("a preview once the pointer rested"); + assert!(key > BASE && showing(&app)); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30), "the preview shows the move"); + let doc = app.session.active().unwrap().doc.clone(); + assert!(blue(&doc, 22, 30) && !blue(&doc, 72, 30), "the document is untouched"); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); + + // Moving on drops the stale preview at once and cancels what is not shown yet. + tool_event(&mut app, ToolEvent::Move { x: 60.0, y: 30.0, pressure: 1.0 }, m); + assert!(step(&mut app, now + 5.0).is_none() && !showing(&app)); + // Release commits the exact move; the preview is gone. + tool_event(&mut app, ToolEvent::Up { x: 60.0, y: 30.0 }, m); + assert!(step(&mut app, now + 10.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn the_transform_on_drop_box_previews_the_dropped_patch() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("paint.pencil", json!({"points": [[16, 30], [28, 30]], "size": 4, "color": "#0000ff"})).unwrap(); + app.run("select.lasso", json!({"points": [[6, 14], [38, 14], [38, 46], [6, 46]]})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + app.ui.tool_options.cam_transform_on_drop = true; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 22.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 72.0, y: 30.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 72.0, y: 30.0 }, m); + assert!(crate::cam_transform::active(&app)); + let (mut now, mut shown) = (0.0, step(&mut app, 0.0)); + for _ in 0..800 { + if shown.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + now += 10.0; + shown = step(&mut app, now); + } + let (preview, _) = shown.expect("the box shows the result"); + assert!(blue(&preview, 72, 30) && !blue(&preview, 22, 30)); + // Cancelling the box drops the preview. + crate::cam_transform::cancel(&mut app); + assert!(step(&mut app, now + 1.0).is_none() && app.cam_preview.is_none()); + } + + #[test] + fn a_selection_too_large_to_preview_is_not_retried() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + app.run("select.rect", json!({"x": 10, "y": 10, "width": 300, "height": 300})).unwrap(); + app.ui.tool = Tool::ContentAwareMove; + let m = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 100.0, y: 100.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 140.0, y: 100.0, pressure: 1.0 }, m); + assert!(step(&mut app, 0.0).is_none()); + assert!(step(&mut app, DEBOUNCE_MS + 1.0).is_none()); + let p = app.cam_preview.as_ref().unwrap(); + assert!(p.running.is_none() && p.failed.as_deref() == Some(p.key.as_str())); + } +} diff --git a/crates/ui-egui/src/cam_transform.rs b/crates/ui-egui/src/cam_transform.rs index 33912a28e8..27b0943a0f 100644 --- a/crates/ui-egui/src/cam_transform.rs +++ b/crates/ui-egui/src/cam_transform.rs @@ -112,6 +112,17 @@ pub(crate) fn cancel_stale(app: &mut PhotocraftApp) { } } +/// `paint.contentAwareMove`'s params for the box's placement and the options bar. +pub(crate) fn params(app: &PhotocraftApp, place: &Placement) -> serde_json::Value { + let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); + if !place.is_plain_move() { + p["scaleX"] = place.scale[0].into(); + p["scaleY"] = place.scale[1].into(); + p["rotate"] = place.rotate.into(); + } + p +} + /// ↩ / ✓: the move with the transformed patch, as one background job and History step. pub(crate) fn commit(app: &mut PhotocraftApp) { cancel_stale(app); @@ -122,12 +133,7 @@ pub(crate) fn commit(app: &mut PhotocraftApp) { crate::retouch_ui::patch_hint(app); return; } - let mut p = crate::retouch_ui::content_aware_move_params(app, place.offset); - if !place.is_plain_move() { - p["scaleX"] = place.scale[0].into(); - p["scaleY"] = place.scale[1].into(); - p["rotate"] = place.rotate.into(); - } + let p = params(app, &place); if let Err(e) = app.run("paint.contentAwareMove", p) { app.ui.status = e; app.ui.status_error = true; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4f78b853f2..051714aeba 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1076,6 +1076,10 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar if let Some(shown) = crate::patch_preview::display_doc(app, idx) { return shown; } + // Content-Aware Move drag or box: the result, once computed in the background. + if let Some(shown) = crate::cam_preview::display_doc(app, idx) { + return shown; + } // A blend mode hovered in the Layers panel. if let Some(shown) = crate::blend_preview::display_doc(app, idx) { return shown; @@ -1146,7 +1150,7 @@ const NAVIGATOR_SETTLE_MS: f64 = 200.0; /// of document `idx` and catches up on release: each step of the drag is a new preview, and a /// thumbnail composite of the whole document per step makes painting lag on large images. fn navigator_waits_for_drag(app: &PhotocraftApp, idx: usize) -> bool { - crate::move_ui::showing(app) || crate::patch_preview::showing(app) || live_stroke(app, idx).is_some() + crate::move_ui::showing(app) || crate::patch_preview::showing(app) || crate::cam_preview::showing(app) || live_stroke(app, idx).is_some() } /// The Navigator panel's image of document `idx`. With the CPU canvas that is the canvas texture @@ -3485,6 +3489,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::type_tool::draw_overlay(app, &painter, &xf); crate::transform_tool::draw_overlay(app, &painter, &xf); crate::cam_transform::draw(app, &painter, &xf); + crate::cam_preview::wake(app, painter.ctx()); crate::distort_ui::draw_overlay(app, &painter, &xf); crate::perspective_crop_ui::draw_overlay(app, &painter, &xf); crate::retouch_ui::draw_source_marker(app, &painter, &xf); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 873437b260..e599845669 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,7 @@ pub mod brush_preview; pub mod brush_resize; pub mod brush_sections; pub mod brushes_tab; +pub(crate) mod cam_preview; mod cam_transform; mod camera_raw_detail_ui; mod camera_raw_preview_ui; @@ -520,6 +521,8 @@ pub struct PhotocraftApp { pub(crate) blend_preview: Option, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, + /// Content-Aware Move drag or box shown with its result (`cam_preview`). + pub(crate) cam_preview: Option, /// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`). pub(crate) magnetic: magnetic_lasso_ui::Runtime, /// The next tool `Down` is a right-button drag that erases (see `paint_mouse`). @@ -747,6 +750,7 @@ impl PhotocraftApp { object_finder: None, blend_preview: None, patch_preview: None, + cam_preview: None, magnetic: Default::default(), secondary_erase: false, held_erase: false, diff --git a/docs/scorecard.md b/docs/scorecard.md index db11f4050b..465b7bc7a9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -133,7 +133,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs | | TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests | | TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) | -| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only) | +| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections | | TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm | | TOOL-213-6 | Fill dialog: contents, blend, opacity, preserve transparency; remembered; Shift+Backspace | done | [#213](https://github.com/storytold/photocraft/issues/213) | fill_ui.rs dialog over edit.fill (fill_cmds.rs): all contents, mode, opacity, preserve; remembered in prefs; Shift+F5 / Shift+Backspace | | TOOL-213-7 | Live stroke preview for every brush-based retouch tool | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Live (canvas.rs strokes_live): Brush, Pencil, Eraser, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Clone Stamp, Healing Brush, History Brush (#1026, #1040); the release commits the live stroke's pixels (P32). Not yet live: Spot Healing Brush, Mixer Brush, Background Eraser, Pattern Stamp | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index c090d4f897..cbff50e333 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -31,7 +31,7 @@ id = "TOOL-213-4" target = "Content-Aware Move tool reusing inpaint completion" status = "partial" issue = 213 -note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). Missing: a live preview of the result (the drag and the box show the outline only)" +note = "paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections" [[item]] id = "TOOL-213-5" From 78c61e6df5aee4ef726044154e2234cc335fbd3e Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:07 +0200 Subject: [PATCH 307/523] Swatches: an RGB group with the exact primaries and secondaries (#4302) (#4409) The built-in "Pure" group's hues are vivid but not pure (its green is #28C850), so picking it for text looked dull next to Photoshop's #00FF00. Add a Photoshop-style "RGB" group (#FF0000, #FFFF00, #00FF00, #00FFFF, #0000FF, #FF00FF) right after Grays. Pure is kept unchanged so existing documents, actions and saved swatches that use it keep their colours. --- crates/engine/src/presets/swatches.rs | 35 ++++++++++++++++++++++---- crates/engine/src/swatch_cmds/tests.rs | 21 ++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/presets/swatches.rs b/crates/engine/src/presets/swatches.rs index eec8c2d0d8..eb666e1ec5 100644 --- a/crates/engine/src/presets/swatches.rs +++ b/crates/engine/src/presets/swatches.rs @@ -288,7 +288,9 @@ pub fn hex(c: [f32; 3]) -> String { // ------------------------------------------------------------------ defaults -/// The default library: 40 colours in four groups (our own palette). +/// The default library: 46 colours in five groups. "RGB" holds the exact primaries and +/// secondaries (#FF0000, #00FF00, ...) as Photoshop's RGB group does; the other four are our own +/// palette, whose "Pure" hues are vivid but not the pure channel values (#4302). pub fn builtin() -> Vec> { let rgb = |n: &str, c: [u8; 3]| Swatch::new(n, SwatchColor::Rgb(c.map(|v| f32::from(v) / 255.0))); let grays = [ @@ -333,8 +335,19 @@ pub fn builtin() -> Vec> { let row = |prefix: &str, cs: [[u8; 3]; 10]| -> Vec { hues.iter().zip(cs).map(|(h, c)| rgb(&if prefix.is_empty() { (*h).to_string() } else { format!("{prefix} {h}") }, c)).collect() }; + // Right after Grays, near the top of the panel (Grays stays first: new swatches land there). + // Photoshop's order: red, yellow, green, cyan, blue, magenta. + let exact: [(&str, [u8; 3]); 6] = [ + ("RGB Red", [255, 0, 0]), + ("RGB Yellow", [255, 255, 0]), + ("RGB Green", [0, 255, 0]), + ("RGB Cyan", [0, 255, 255]), + ("RGB Blue", [0, 0, 255]), + ("RGB Magenta", [255, 0, 255]), + ]; vec![ Group::new("Grays", grays.iter().map(|(n, v)| rgb(n, [*v; 3])).collect()), + Group::new("RGB", exact.iter().map(|(n, c)| rgb(n, *c)).collect()), Group::new("Pastel", row("Pastel", pastel)), Group::new("Pure", row("", pure)), Group::new("Dark", row("Dark", dark)), @@ -346,15 +359,27 @@ mod tests { use super::*; #[test] - fn default_library_has_the_forty_colours() { + fn default_library_has_the_forty_six_colours() { let b = builtin(); - assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 40); + assert_eq!(b.iter().map(|g| g.items.len()).sum::(), 46); let names: Vec<&str> = b.iter().flat_map(|g| g.items.iter().map(|s| s.name.as_str())).collect(); let mut uniq = names.clone(); uniq.sort_unstable(); uniq.dedup(); - assert_eq!(uniq.len(), 40, "names are unique: {names:?}"); - assert_eq!(b[2].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + assert_eq!(uniq.len(), 46, "names are unique: {names:?}"); + assert_eq!(b[3].name, "Pure"); + assert_eq!(b[3].items[0].color, SwatchColor::Rgb([230.0 / 255.0, 40.0 / 255.0, 40.0 / 255.0])); + } + + #[test] + fn the_rgb_group_holds_exact_primaries_and_secondaries() { + // #4302: picking green should give #00FF00, as in Photoshop's RGB group. + let b = builtin(); + let g = &b[1]; + assert_eq!(g.name, "RGB"); + let conv = SwatchConv::builtin(); + let hexes: Vec = g.items.iter().map(|s| hex(conv.rgb(&s.color))).collect(); + assert_eq!(hexes, ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#0000ff", "#ff00ff"]); } #[test] diff --git a/crates/engine/src/swatch_cmds/tests.rs b/crates/engine/src/swatch_cmds/tests.rs index cb121a1832..836b843971 100644 --- a/crates/engine/src/swatch_cmds/tests.rs +++ b/crates/engine/src/swatch_cmds/tests.rs @@ -14,10 +14,11 @@ fn export_bytes(s: &mut Session, p: Value) -> Vec { fn defaults_and_listing() { let mut s = Session::new(); let r = s.execute("swatches.list", json!({})).unwrap(); - assert_eq!(r["count"], 40); + assert_eq!(r["count"], 46); assert_eq!(r["groups"][0]["name"], "Grays"); assert_eq!(r["groups"][0]["swatches"][0], json!({"name": "Black", "model": "rgb", "values": [0.0, 0.0, 0.0], "hex": "#000000"})); - assert_eq!(r["groups"][2]["swatches"][0]["hex"], "#e62828"); + assert_eq!(r["groups"][1]["swatches"][2]["hex"], "#00ff00", "RGB Green is exact (#4302)"); + assert_eq!(r["groups"][3]["swatches"][0]["hex"], "#e62828"); } #[test] @@ -67,7 +68,7 @@ fn rename_delete_move_and_groups() { assert_eq!(s.presets.swatches[0].items[0].name, "White"); let r = s.execute("swatches.move", json!({"swatch": "White", "to": "Dark", "position": 1})).unwrap(); assert_eq!(r, json!({"group": "Dark", "position": 1})); - assert_eq!(s.presets.swatches[3].items[1].name, "White"); + assert_eq!(s.presets.swatches[4].items[1].name, "White"); // Out-of-range positions clamp. let r = s.execute("swatches.move", json!({"swatch": "White", "position": 999_999})).unwrap(); assert_eq!(r["position"], 10); @@ -80,21 +81,21 @@ fn rename_delete_move_and_groups() { assert!(s.execute("swatches.renameGroup", json!({"group": "Favourites", "name": "Dark"})).is_err()); let r = s.execute("swatches.deleteGroup", json!({"group": "Favourites", "keepSwatches": true})).unwrap(); assert_eq!(r["kept"], 2); - assert!(s.presets.swatches[3].items.iter().any(|i| i.name == "Red"), "kept in the group above"); + assert!(s.presets.swatches[4].items.iter().any(|i| i.name == "Red"), "kept in the group above"); s.execute("swatches.deleteGroup", json!({"group": "Mine 1"})).unwrap(); // Delete several by name, and one by index. let r = s.execute("swatches.delete", json!({"swatch": ["Red", "Blue", "Red"]})).unwrap(); assert_eq!(r["deleted"].as_array().unwrap().len(), 2); let r = s.execute("swatches.delete", json!({"index": 0})).unwrap(); assert_eq!(r["deleted"], json!(["Ink Black"])); - assert_eq!(names(&s).len(), 37); + assert_eq!(names(&s).len(), 43); // Reset restores the defaults; append adds them again with unique names. s.execute("swatches.reset", json!({})).unwrap(); - assert_eq!(names(&s).len(), 40); + assert_eq!(names(&s).len(), 46); s.execute("swatches.reset", json!({"append": true})).unwrap(); - assert_eq!(names(&s).len(), 80); + assert_eq!(names(&s).len(), 92); assert!(names(&s).contains(&"Black 2".to_string())); - assert_eq!(s.presets.swatches[4].name, "Grays 1"); + assert_eq!(s.presets.swatches[5].name, "Grays 1"); } #[test] @@ -220,7 +221,7 @@ fn bad_params_fail_gracefully() { ("swatches.delete", json!({"swatch": "Nope"})), ("swatches.delete", json!({"swatch": ["Red", 5]})), ("swatches.delete", json!({"swatch": ["Red", "Nope"]})), - ("swatches.delete", json!({"index": 40})), + ("swatches.delete", json!({"index": 46})), ("swatches.delete", json!({"index": "0"})), ("swatches.delete", json!({"swatch": "Red", "group": "Grays"})), ("swatches.rename", json!({"swatch": "Red"})), @@ -264,7 +265,7 @@ fn bad_params_fail_gracefully() { assert_eq!(s.presets.swatches, before, "failed commands change nothing"); // Empty library: the swatch commands are disabled, not crashing. s.execute("swatches.delete", json!({"swatch": names(&s)})).unwrap(); - for g in ["Grays", "Pastel", "Pure", "Dark"] { + for g in ["Grays", "RGB", "Pastel", "Pure", "Dark"] { s.execute("swatches.deleteGroup", json!({"group": g})).unwrap(); } for id in ["swatches.delete", "swatches.rename", "swatches.move", "swatches.use", "swatches.export", "swatches.renameGroup", "swatches.deleteGroup"] { From 1f76d839e3825fc3f1bf121c76e8bf252b4a8365 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:11 +0200 Subject: [PATCH 308/523] SVG: a group clipped by one path opens as editable shapes with a vector mask (#4266) (#4425) --- crates/io/src/svg.rs | 90 +++++++++++++++++++++++++++++++++++++++--- crates/io/tests/svg.rs | 3 +- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index b6a7741d6e..04fc4bee3a 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, OnceLock}; use photocraft_color::blend::BlendMode; use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::effects::GradientStyle; -use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath}; +use photocraft_doc::vector::{FillRule, Knot, LineCap, LineJoin, Path, PathOp, ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask}; use photocraft_doc::{Document, Fill, Group, Layer, LayerContent, MAX_GROUP_DEPTH}; use photocraft_geom::{Point, Rect, Size}; use photocraft_raster::Surface; @@ -348,8 +348,11 @@ impl Conv { fn convert_node(&mut self, node: &usvg::Node, out: &mut Vec, depth: usize, rec: usize, parent: tiny_skia::Transform) { match node { usvg::Node::Group(g) => { + // A clip-path of one plain path becomes the group's vector mask (#4266); others + // are rasterised with the group. + let clip = g.clip_path().and_then(|cp| self.clip_mask(g, cp)); let dropped: [(bool, &'static str); 3] = - [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some(), "clip-path"), (g.mask().is_some(), "mask")]; + [(!g.filters().is_empty(), "filter"), (g.clip_path().is_some() && clip.is_none(), "clip-path"), (g.mask().is_some(), "mask")]; let too_deep = self.max_group_depth.is_some_and(|limit| depth >= limit) || depth >= MAX_GROUP_DEPTH || rec >= MAX_RECURSION; if dropped.iter().any(|d| d.0) || too_deep { for (yes, what) in dropped { @@ -366,7 +369,7 @@ impl Conv { } // A group that only carries a transform (baked into its paths by usvg) or that // usvg inserted for its own bookkeeping adds nothing to the layer tree. - let plain = g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; + let plain = clip.is_none() && g.id().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; let children = self.convert_children(g.children(), if plain { depth } else { depth + 1 }, rec + 1, g.abs_transform()); if children.is_empty() { return; @@ -376,9 +379,14 @@ impl Conv { return; } let only_id = g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal; - if only_id && children.len() == 1 { + // A lone child takes the group's name (and its clip, when it has no vector mask of + // its own). + if only_id && children.len() == 1 && (clip.is_none() || children.iter().all(|c| c.vector_mask.is_none())) { if let Some(mut child) = children.into_iter().next() { - child.name = g.id().to_string(); + if !g.id().is_empty() { + child.name = g.id().to_string(); + } + child.vector_mask = clip.or(child.vector_mask); out.push(child); } return; @@ -386,6 +394,7 @@ impl Conv { let mut l = Layer::new(self.name(g.id(), 1), LayerContent::Group(Group { children, expanded: false, artboard: None })); l.opacity = g.opacity().get(); l.blend = blend(g.blend_mode()); + l.vector_mask = clip; out.push(l); } usvg::Node::Path(p) => out.extend(self.shape_layer(node, p, parent)), @@ -412,6 +421,29 @@ impl Conv { } } + /// The vector mask for group `g` clipped by `cp`, when the clip is one visible path with no + /// clip of its own: what resvg draws for the clip (the group's transform, then the clip's, + /// then the path), in canvas pixels, with the clip rule as its fill rule. + fn clip_mask(&self, g: &usvg::Group, cp: &usvg::ClipPath) -> Option { + if cp.clip_path().is_some() { + return None; + } + let [usvg::Node::Path(p)] = cp.root().children() else { return None }; + if !p.is_visible() { + return None; + } + let ts = g.abs_transform().pre_concat(cp.transform()).post_concat(self.global); + let subpaths = knots(&(*p.data()).clone().transform(ts)?); + if subpaths.is_empty() { + return None; + } + let fill_rule = match p.fill().map(|f| f.rule()) { + Some(usvg::FillRule::EvenOdd) => FillRule::EvenOdd, + _ => FillRule::NonZero, + }; + Some(VectorMask::new(Path { subpaths, fill_rule, inverted: false })) + } + /// A path as a shape layer, or a pixel layer when its paint is a pattern. fn shape_layer(&mut self, node: &usvg::Node, p: &usvg::Path, parent: tiny_skia::Transform) -> Option { if !p.is_visible() { @@ -688,6 +720,54 @@ mod tests { assert!(r.warnings.iter().all(|w| !w.contains("font")), "{:?}", r.warnings); } + /// #4266: a group clipped by one path (the PhotoCraft icon's rounded square) stays editable + /// shapes with the clip as its vector mask, and looks as resvg draws the drawing; a clip of + /// two shapes is still rasterised, with the warning. + #[test] + fn a_single_path_clip_becomes_the_groups_vector_mask() { + let svg = br##" + + + + + "##; + let r = import_svg("t", svg).unwrap(); + assert!(r.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", r.warnings); + let [group] = r.document.layers.as_slice() else { panic!("one group: {:?}", r.document.layers.len()) }; + let LayerContent::Group(g) = &group.content else { panic!("a group") }; + assert_eq!(g.children.len(), 2); + assert!(g.children.iter().all(|c| matches!(c.content, LayerContent::Shape(_))), "editable shapes"); + let mask = group.vector_mask.as_ref().expect("the clip as a vector mask"); + let xs: Vec = mask.path.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| k.anchor.x)).collect(); + let (lo, hi) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); + assert!((lo - 12.0).abs() < 1e-3 && (hi - 52.0).abs() < 1e-3, "in the group's space: {lo}..{hi}"); + // Pixel for pixel what resvg draws, but for anti-aliasing along the edges. + let ours = photocraft_compose::flatten(&r.document).to_rgba8().pixels; + let tree = parse(svg).unwrap().tree; + let mut pm = tiny_skia::Pixmap::new(64, 64).unwrap(); + resvg::render(&tree, tiny_skia::Transform::default(), &mut pm.as_mut()); + let theirs: Vec = pm + .pixels() + .iter() + .flat_map(|p| { + let c = p.demultiply(); + [c.red(), c.green(), c.blue(), c.alpha()] + }) + .collect(); + let off = ours.chunks(4).zip(theirs.chunks(4)).filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 48)).count(); + assert!(off < 64, "{off} pixels differ"); + // The report's file: PhotoCraft's own icon opens without the clip-path warning. + let icon = import_svg("photocraft.svg", include_bytes!("../../../assets/app-icon/photocraft.svg")).unwrap(); + assert!(icon.warnings.iter().all(|w| !w.contains("clip-path")), "{:?}", icon.warnings); + assert!(icon.document.layers.iter().any(|l| l.vector_mask.is_some()), "its rounded square is a vector mask"); + // Two shapes in the clip: rasterised, and said so. + let two = br##" + + "##; + let r = import_svg("t", two).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("clip-path")), "{:?}", r.warnings); + } + #[test] fn huge_drawings_scale_to_the_side_limit() { let tree = parse(b"").unwrap().tree; diff --git a/crates/io/tests/svg.rs b/crates/io/tests/svg.rs index adbdf08a03..d1baf418fa 100644 --- a/crates/io/tests/svg.rs +++ b/crates/io/tests/svg.rs @@ -148,10 +148,11 @@ fn gradients_are_approximated_with_a_warning() { #[test] fn clip_paths_masks_filters_patterns_and_images_are_rasterised() { + // A clip of one path becomes a vector mask (#4266); one of two shapes is still rasterised. let r = open(&format!( "\ \ - \ + \ \ \ \ From f586e7133ef302d241b42c3eaabe1881d38d55cc Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:24:15 +0200 Subject: [PATCH 309/523] Compose: key the vector-mask cache by mask content, not a temporary's address (#4426) (#4428) RenderVectorMasks keyed compiled masks by the address of the VectorMask it was given. hiding_mask/effects_source pass temporary clones, whose addresses get reused, so a later layer received an earlier layer's compiled mask. Co-authored-by: Claude Opus 5.5 --- crates/compose/src/advanced_tests.rs | 20 ++++++++++++++++++++ crates/compose/src/lib.rs | 17 +++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/compose/src/advanced_tests.rs b/crates/compose/src/advanced_tests.rs index 9a8723c41d..c19b0dc92f 100644 --- a/crates/compose/src/advanced_tests.rs +++ b/crates/compose/src/advanced_tests.rs @@ -426,6 +426,26 @@ fn vector_mask_hides_effects() { } } +/// The per-render vector-mask cache must key masks by content: `hiding_mask` and +/// `effects_source` hand it temporary clones whose addresses get reused (#4426). +#[test] +fn vector_mask_cache_does_not_mix_up_layers() { + use photocraft_doc::{Path, Subpath, VectorMask}; + for depth in DEPTHS { + let mut d = doc(depth, true); + for (name, color, x0, x1) in [("a", BLUE, -4.0, 6.0), ("b", GREEN, 6.0, 16.0)] { + let mut l = solid(&d, name, full(&d), color); + l.effects.items = vec![inside_stroke()]; + l.advanced.vector_mask_hides_effects = true; + let path = Path::new(vec![Subpath::polygon(&[(x0, -4.0), (x1, -4.0), (x1, 12.0), (x0, 12.0)])]); + l.vector_mask = Some(VectorMask::new(path)); + d.layers.push(l); + } + check(&d, 2, 4, BLUE, "layer A inside its own mask"); + check(&d, 9, 4, GREEN, "layer B inside its own mask"); + } +} + #[test] fn defaults_take_the_plain_paths() { let d = doc(SampleType::U8, true); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ae9e53e39c..5931bea018 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -451,18 +451,27 @@ fn render_reduced_in_bands(doc: &Document, b: Rect, w: u32, h: u32, damage: Opti Buffer { rect: out, px: acc } } +type VectorMaskSlot = std::sync::Arc>; + #[derive(Default)] struct RenderVectorMasks { - masks: std::sync::Mutex>>>, + /// Compiled masks by content. Callers pass temporary clones (see `advanced.rs`), so an + /// address cannot identify a mask: a dropped clone's address is reused by the next one. + masks: std::sync::Mutex>, } impl RenderVectorMasks { fn values(&self, mask: &photocraft_doc::VectorMask, rect: Rect) -> Vec { - // Addresses identify immutable mask instances only for this render's lifetime. - let key = std::ptr::from_ref(mask) as usize; let slot = { let mut masks = self.masks.lock().unwrap_or_else(|e| e.into_inner()); - masks.entry(key).or_default().clone() + match masks.iter().find(|(m, _)| m == mask) { + Some((_, slot)) => slot.clone(), + None => { + let slot = VectorMaskSlot::default(); + masks.push((mask.clone(), slot.clone())); + slot + } + } }; // Compilation is sequential. Rendering can enter Rayon and must happen after // initialization, so waiting tiles cannot re-enter a slot still being built. From b4aea45e1960346dc897bb82de21082eaf3d9b60 Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:24:18 +0200 Subject: [PATCH 310/523] =?UTF-8?q?Actions:=20record=20File=20=E2=80=BA=20?= =?UTF-8?q?Save=20a=20Copy=20and=20play=20it=20back=20as=20a=20copy=20(#43?= =?UTF-8?q?73)=20(#4390)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/actions_cmds.rs | 7 ++-- crates/ui-egui/src/actions.rs | 4 +- .../src/discard_ui/tests/targa_tests.rs | 20 +++++++--- crates/ui-egui/src/file_open/tests.rs | 24 +++++++++++ crates/ui-egui/src/lib.rs | 9 ++++- crates/ui-egui/src/targa_options_ui.rs | 15 +++---- crates/ui-egui/src/view_cmds.rs | 40 +++++++++++++------ 7 files changed, 88 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/actions_cmds.rs b/crates/engine/src/actions_cmds.rs index 3ee242d7d6..3b3ffd1f01 100644 --- a/crates/engine/src/actions_cmds.rs +++ b/crates/engine/src/actions_cmds.rs @@ -115,10 +115,11 @@ pub fn replayable(id: &str) -> bool { && (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal))) } -/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032). -/// The shell replays them; batch and droplets write to their destination folder instead. +/// File › Save, Save As and Save a Copy, recorded by the desktop shell once the save has a path +/// (#2032, #4373). The shell replays them; batch and droplets write to their destination folder +/// instead. pub fn shell_save_command(id: &str) -> bool { - matches!(id, "file.save" | "file.saveAs") + matches!(id, "file.save" | "file.saveAs" | "file.saveACopy") } /// View-only menu commands recorded by the desktop shell. Headless playback reports diff --git a/crates/ui-egui/src/actions.rs b/crates/ui-egui/src/actions.rs index 7902a39bba..e0f3713f2e 100644 --- a/crates/ui-egui/src/actions.rs +++ b/crates/ui-egui/src/actions.rs @@ -37,10 +37,12 @@ struct RenameAction { focused: bool, } -fn label_of(id: &str) -> String { +pub(crate) fn label_of(id: &str) -> String { photocraft_engine::commands::find(id) .map(|c| c.label) .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1)) + // Shell menu items recorded as steps, like Save a Copy (#4373). + .or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).map(|c| c.1)) .map(|label| label.trim_end_matches('…').to_string()) .unwrap_or_else(|| id.to_string()) } diff --git a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs index e4c83bda64..565fe01037 100644 --- a/crates/ui-egui/src/discard_ui/tests/targa_tests.rs +++ b/crates/ui-egui/src/discard_ui/tests/targa_tests.rs @@ -113,7 +113,7 @@ fn save_a_copy_to_a_targa_asks_and_keeps_the_document_unsaved() { crate::menus::invoke(h.state_mut(), &ctx, "file.saveACopy", json!({})).unwrap(); h.run_steps(3); assert_eq!(h.state().targa_options.as_ref().map(|p| (p.bits, p.copy)), Some((TgaBits::Bits32, true))); - assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, true).is_err(), "the waiting prompt is kept"); + assert!(targa_options_ui::park(h.state_mut(), "other.tga".into(), false, "file.saveAs").is_err(), "the waiting prompt is kept"); assert_eq!(h.state().targa_options.as_ref().map(|p| p.path.as_str()), Some("copy.tga")); h.key_press(Key::Enter); h.run_steps(3); @@ -140,8 +140,8 @@ fn targa_options_keeps_a_layered_save_as_a_copy() { assert_eq!(st.path, None); } -/// An action being recorded keeps a Targa Save As with its bits, answered in Targa Options or -/// scripted (#2032), and plays it back without asking; the dialog's Save a Copy isn't recorded. +/// An action being recorded keeps a Targa Save As or Save a Copy with its bits, answered in Targa +/// Options or scripted (#2032, #4373), and plays them back without asking. #[test] fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { let (mut h, writes) = targa_app(true, "copy.tga"); @@ -164,12 +164,22 @@ fn recorded_targa_save_as_keeps_its_bits_and_replays_without_asking() { &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] ); let steps = &h.state().session.actions.list[0].steps; - assert_eq!(steps, &[("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32}))]); + assert_eq!( + steps, + &[ + ("file.saveAs".into(), json!({"path": "t.tga", "tgaBits": 24})), + ("file.saveACopy".into(), json!({"path": "copy.tga", "tgaBits": 32})), + ("file.saveAs".into(), json!({"path": "s.tga", "tgaBits": 32})) + ] + ); writes.borrow_mut().clear(); let played = h.state_mut().run("actions.play", json!({"action": 0})).unwrap(); assert!(played.get("failed").is_none(), "{played}"); assert!(h.state().targa_options.is_none(), "playback doesn't ask"); - assert_eq!(writes.borrow().as_slice(), &[("t.tga".to_string(), asked(Some(24))), ("s.tga".to_string(), asked(Some(32)))]); + assert_eq!( + writes.borrow().as_slice(), + &[("t.tga".to_string(), asked(Some(24))), ("copy.tga".to_string(), asked(Some(32))), ("s.tga".to_string(), asked(Some(32)))] + ); } /// Closing an unsaved document whose save goes to a Targa waits behind Targa Options, then closes. diff --git a/crates/ui-egui/src/file_open/tests.rs b/crates/ui-egui/src/file_open/tests.rs index 9a833b7ba1..67887877e1 100644 --- a/crates/ui-egui/src/file_open/tests.rs +++ b/crates/ui-egui/src/file_open/tests.rs @@ -777,6 +777,30 @@ fn a_set_of_background_opens_keeps_its_order_however_they_finish() { assert_eq!(doc_names(&app), ["dropped.psd", "existing.psd", "f0.psd", "f1.psd", "f2.psd", "f3.psd", "late.psd"]); } +/// #4373: Save a Copy is recorded in an action, like Save and Save As, and played back as a copy: +/// the document keeps its own file and unsaved changes. +#[test] +fn save_a_copy_is_recorded_and_replayed_as_a_copy() { + let (mut app, written) = app_with(vec![Some(FileDialogAnswer::SaveTo("/pics/copy.png".into()))]); + let ctx = egui::Context::default(); + app.open_file("/pics/work.pcraft", b"x").unwrap(); + app.run("layer.new.layer", json!({})).unwrap(); + app.run("actions.record", json!({})).unwrap(); + app.run("edit.fill", json!({"color": "#ff0000"})).unwrap(); + menus::invoke(&mut app, &ctx, "file.saveACopy", json!({})).unwrap(); + answer(&mut app); + app.run("actions.stop", json!({})).unwrap(); + assert_eq!(app.session.actions.list[0].steps.last(), Some(&("file.saveACopy".to_string(), json!({"path": "/pics/copy.png"})))); + assert_eq!(crate::actions::label_of("file.saveACopy"), "Save a Copy"); + written.borrow_mut().clear(); + let played = app.run("actions.play", json!({"action": 0})).unwrap(); + assert!(played.get("failed").is_none(), "{played}"); + assert_eq!(written.borrow().iter().map(|(p, _)| p.as_str()).collect::>(), ["/pics/copy.png"]); + let st = app.session.active().unwrap(); + assert_eq!(st.path.as_deref(), Some("/pics/work.pcraft"), "the document keeps its file"); + assert!(st.is_dirty(), "and its unsaved changes"); +} + /// #4400: a new document saved under a name is listed in Open Recent (and on the Home screen), /// as opening it is; a Save a Copy isn't, since the document doesn't live there. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e599845669..96158c024e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1288,7 +1288,7 @@ impl PhotocraftApp { // which records the step once answered. if tga_bits.is_none() && targa_options_ui::wants_prompt(self, &path) { let copy = self.saves_copy(&path, None)?; - targa_options_ui::park(self, path.clone(), copy, true)?; + targa_options_ui::park(self, path.clone(), copy, "file.saveAs")?; return Ok(serde_json::json!({"path": path, "warnings": []})); } // A layered TIFF asks about its layers first (Preferences › File Handling); the save @@ -1325,7 +1325,7 @@ impl PhotocraftApp { Ok(r) } - /// Replays a recorded `file.save` / `file.saveAs` step: no dialog or prompt, and the + /// Replays a recorded `file.save` / `file.saveAs` / `file.saveACopy` step: no dialog or prompt, and the /// recorded TIFF or Targa answer (if any) stands in for the TIFF or Targa Options prompt. pub(crate) fn replay_save(&mut self, id: &str, params: &Value) -> Result { if self.save_options_open() { @@ -1344,6 +1344,11 @@ impl PhotocraftApp { } let path = params.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("Save As needs a `path`")?; let tga_bits = targa_options_ui::bits_param(params, id)?; + if id == "file.saveACopy" { + // A copy: the document keeps its own file and unsaved state, as when recorded (#4373). + let doc = self.session.active().ok_or("no document")?.doc.clone(); + return crate::view_cmds::write_copy(self, &doc, path, tga_bits); + } self.save_as_step(path.to_string(), params.get("tiffLayers").and_then(Value::as_bool), tga_bits) } diff --git a/crates/ui-egui/src/targa_options_ui.rs b/crates/ui-egui/src/targa_options_ui.rs index afc7f60ba2..c2161a0c9b 100644 --- a/crates/ui-egui/src/targa_options_ui.rs +++ b/crates/ui-egui/src/targa_options_ui.rs @@ -20,9 +20,10 @@ pub struct Prompt { pub bits: TgaBits, /// Save a Copy: the document keeps its path and unsaved changes. pub copy: bool, - /// File › Save As: the answered save is recorded as a `file.saveAs` step, so an action being - /// recorded keeps it (#2032). The dialog's Save a Copy isn't recorded. - pub record: bool, + /// The step the answered save is recorded as, so an action being recorded keeps it: + /// `file.saveAs` for Save As (#2032, even when a flat Targa is written as a copy) and + /// `file.saveACopy` for Save a Copy (#4373). + pub record: &'static str, } /// `true` when `path` names a Targa (`.tga`, and the rarer `.icb`, `.vda`, `.vst`). @@ -43,7 +44,7 @@ pub fn bits_param(params: &serde_json::Value, cmd: &str) -> Result Result<(), String> { +pub fn park(app: &mut PhotocraftApp, path: String, copy: bool, record: &'static str) -> Result<(), String> { if app.save_options_open() { return Err("Answer TIFF or Targa Options before starting another save".into()); } @@ -80,9 +81,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { let settings = ExportSettings { tga_bits: Some(p.bits), ..app.save_settings() }; let step = serde_json::json!({"path": p.path, "tgaBits": p.bits.bits()}); let written = app.with_document(p.doc, |app| app.write_document(p.path, &settings, p.copy)); - if written.is_ok() && p.record { - // An action being recorded keeps the Save As with this answer (#2032). - app.session.journal.push(("file.saveAs".into(), step)); + if written.is_ok() { + // An action being recorded keeps the Save As or Save a Copy with this answer (#2032). + app.session.journal.push((p.record.into(), step)); } match written { Err(e) => { diff --git a/crates/ui-egui/src/view_cmds.rs b/crates/ui-egui/src/view_cmds.rs index 585e5dd074..09d759ed45 100644 --- a/crates/ui-egui/src/view_cmds.rs +++ b/crates/ui-egui/src/view_cmds.rs @@ -1387,7 +1387,8 @@ fn front(app: &mut PhotocraftApp, id: &str, params: &Value) -> Option Result { let doc = app.session.active().ok_or("no document")?.doc.clone(); let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a); @@ -1398,27 +1399,40 @@ fn save_a_copy(app: &mut PhotocraftApp) -> Result { let parked = app.with_document(doc.id, |app| { let ask = crate::targa_options_ui::wants_prompt(app, &path); if ask { - crate::targa_options_ui::park(app, path.clone(), true, false)?; + crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?; } Ok(ask) })?; if parked { return Ok(json!({"path": path, "warnings": []})); } - let settings = app.save_settings(); - let export = app.services.export.as_ref().ok_or("no exporter configured")?; - let (bytes, warnings) = export(&doc, &path, &settings)?; - let write = app.services.write.as_mut().ok_or("no writer configured")?; - write(&path, &bytes)?; - let template = "Saved a copy as {path}"; - app.ui.status = crate::i18n::fmt(tl!(template), &[("path", &path)]); - app.ui.status_error = false; - let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(&path))]); - crate::notices::io_warnings(app, &what, &warnings); - Ok(json!({"path": path, "warnings": warnings})) + let r = write_copy(app, &doc, &path, None)?; + app.session.journal.push(("file.saveACopy".into(), json!({"path": path}))); + Ok(r) }) } +/// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's +/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one. +pub(crate) fn write_copy( + app: &mut PhotocraftApp, + doc: &photocraft_doc::Document, + path: &str, + tga_bits: Option, +) -> Result { + let settings = crate::ExportSettings { tga_bits, ..app.save_settings() }; + let export = app.services.export.as_ref().ok_or("no exporter configured")?; + let (bytes, warnings) = export(doc, path, &settings)?; + let write = app.services.write.as_mut().ok_or("no writer configured")?; + write(path, &bytes)?; + let template = "Saved a copy as {path}"; + app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]); + app.ui.status_error = false; + let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(path))]); + crate::notices::io_warnings(app, &what, &warnings); + Ok(json!({"path": path, "warnings": warnings})) +} + /// Layer › Smart Objects › Export Contents: saves the contents file under its own name by default. fn export_contents(app: &mut PhotocraftApp) -> Result { let st = app.session.active().ok_or("no document")?; From 2842973dc01c437e7318e37faf1fa90aa2e4545d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 04:35:39 +0200 Subject: [PATCH 311/523] Compose: no overflow or unbounded allocation on hostile coordinates (#4427) (#4431) Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/compose/src/adjust.rs | 20 ++++++++++++++++++++ crates/compose/src/effects.rs | 13 +++++++++++++ crates/compose/src/lib.rs | 11 ++++++++++- crates/compose/src/masks.rs | 20 +++++++++++++++++++- crates/compose/src/pattern.rs | 12 +++++++++++- crates/compose/src/proxy.rs | 9 ++++++--- crates/compose/src/tests.rs | 10 ++++++++++ 7 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs index edba488262..3c5ae5e465 100644 --- a/crates/compose/src/adjust.rs +++ b/crates/compose/src/adjust.rs @@ -493,6 +493,10 @@ pub fn bayer4(x: i32, y: i32) -> f32 { /// Samples a 3D LUT (`n`³ RGB triplets, red fastest) at `c`, trilinear or tetrahedral. pub fn lut3d_sample(lut: &[f32], n: usize, c: [f32; 3], tetrahedral: bool) -> [f32; 3] { + // A degenerate or short table can't be sampled: leave the colour alone. + if n < 2 || n.checked_pow(3).and_then(|c| c.checked_mul(3)).is_none_or(|len| lut.len() < len) { + return c; + } let m = (n - 1) as f32; let pos = c.map(|v| v.clamp(0.0, 1.0) * m); let i0 = pos.map(|p| (p.floor() as usize).min(n - 2)); @@ -1528,3 +1532,19 @@ mod tone_tests { /// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128, /// measured on Photoshop 27.11; a 0.5 pivot moved it to 130). pub const LEGACY_PIVOT: f32 = 128.0 / 255.0; + +#[cfg(test)] +mod lut3d_guard_tests { + use super::lut3d_sample; + + #[test] + fn degenerate_or_short_tables_leave_the_colour_alone() { + let c = [0.2, 0.4, 0.6]; + for tetra in [false, true] { + assert_eq!(lut3d_sample(&[], 0, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 1, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 3], 2, c, tetra), c); + assert_eq!(lut3d_sample(&[0.0; 24], usize::MAX, c, tetra), c); + } + } +} diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 33af1a08b6..97d08f00da 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -95,6 +95,10 @@ impl Map { /// Shifted copy (bilinear), outside reads `outside`. fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map { let mut out = Map::new(self.w, self.h, 0.0); + // A hostile distance (±1e30, inf, NaN) would overflow the i64 offsets below; no shift beyond + // 2^30 px can land inside a map, so clamp (non-finite shifts read as none). + let reach = |d: f32| if d.is_finite() { d.clamp(-1.0e9, 1.0e9) } else { 0.0 }; + let (dx, dy) = (reach(dx), reach(dy)); let (fx, fy) = (dx.floor(), dy.floor()); let (ax, ay) = (dx - fx, dy - fy); let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] }; @@ -3122,4 +3126,13 @@ mod tests { let diff: f32 = plain.v.iter().zip(&noisy.v).map(|(a, b)| (a - b).abs()).sum(); assert!(diff > 5.0, "glow noise changes the coverage: {diff}"); } + + #[test] + fn shifting_by_hostile_distances_does_not_overflow() { + let m = square_shape(12); + for d in [1.0e30, -1.0e30, f32::INFINITY, f32::NAN] { + let s = m.shifted(d, d, 0.0); + assert_eq!(s.v.len(), 144); + } + } } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5931bea018..3047cb4881 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -88,6 +88,11 @@ pub fn render(doc: &Document, rect: Rect) -> Buffer { render_tiled(doc, rect, RENDER_TILE) } +/// Most pixels one [`render`] call will produce (2^30, a 16 GiB buffer; 32768² px). Larger rects +/// (a hostile canvas size is up to 300000 px a side) return an empty buffer instead of aborting +/// on allocation; callers wanting more render in pieces. +pub const MAX_RENDER_PIXELS: u64 = 1 << 30; + /// [`render`] with an explicit tile size (tests check tile independence). pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES).in_mode(doc.pixel_format().mode); @@ -107,8 +112,12 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer if rect.is_empty() { return Buffer::transparent(rect); } + // Refuse rects too large to hold: an empty buffer, not an allocation failure. + if u64::from(rect.width()) * u64::from(rect.height()) > MAX_RENDER_PIXELS { + return Buffer::transparent(Rect::EMPTY); + } cx.set_fx_clip(rect); - if rect.width() as i32 <= tile && rect.height() as i32 <= tile { + if i64::from(rect.width()) <= i64::from(tile) && i64::from(rect.height()) <= i64::from(tile) { return photocraft_color::convert::with_cmyk_space(cmyk, || { let mut buf = multichannel::backdrop(doc, rect); in_blend_space(lab, hdr, || composite_stack_at(&doc.layers, &mut buf, cx, advanced::Scope::ROOT)); diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index c14f73e083..d70947ec7c 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -344,7 +344,15 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv), + Some((x0, y0, x1, y1)) => grow( + Rect::new( + (x0.floor() as i32).saturating_sub(2), + (y0.floor() as i32).saturating_sub(2), + (x1.ceil() as i32).saturating_add(2), + (y1.ceil() as i32).saturating_add(2), + ), + sv, + ), None => Rect::EMPTY, }; if let Some(m) = pixel @@ -761,4 +769,14 @@ mod tests { l.vector_mask = None; assert!(combined_mask(&l, canvas).is_none()); } + + #[test] + fn hostile_path_coordinates_do_not_overflow() { + let mut l = Layer::raster("l", PixelFormat::RGBA8); + let knots = [(-1.0e30, -1.0e30), (1.0e30, 0.0), (0.0, 1.0e30)].iter().map(|&(x, y)| Knot::corner(x, y)).collect(); + let mut path = Path::default(); + path.subpaths.push(Subpath { knots, closed: true, ..Default::default() }); + l.vector_mask = Some(VectorMask::new(path)); + let _ = combined_mask(&l, Rect::new(0, 0, 8, 8)); + } } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index a2c5c52fb9..d107ecf4e7 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -159,7 +159,7 @@ impl Tile { if wx == 0.0 { continue; } - let p = self.at(x0 + dx, y0 + dy); + let p = self.at(x0.saturating_add(dx), y0.saturating_add(dy)); let k = wx * wy; for c in 0..4 { acc[c] += p[c] * k; @@ -287,4 +287,14 @@ mod tests { let (u, v) = rot.map(1.0, 3.0); assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + + #[test] + fn sampling_at_hostile_phases_does_not_overflow() { + let patterns = [checker()]; + let prepared = PreparedPatterns::new(&patterns, PREPARED_PATTERN_BYTES); + let t = prepared.get(&patterns[0].id, "").unwrap(); + for u in [1.0e30, -1.0e30, f64::INFINITY, f64::NAN] { + let _ = t.sample(u, u); + } + } } diff --git a/crates/compose/src/proxy.rs b/crates/compose/src/proxy.rs index 7fe7fc3983..84c9348029 100644 --- a/crates/compose/src/proxy.rs +++ b/crates/compose/src/proxy.rs @@ -20,12 +20,15 @@ pub fn downsample(s: &Surface, k: u32) -> Surface { let bpp = s.format().bytes_per_pixel(); let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0)); let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0)); - let w = (x1 - x0).max(0) as usize; + let w = x1.saturating_sub(x0).max(0) as usize; let mut row = vec![0u8; w * bpp]; for oy in y0..y1 { - let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1)); + let src = s.to_interleaved(Rect::new(x0.saturating_mul(k), oy.saturating_mul(k), x1.saturating_mul(k), oy.saturating_mul(k).saturating_add(1))); for (i, px) in row.chunks_exact_mut(bpp).enumerate() { - px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]); + // Saturated edges (a surface at the i32 limits) can make `src` shorter than expected. + if let Some(from) = (i * k as usize * bpp).checked_add(bpp).and_then(|end| src.get(i * k as usize * bpp..end)) { + px.copy_from_slice(from); + } } out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e8bf005e59..c13a423ca8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2488,6 +2488,16 @@ fn lab_documents_gradient_map_follows_lightness() { assert!((buf.px[0][0] - grey_buf.px[0][0]).abs() > 0.05, "{:?} {:?}", buf.px[0], grey_buf.px[0]); } +#[test] +fn render_refuses_rects_beyond_the_pixel_cap() { + let doc = Document::new("c", Size::new(4, 4), ColorMode::Rgb, SampleType::U8); + // 2^32 - 1 px wide: the width wraps negative as an i32, which once took the single-tile path. + for tile in [crate::RENDER_TILE, 1 << 20] { + let b = crate::render_tiled(&doc, Rect::new(i32::MIN, 0, i32::MAX, 1), tile); + assert!(b.px.is_empty() && b.rect.is_empty()); + } +} + // The effect-map cache key covers Transparency Shapes Layer and Blend If: both change the shape // the maps are built from (a layer's own, or a styled group's children composite) without // touching pixels, masks or effects, and stale maps kept stroking a hole the layer no longer From 05e98e7ade3749345169c7f317e9e82154a84daf Mon Sep 17 00:00:00 2001 From: F4LCON <107556180+itsF4LCON@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:35:54 +0200 Subject: [PATCH 312/523] =?UTF-8?q?Smart=20Filters:=20select=20a=20filter,?= =?UTF-8?q?=20drag=20or=20=E2=8C=A5-drag=20it=20onto=20another=20smart=20o?= =?UTF-8?q?bject=20(#4271)=20(#4371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/smart_cmds.rs | 78 +++++++++ crates/engine/src/smart_cmds/tests.rs | 63 +++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panels.rs | 17 +- crates/ui-egui/src/smart_filter_rows_tests.rs | 164 ++++++++++++++++++ crates/ui-egui/src/smart_ui.rs | 91 +++++++++- docs/ui-parity.md | 4 +- 7 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/smart_filter_rows_tests.rs diff --git a/crates/engine/src/smart_cmds.rs b/crates/engine/src/smart_cmds.rs index 635c5bb088..e108c4d7be 100644 --- a/crates/engine/src/smart_cmds.rs +++ b/crates/engine/src/smart_cmds.rs @@ -1069,6 +1069,73 @@ fn move_filter(s: &mut Session, p: &Value) -> Result { }) } +/// Dragging smart filters onto another smart object in the Layers panel (#4271): they move there, +/// or are copied with `copy` (⌥-drag in Photoshop). One filter (`filter`) lands on top of the +/// target's filters; all of them (the Smart Filters row) are added on top too, with the filter +/// mask when the target has none. Both smart objects are re-rendered in one undo step. +fn transfer_filters(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.smartFilter.transfer"; + let id_of = |key: &str| p.get(key).and_then(Value::as_u64).map(LayerId).ok_or_else(|| bad(CMD, format!("`{key}` must be a layer id"))); + let (from, to) = (id_of("from")?, id_of("to")?); + if from == to { + return Err(bad(CMD, "the smart filters are already on that layer")); + } + // The other `layer.smartFilter.*` commands name a filter `index`; here a guessed `index` + // would otherwise be dropped and every filter moved. + if p.get("index").is_some() { + return Err(bad(CMD, "name the filter with `filter` (no `filter`: all of them)")); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + let src = smart(doc, from)?; + smart(doc, to).map_err(|_| bad(CMD, "smart filters can only be dropped on a smart object"))?; + let n = src.smart_filters.len(); + if n == 0 { + return Err(other("the smart object has no smart filters")); + } + let index = match p.get("filter") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|i| i as usize).filter(|i| *i < n).ok_or_else(|| bad(CMD, format!("`filter` must be 0..{n} (0 = bottom filter)")))?), + }; + let (filters, mask) = match index { + Some(i) => (vec![src.smart_filters[i].clone()], None), + None => (src.smart_filters.clone(), src.filter_mask.clone()), + }; + let label = match (copy, index.is_some()) { + (true, true) => "Copy Smart Filter", + (true, false) => "Copy Smart Filters", + (false, true) => "Move Smart Filter", + (false, false) => "Move Smart Filters", + }; + crate::jobs::edit_job( + s, + label, + move |doc, _, ctx| { + let dst = smart_mut(doc, to)?; + dst.smart_filters.extend(filters); + if dst.filter_mask.is_none() { + dst.filter_mask = mask; + } + refresh_or_fail_in(doc, to, ctx)?; + if !copy { + let src = smart_mut(doc, from)?; + match index { + Some(i) => { + src.smart_filters.remove(i); + } + None => { + src.smart_filters.clear(); + src.filter_mask = None; + } + } + refresh_or_fail_in(doc, from, ctx)?; + } + Ok(()) + }, + move |()| json!({"from": from.0, "to": to.0}), + ) +} + // ---------- enablement ---------- fn active_smart(s: &Session) -> std::result::Result<&SmartObject, String> { @@ -1088,6 +1155,9 @@ fn has_linked(s: &Session) -> std::result::Result<(), String> { _ => Err("the smart object is embedded".into()), } } +fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} fn has_smart_filters(s: &Session) -> std::result::Result<(), String> { if active_smart(s)?.smart_filters.is_empty() { Err("the smart object has no smart filters".into()) } else { Ok(()) } } @@ -1269,6 +1339,14 @@ pub fn specs() -> Vec { ), spec!("layer.smartFilter.delete", "Delete Smart Filter", &[], r##"{"layer":id?,"index":u32?}"##, has_smart_filters, delete_filter), spec!("layer.smartFilter.move", "Move Smart Filter", &[], r##"{"layer":id?,"index":u32?,"to":u32}"##, has_smart_filters, move_filter), + spec!( + "layer.smartFilter.transfer", + "Move or Copy Smart Filters", + &[], + r##"{"from":id,"to":id,"filter":u32? (default: all, with the filter mask),"copy":bool=false}"##, + has_doc, + transfer_filters + ), ] } diff --git a/crates/engine/src/smart_cmds/tests.rs b/crates/engine/src/smart_cmds/tests.rs index aa88fe3fed..a6cdbf48d5 100644 --- a/crates/engine/src/smart_cmds/tests.rs +++ b/crates/engine/src/smart_cmds/tests.rs @@ -247,6 +247,69 @@ fn smart_filter_commands_check_an_explicit_layer_target() { assert_eq!(active(&s), curves); } +/// #4271: dragging smart filters onto another smart object moves them, ⌥-dragging copies them; +/// one filter or all of them (with the filter mask), each in one undo step. +#[test] +fn smart_filters_move_or_copy_to_another_smart_object() { + let mut s = session(8); + paint(&mut s); + let a = convert(&mut s); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 48})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 5, "undefinedAreas": "transparent"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s); + let b = convert(&mut s); + let pixel = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + let so = |s: &Session, id: u64| match &s.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.clone(), + other => panic!("not smart: {}", other.kind_name()), + }; + let commands = |sm: &SmartObject| sm.smart_filters.iter().map(|f| f.command.clone()).collect::>(); + let (offset, blur) = ("filter.other.offset", "filter.blur.gaussianBlur"); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, a).filter_mask.is_some()); + let before = flat(&s); + + // ⌥-drag the blur: B gets a copy (re-rendered), A keeps it. + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 1, "copy": true})).unwrap(); + assert_eq!(commands(&so(&s, b)), [blur]); + assert_eq!(commands(&so(&s, a)), [offset, blur]); + assert!(so(&s, b).filter_mask.is_none(), "one filter comes without the mask"); + assert!(max_diff(&flat(&s), &before) > 1e-3, "B is blurred"); + s.undo(); + assert!(so(&s, b).smart_filters.is_empty()); + assert_eq!(flat(&s), before); + + // Drag the offset: it moves to the top of B's filters. + s.redo(); + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b, "filter": 0})).unwrap(); + assert_eq!(commands(&so(&s, a)), [blur]); + assert_eq!(commands(&so(&s, b)), [blur, offset]); + + // Drag the Smart Filters row: all of A's filters and its filter mask move to B. + let mask = so(&s, a).filter_mask; + s.execute("layer.smartFilter.transfer", json!({"from": a, "to": b})).unwrap(); + assert!(so(&s, a).smart_filters.is_empty() && so(&s, a).filter_mask.is_none()); + assert_eq!(commands(&so(&s, b)), [blur, offset, blur]); + assert_eq!(so(&s, b).filter_mask, mask); + s.undo(); + assert_eq!(so(&s, a).smart_filters.len(), 1); + assert_eq!(so(&s, b).smart_filters.len(), 2); + + // Refused: onto itself, onto a layer that isn't a smart object, a bad index, no filters. + let transfer = |s: &mut Session, p: Value| s.execute("layer.smartFilter.transfer", p); + assert!(transfer(&mut s, json!({"from": a, "to": a})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": pixel})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "filter": 5})).is_err()); + assert!(transfer(&mut s, json!({"from": a, "to": b, "index": 0})).is_err(), "`index` isn't taken for `filter`"); + assert!(transfer(&mut s, json!({"from": pixel, "to": b})).is_err()); + s.execute("layer.smartFilter.clearSmartFilters", json!({"layer": a})).unwrap(); + assert!(transfer(&mut s, json!({"from": a, "to": b})).is_err()); + assert_eq!(so(&s, b).smart_filters.len(), 2, "nothing changed"); +} + #[test] fn selection_becomes_the_filter_mask() { let mut s = session(8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96158c024e..cc8d8bdf27 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2390,6 +2390,9 @@ mod color_tab_theme_tests; #[cfg(test)] mod new_layer_button_tests; +#[cfg(test)] +mod smart_filter_rows_tests; + #[cfg(test)] mod move_outline_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 78f1715c98..3624dbbf5a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2247,8 +2247,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } - let held = ctx.data(|d| d.get_temp::(egui::Id::new("layer-drag")).is_some() || d.get_temp::(fx_drag_key()).is_some()); + let held = ctx.data(|d| { + d.get_temp::(egui::Id::new("layer-drag")).is_some() + || d.get_temp::(fx_drag_key()).is_some() + || d.get_temp::(crate::smart_ui::drag_key()).is_some() + }); let dragging = held && ctx.input(|i| i.pointer.primary_down()); let pointer = ctx.input(|i| i.pointer.interact_pos()); let delta = layer_drag_edge_scroll(pointer, ui.clip_rect(), dragging, ctx.input(|i| i.stable_dt)); @@ -2332,6 +2337,9 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // An effect row can be dropped on Delete too (#3102), and a mask's thumbnail (#3113, #3119). let fx_footer_drag = ctx.data(|d| d.get_temp::(fx_drag_key())); fx_drag_feedback(&ctx, &doc); + // And a smart filter's row (#4271). + let filter_footer_drag = ctx.data(|d| d.get_temp::(crate::smart_ui::drag_key())); + crate::smart_ui::drag_feedback(&ctx, &doc); let mask_footer_drag = ctx.data(|d| d.get_temp::(mask_drag_key())); if let Some((_, kind)) = mask_footer_drag && let Some(p) = ctx.input(|i| i.pointer.interact_pos()) @@ -2344,6 +2352,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); ctx.data_mut(|d| d.remove::(fx_drag_key())); ctx.data_mut(|d| d.remove::(mask_drag_key())); + ctx.data_mut(|d| d.remove::(crate::smart_ui::drag_key())); } widgets::panel_footer(ui, |ui| { let active_layer = active.and_then(|id| doc.layer(id)); @@ -2363,6 +2372,11 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag)); + if let Some(drag) = filter_footer_drag + && dropped_on(ui, &trash) + { + actions.push(crate::smart_ui::trash_action(drag)); + } // Photoshop asks "Apply mask to layer before removing?" or "Delete the vector mask?" // (`mask_trash_prompt`). if let Some((layer, kind)) = mask_footer_drag @@ -2642,6 +2656,7 @@ fn layer_row( let mask_drag = mask_drag.is_some(); layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions); fx_drop(ctx, ui, l, rect, actions); + crate::smart_ui::drop_on_row(ctx, ui, l, rect, actions); if resp.drag_started() && !mask_drag { crate::layer_transfer::begin_from_panel(app, ctx, l.id); } diff --git a/crates/ui-egui/src/smart_filter_rows_tests.rs b/crates/ui-egui/src/smart_filter_rows_tests.rs new file mode 100644 index 0000000000..519402672c --- /dev/null +++ b/crates/ui-egui/src/smart_filter_rows_tests.rs @@ -0,0 +1,164 @@ +//! Layers panel Smart Filters rows (#4271): a click selects a filter (and its smart object), a +//! drag onto another smart object moves it there, ⌥-drag copies it, and a drag onto Delete +//! removes it, as in Photoshop. + +use egui::accesskit::{Role, Toggled}; +use egui::{Modifiers, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::{LayerContent, LayerId}; +use serde_json::json; + +use crate::PhotocraftApp; + +/// Smart objects "Smart A" (Gaussian Blur and Offset) and "Smart B" (no filters), and a pixel +/// layer "Pixels" on top: (harness, A, B, pixels). +fn panel() -> (egui_kittest::Harness<'static, PhotocraftApp>, u64, u64, u64) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 16, "height": 16})).unwrap(); + let smart = |s: &mut photocraft_engine::Session, name: &str| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"contents": "black"})).unwrap(); + let id = s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": id, "name": name})).unwrap(); + id + }; + let a = smart(&mut s, "Smart A"); + s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 2})).unwrap(); + let b = smart(&mut s, "Smart B"); + let c = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setProps", json!({"layer": c, "name": "Pixels"})).unwrap(); + // 60 fps steps, so a double-click's two clicks fall inside egui's 0.3 s window. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_max_steps(64).with_step_dt(1.0 / 60.0).build_eframe(|cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + PhotocraftApp::new(s, crate::Services::default()) + }); + h.run_steps(4); + (h, a, b, c) +} + +fn filters(h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64) -> Vec { + match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().map(|f| f.command.clone()).collect(), + _ => Vec::new(), + } +} + +/// The centre of the Layers panel row (or footer button) labelled `label`: rows are selectable +/// labels, which AccessKit calls buttons, unlike the same name drawn elsewhere. +fn at(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> Pos2 { + h.query_all_by_label(label).find(|n| n.accesskit_node().role() == Role::Button).unwrap_or_else(|| panic!("`{label}` drawn")).rect().center() +} + +fn selected(h: &egui_kittest::Harness<'static, PhotocraftApp>, label: &str) -> bool { + h.get_by_role_and_label(Role::Button, label).accesskit_node().toggled() == Some(Toggled::True) +} + +/// Press at `from`, move to `to` in steps and release there, with `modifiers` held on release. +fn drag(h: &mut egui_kittest::Harness<'static, PhotocraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for i in 1..=6 { + h.hover_at(from + (to - from) * (i as f32 / 6.0)); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(modifiers)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.run_steps(4); + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +#[test] +fn a_click_selects_the_filter_and_its_smart_object() { + let (mut h, a, _, c) = panel(); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + h.get_by_role_and_label(Role::Button, "Gaussian Blur").click(); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(a)), "the smart object becomes active"); + assert!(selected(&h, "Gaussian Blur")); + assert!(!selected(&h, "Offset")); + // Another layer: the filter is no longer shown selected. + let pixels = at(&h, "Pixels"); + h.hover_at(pixels); + h.run_steps(1); + h.drag_at(pixels); + h.run_steps(1); + h.drop_at(pixels); + h.run_steps(4); + assert_eq!(h.state().session.active().unwrap().active_layer, Some(LayerId(c))); + assert!(!selected(&h, "Gaussian Blur")); +} + +#[test] +fn dragging_a_filter_onto_another_smart_object_moves_it_and_alt_copies_it() { + let (mut h, a, b, _) = panel(); + let (blur, offset) = ("filter.blur.gaussianBlur", "filter.other.offset"); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + assert_eq!(filters(&h, b), [blur], "⌥-drag copies"); + assert_eq!(filters(&h, a), [blur, offset]); + let (from, to) = (at(&h, "Offset"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, b), [blur, offset], "a drag moves"); + assert_eq!(filters(&h, a), [blur]); +} + +#[test] +fn a_pixel_layer_does_not_take_smart_filters() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Gaussian Blur"), at(&h, "Pixels")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a).len(), 2); + assert!(!h.state().ui.status_error, "{}", h.state().ui.status); +} + +#[test] +fn dragging_filters_onto_delete_removes_them() { + let (mut h, a, _, _) = panel(); + let (from, to) = (at(&h, "Offset"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert_eq!(filters(&h, a), ["filter.blur.gaussianBlur"]); + let (from, to) = (at(&h, "Smart Filters"), at(&h, "Delete layer")); + drag(&mut h, from, to, Modifiers::NONE); + assert!(filters(&h, a).is_empty()); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 4, "no layer was deleted"); +} + +/// The report's case: the Camera Raw Filter is ⌥-dragged to another smart object with its +/// settings, and double-clicking the copy opens Camera Raw on that smart object's filter. +#[test] +fn a_camera_raw_filter_copies_and_its_copy_opens_on_the_target() { + let (mut h, a, b, _) = panel(); + h.state_mut().run("layer.select", json!({"layer": a})).unwrap(); + h.state_mut().run("filter.cameraRaw", json!({"exposure": 1.0, "contrast": 20})).unwrap(); + h.run_steps(4); + let (from, to) = (at(&h, "Camera Raw Filter"), at(&h, "Smart B")); + drag(&mut h, from, to, Modifiers::ALT); + let raw = |h: &egui_kittest::Harness<'static, PhotocraftApp>, id: u64| match &h.state().session.active().unwrap().doc.layer(LayerId(id)).unwrap().content { + LayerContent::Smart(sm) => sm.smart_filters.iter().find(|f| f.command == "filter.cameraRaw").map(|f| f.params.clone()), + _ => None, + }; + assert_eq!(raw(&h, b), raw(&h, a), "copied with its settings"); + assert_eq!(raw(&h, b).unwrap()["exposure"], 1.0); + // B is above A in the panel, so its copy is the upper of the two Camera Raw rows. + let copy = h + .query_all_by_label("Camera Raw Filter") + .filter(|n| n.accesskit_node().role() == Role::Button) + .map(|n| n.rect().center()) + .min_by(|p, q| p.y.total_cmp(&q.y)) + .unwrap(); + for _ in 0..2 { + h.hover_at(copy); + h.drag_at(copy); + h.run_steps(1); + h.drop_at(copy); + h.run_steps(1); + } + h.run_steps(3); + let dialog = h.state().camera_raw.as_ref().expect("Camera Raw opened"); + assert_eq!(dialog.layer, LayerId(b)); +} diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..ed31092016 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,8 +1,9 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows -//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). +//! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters; click +//! to select one; drag onto another smart object to move it there, ⌥-drag to copy it, #4271). -use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; -use photocraft_doc::{Layer, LayerContent, SmartObject}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartObject}; use serde_json::{Value, json}; use crate::theme::Tokens; @@ -41,10 +42,20 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } let t = Tokens::get(ui.ctx()); let indent = 30.0 + depth as f32 * 14.0 + 34.0; + let Some(st) = app.session.active() else { return }; + let (active, doc) = (st.active_layer == Some(l.id), st.doc.id); + let picked = ui.ctx().data(|d| d.get_temp::(selected_key(doc))); let rows = std::iter::once(None).chain((0..sm.smart_filters.len()).rev().map(Some)); for index in rows { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); - if resp.hovered() { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag()); + if resp.drag_started() { + ui.ctx().data_mut(|d| d.insert_temp::(drag_key(), (l.id.0, index))); + } + // Selected as in Photoshop: the row stays highlighted while its smart object is active. + let selected = active && picked == Some((l.id.0, index)); + if selected { + ui.painter().rect_filled(rect, 0.0, if t.pro { t.row_selected } else { t.hover }); + } else if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.35)); } if t.pro { @@ -75,7 +86,14 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: ui.painter().rect_filled(sw, 1.0, if sm.filter_mask.as_ref().is_some_and(|m| m.enabled) { Color32::from_gray(200) } else { Color32::WHITE }); ui.painter().rect_stroke(sw, 1.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); } - ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + ui.painter().text(pos2(x, rect.center().y), Align2::LEFT_CENTER, &name, egui::FontId::proportional(11.5), if on { t.text_dim } else { t.text_faint }); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name)); + if resp.clicked() { + ui.ctx().data_mut(|d| d.insert_temp::(selected_key(doc), (l.id.0, index))); + if !active { + actions.push(("layer.select".into(), json!({"layer": l.id.0}))); + } + } if let Some(i) = index && resp.double_clicked() { @@ -84,6 +102,67 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: } } +/// Smart filter rows being dragged: the smart object and the filter index (`None` = the Smart +/// Filters row, all of them). Also what a click selects. +pub(crate) type FilterDrag = (u64, Option); + +pub(crate) fn drag_key() -> egui::Id { + egui::Id::new("smart-filter-drag") +} + +/// The selected smart filter row of a document. +fn selected_key(doc: photocraft_doc::DocId) -> egui::Id { + egui::Id::new(("smart-filter-selected", doc)) +} + +/// While smart filters are dragged: their name by the pointer, and the copy cursor while ⌥ is held. +pub(crate) fn drag_feedback(ctx: &egui::Context, doc: &Document) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + let label = match (filter, doc.layer(LayerId(from)).map(|l| &l.content)) { + (Some(i), Some(LayerContent::Smart(sm))) => sm.smart_filters.get(i).map_or_else(String::new, filter_label), + _ => "Smart Filters".to_string(), + }; + crate::layer_transfer::ghost(ctx, p, &label); + if ctx.input(|i| i.modifiers.alt) { + ctx.set_cursor_icon(egui::CursorIcon::Copy); + } +} + +/// The command for dropping the smart filters of `from` on the smart object `target`: they move +/// there, or with ⌥ held on release are copied (Photoshop). +pub(crate) fn drop_action(from: u64, filter: Option, target: LayerId, copy: bool) -> (String, Value) { + let mut payload = json!({"from": from, "to": target.0, "copy": copy}); + if let (Some(i), Some(o)) = (filter, payload.as_object_mut()) { + o.insert("filter".into(), json!(i)); + } + ("layer.smartFilter.transfer".into(), payload) +} + +/// Another smart object's row accepts the smart filters being dragged: outlined while the pointer +/// is over it, dropped on release. Other layers don't take them, as in Photoshop. +pub(crate) fn drop_on_row(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect, actions: &mut Vec<(String, Value)>) { + let Some((from, filter)) = ctx.data(|d| d.get_temp::(drag_key())) else { return }; + let Some(p) = ctx.input(|i| i.pointer.interact_pos()) else { return }; + if from == l.id.0 || !matches!(l.content, LayerContent::Smart(_)) || !rect.contains(p) { + return; + } + let t = Tokens::get(ctx); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push(drop_action(from, filter, l.id, ctx.input(|i| i.modifiers.alt))); + } +} + +/// Smart filters dropped on the footer's Delete button: a filter's row deletes that filter, the +/// Smart Filters row clears them all. +pub(crate) fn trash_action((layer, filter): FilterDrag) -> (String, Value) { + match filter { + Some(i) => ("layer.smartFilter.delete".into(), json!({"layer": layer, "index": i})), + None => ("layer.smartFilter.clearSmartFilters".into(), json!({"layer": layer})), + } +} + /// Re-opens a smart filter's dialog with its recorded parameters; OK runs /// `layer.smartFilter.setParams` instead of adding another filter (see `dialogs::confirm`). pub fn open_editor(app: &mut PhotocraftApp, ctx: &egui::Context, layer: u64, sm: &SmartObject, index: usize) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8c0e0d2580..708e5276a3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -210,6 +210,7 @@ Paragraph; the rest open as in-app floating windows. | Themes: Photoshop's four greys | partial | five themes; #2661 | | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | +| Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | ## Revision history @@ -226,3 +227,4 @@ Paragraph; the rest open as in-app floating windows. | 2026-10-11 | minor | Type formatting keys done: ⇧⌘K, U, B, I and /, ⌥↑/↓, ⇧⌥↑/↓, ⌥←/→ over a selection and ⌥⇧⌘H format the selected type while editing, and no longer reach Color Settings, Desaturate, Auto Color or Inverse | | 2026-10-12 | minor | Pen Auto Add/Delete on the active path, with its options-bar checkbox, hover feedback and + / − cursor badge (#4144) | | 2026-10-12 | minor | Toolbar tool shortcuts can be reassigned, removed and reset in Keyboard Shortcuts (#4086); capture, restart, group cycling, conflict and text-focus regressions | +| 2026-10-12 | minor | Smart filter rows can be selected, and dragged (⌥: copied) onto another smart object or onto Delete (#4271) | From 0a32b3593d8348d37e302ddfa40689bb524ce831 Mon Sep 17 00:00:00 2001 From: Scott Campbell Date: Mon, 12 Oct 2026 22:41:38 -0400 Subject: [PATCH 313/523] Add Krita (.kra) import (#4388) Open .kra and .krz with paint layers (8/16-bit RGBA and gray, any offset), nested and pass-through groups, names, visibility, opacity, locks, blend modes and transparency masks, plus the image's ICC profile and resolution. Vector, filter, fill, file and clone layers and the other mask kinds are left out with a warning; float, CMYK and Lab documents open as Krita's merged image (preview). Import only: Save suggests .ora. Checked against 35 Krita 5.2.9-authored files. Co-authored-by: Scott Campbell <251750033+scottcampbelldata@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- apps/photocraft-cli/src/lib.rs | 7 +- apps/photocraft-web/src/web.rs | 9 +- apps/photocraft/src/services.rs | 10 +- crates/automation/tests/krita.rs | 68 +++ crates/engine/src/file_cmds.rs | 5 +- crates/engine/tests/kra.rs | 53 +++ crates/io/src/kra.rs | 402 ++++++++++++++---- crates/io/src/kra/tests.rs | 287 +++++++++++++ crates/io/src/kra/tiles.rs | 259 +++++++++++ crates/io/src/lib.rs | 18 +- crates/io/tests/krita_corpus.rs | 12 +- crates/ui-egui/src/lib.rs | 9 +- docs/kra.md | 77 ++++ docs/ora.md | 13 +- .../linux/ai.storyteller.photocraft.desktop | 2 +- .../ai.storyteller.photocraft.metainfo.xml.in | 1 + packaging/macos/Info.plist.in | 16 + packaging/windows/photocraft.wxs | 4 + 19 files changed, 1139 insertions(+), 115 deletions(-) create mode 100644 crates/automation/tests/krita.rs create mode 100644 crates/engine/tests/kra.rs create mode 100644 crates/io/src/kra/tests.rs create mode 100644 crates/io/src/kra/tiles.rs create mode 100644 docs/kra.md diff --git a/README.md b/README.md index b6e8c5a601..5fd2650415 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off

🗂️ Formats

- PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable. + PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), Paint.NET PDN3 (read, with editable layers and all 14 blend modes), OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP), Krita .kra (read, with paint layers, groups, blend modes and transparency masks), SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native .pcraft format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the codec capability matrix for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional --features heif for source builds). AVIF is write-only with the optional avif build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (.af, .afdesign, .afphoto, .afpub) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.

🪄 The everyday essentials

diff --git a/apps/photocraft-cli/src/lib.rs b/apps/photocraft-cli/src/lib.rs index ce1cbeaf9e..87050381a5 100644 --- a/apps/photocraft-cli/src/lib.rs +++ b/apps/photocraft-cli/src/lib.rs @@ -438,7 +438,7 @@ fn same_dir(a: &Path, b: &Path) -> bool { fn is_input(p: &Path) -> bool { let known = |e: &str| { - matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "af" | "afdesign" | "afphoto" | "afpub") + matches!(e, "pcraft" | "psd" | "psb" | "pdn" | "ora" | "kra" | "krz" | "af" | "afdesign" | "afphoto" | "afpub") || photocraft_codecs::from_extension(e).is_some_and(|f| photocraft_codecs::caps(f).read) }; p.is_file() && p.extension().is_some_and(|e| known(&e.to_string_lossy().to_ascii_lowercase())) @@ -674,6 +674,11 @@ mod missing_font_warning_tests { let (code, _, stderr) = invoke(&["convert", &ora, &png]); assert_eq!(code, 0, "{stderr}"); assert!(is_input(std::path::Path::new(&ora))); + // Krita documents are batch inputs too (import-only). + for name in ["sketch.kra", "old.KRZ"] { + std::fs::write(folder.join(name), b"").unwrap(); + assert!(is_input(&folder.join(name)), "{name}"); + } let _ = std::fs::remove_dir_all(&folder); } diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs index e44a0e259b..8d7e8e0826 100644 --- a/apps/photocraft-web/src/web.rs +++ b/apps/photocraft-web/src/web.rs @@ -14,12 +14,12 @@ use wasm_bindgen::JsCast as _; type Inbox = Arc)>>>; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd) and swatches (.aco, .ase), which go to the preset libraries. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", - "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", - "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "dds", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", + "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; const CANVAS_ID: &str = "photocraft_canvas"; @@ -441,6 +441,7 @@ fn mime_for(name: &str) -> &'static str { Some("gif") => "image/gif", Some("psd" | "psb") => "image/vnd.adobe.photoshop", Some("ora") => "image/openraster", + Some("kra" | "krz") => "application/x-krita", _ => "application/octet-stream", } } diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index 891e251bf1..e67f13ae43 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -16,13 +16,13 @@ use std::sync::Arc; #[cfg(test)] mod recovery_tests; -/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster and Affinity documents, flat images, and +/// Everything File › Open reads: PhotoCraft, Photoshop, OpenRaster, Krita and Affinity documents, flat images, and /// Photoshop brushes (.abr), gradients (.grd), swatches (.aco, .ase) and keyboard shortcut sets /// (.kys), which go to the preset libraries and Edit › Keyboard Shortcuts. const OPEN_EXTS: &[&str] = &[ - "pcraft", "pdn", "ora", "kra", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", - "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", - "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", + "pcraft", "pdn", "ora", "kra", "krz", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", + "pbm", "pgm", "ppm", "pam", "pfm", "pcx", "dds", "xpm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", + "pef", "orf", "rw2", "raf", "abr", "grd", "svg", "svgz", "aco", "ase", "kys", "af", "afdesign", "afphoto", "afpub", ]; const SVG_EXTS: &[&str] = &["svg", "svgz"]; @@ -43,7 +43,7 @@ fn open_filters() -> Vec<(&'static str, Vec)> { ("All Formats", open_filter_extensions(OPEN_EXTS)), ("PhotoCraft", open_filter_extensions(&["pcraft"])), ("OpenRaster", open_filter_extensions(&["ora"])), - ("Krita", open_filter_extensions(&["kra"])), + ("Krita", open_filter_extensions(&["kra", "krz"])), ("SVG", open_filter_extensions(SVG_EXTS)), ] } diff --git a/crates/automation/tests/krita.rs b/crates/automation/tests/krita.rs new file mode 100644 index 0000000000..85bde4f5fc --- /dev/null +++ b/crates/automation/tests/krita.rs @@ -0,0 +1,68 @@ +//! Krita documents through the agent surface (`doc.open`, `engine.execute`, `doc.save`, as MCP +//! and the CLI use it): open a .kra with its layers, edit it, and save it as OpenRaster. + +use photocraft_automation::Headless; +use serde_json::json; + +/// A tiled pixel file with one raw 64x64 tile of `bgra` at (0, 0). +fn tiles(bgra: [u8; 4]) -> Vec { + let n = 64 * 64; + let mut out = b"VERSION 2\nTILEWIDTH 64\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 1\n".to_vec(); + out.extend(format!("0,0,LZF,{}\n", 1 + n * 4).into_bytes()); + out.push(0); + for b in bgra { + out.extend(std::iter::repeat_n(b, n)); + } + out +} + +fn kra() -> Vec { + let maindoc = r#" + + + + + + + +"#; + let mut z = photocraft_format::zip::ZipWriter::new(); + z.add("mimetype", b"application/x-krita").unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + z.add("art/layers/layer2", &tiles([0, 0, 255, 255])).unwrap(); + z.add("art/layers/layer4", &tiles([255, 255, 255, 255])).unwrap(); + z.finish().unwrap() +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + h.handle("engine.execute", json!({"command": command, "params": params})).unwrap() +} + +#[test] +fn krita_documents_open_with_layers_and_save_as_openraster() { + let dir = std::env::temp_dir().join(format!("pc-krita-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("art.kra"); + std::fs::write(&path, kra()).unwrap(); + let mut h = Headless::trusted_local(); + let opened = h.handle("doc.open", json!({"path": path.to_str().unwrap()})).unwrap(); + let warnings = opened["warnings"].to_string(); + assert!(warnings.contains("vector layers") && warnings.contains("Speech bubble"), "{opened}"); + let doc = &h.session.active().unwrap().doc; + let names: Vec<&str> = doc.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Paper", "Inks"]); + let line = doc.layers[1].children().unwrap()[0].clone(); + assert_eq!((line.name.as_str(), line.blend), ("Line", photocraft_doc::BlendMode::Multiply)); + let flat = photocraft_compose::flatten(doc); + assert!((flat.px[0][1] - 0.2).abs() < 0.01, "80% red multiplied over white: {:?}", flat.px[0]); + + // Read-only source: the file itself is never overwritten as Krita. + assert!(h.handle("doc.save", json!({"path": path.to_str().unwrap()})).is_err()); + exec(&mut h, "layer.setProps", json!({"layer": line.id.0, "opacity": 0.5})); + let ora = dir.join("art.ora"); + h.handle("doc.save", json!({"path": ora.to_str().unwrap()})).unwrap(); + let back = photocraft_io::import("art.ora", &std::fs::read(&ora).unwrap()).unwrap().document; + assert_eq!(back.layers.len(), 2); + assert_eq!(back.layers[1].children().unwrap()[0].opacity, 0.5); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs index 2757df5696..c31d2d8afb 100644 --- a/crates/engine/src/file_cmds.rs +++ b/crates/engine/src/file_cmds.rs @@ -117,8 +117,9 @@ pub(crate) fn list_images(dir: &str) -> Result> { /// Extensions the batch commands pick up from a folder. #[cfg(not(target_arch = "wasm32"))] const OPENABLE: &[&str] = &[ - "pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm", - "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub", + "pdn", "ora", "kra", "krz", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", + "ppm", "pgm", "fits", "fit", "fts", "dpx", "heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", + "afpub", ]; /// Whether saving `doc` as a TIFF writes Photoshop layer data (anything beyond a lone diff --git a/crates/engine/tests/kra.rs b/crates/engine/tests/kra.rs new file mode 100644 index 0000000000..8bdf10531c --- /dev/null +++ b/crates/engine/tests/kra.rs @@ -0,0 +1,53 @@ +//! Krita documents opened through the session: read-only source, 16-bit layers, editable +//! layers with history, and saving as OpenRaster or native. The files come from +//! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus. Run with +//! `KRITA_TEST_DIR= cargo test -p photocraft-engine --test kra -- --ignored --nocapture`. +use photocraft_engine::Session; +use serde_json::json; + +fn open(path: &std::path::Path) -> Session { + let mut session = Session::new(); + let name = path.to_string_lossy().into_owned(); + photocraft_engine::file_cmds::open_bytes_as(&mut session, &name, &std::fs::read(path).unwrap(), None, Some(name.clone())).unwrap(); + session +} + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_documents_open_read_only_and_stay_editable() { + let dir = std::path::PathBuf::from(std::env::var("KRITA_TEST_DIR").expect("set KRITA_TEST_DIR")); + let path = dir.join("depth-rgba-u16.kra"); + let mut session = open(&path); + let active = session.active().unwrap(); + assert!(active.source_read_only && active.path.is_none()); + assert_eq!(active.doc.depth, photocraft_color::SampleType::U16); + assert!(session.execute("file.save", json!({})).is_err()); + + // Krita's merged image is the composite (the corpus files carry no ICC profile, so both + // sides are sRGB); crates/io/tests/krita_corpus.rs compares every file. + let ours = photocraft_compose::flatten(&session.active().unwrap().doc); + let zip_bytes = std::fs::read(&path).unwrap(); + let zip = photocraft_format::zip::ZipReader::new(&zip_bytes).unwrap(); + let merged = photocraft_io::import("m.png", &zip.read_by_name("mergedimage.png", 1 << 28).unwrap()).unwrap().document; + let theirs = photocraft_compose::flatten(&merged); + let worst = ours.px.iter().zip(&theirs.px).map(|(a, b)| (0..4).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold(0.0f32, f32::max)).fold(0.0, f32::max); + assert!(worst <= 3.0 / 255.0, "{}", worst * 255.0); + + // Imported Krita layers take the ordinary commands and history. + let mut session = open(&dir.join("layers-opacity-visibility.kra")); + let before = session.active().unwrap().doc.clone(); + let id = before.layers.last().unwrap().id; + session.execute("layer.setProps", json!({"layer":id.0,"name":"Edited","opacity":0.25,"blend":"Screen"})).unwrap(); + assert_eq!(session.active().unwrap().doc.layer(id).unwrap().blend, photocraft_color::BlendMode::Screen); + session.execute("layer.select", json!({"layer":id.0})).unwrap(); + session.execute("paint.stroke", json!({"points":[[2,2],[30,30]],"size":6,"hardness":1.0,"color":"#00ff00"})).unwrap(); + assert!(session.undo() && session.undo()); + assert_eq!(session.active().unwrap().doc.layers, before.layers); + // ...and save as OpenRaster or native, never back to .kra. + let doc = &session.active().unwrap().doc; + assert!(photocraft_io::export(doc, "x.kra", &Default::default()).is_err()); + let ora = photocraft_io::export(doc, "x.ora", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.ora", &ora.bytes).unwrap().document.layers.len(), doc.layers.len()); + let native = photocraft_io::export(doc, "x.pcraft", &Default::default()).unwrap(); + assert_eq!(photocraft_io::import("x.pcraft", &native.bytes).unwrap().document.layers, doc.layers); +} diff --git a/crates/io/src/kra.rs b/crates/io/src/kra.rs index a3af8b119c..64e4facf61 100644 --- a/crates/io/src/kra.rs +++ b/crates/io/src/kra.rs @@ -1,108 +1,342 @@ -//! Krita .kra import: the merged PNG is an editable raster layer, not a replacement -//! for Krita's native layer tree. Never write this document back to the .kra source. -//! The ZIP reader checks entry bounds, sizes and CRC before the PNG decoder runs. +//! Krita (`.kra`, `.krz`) import. Layout observed from files Krita writes (no Krita code used): +//! a ZIP whose first, stored entry is `mimetype` = `application/x-krita`, a `maindoc.xml` node +//! tree, one tiled pixel file per paint layer and mask under `/layers/`, the +//! image's ICC profile in `/annotations/icc`, and Krita's rendering in +//! `mergedimage.png`. +//! +//! Tiled pixel files are a short text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, +//! `PIXELSIZE`, `DATA `) followed by, per tile, a `x,y,LZF,` line and that many +//! bytes: a flag (1 = LZF-compressed, 0 = raw) and the tile's pixels stored as byte planes +//! (every pixel's first byte, then every pixel's second byte, ...). RGB pixels are in +//! Krita's BGRA order, 16-bit samples little-endian. +//! +//! Imported: paint layers (8 and 16-bit RGBA and gray), groups (isolated and pass-through), +//! names, visibility, opacity, blend modes, locks, layer offsets and transparency masks. +//! Every other node kind (vector, filter, fill, file and clone layers, filter, transform and +//! colorize masks) is left out with a warning; Krita's merged image still shows their look. +//! Documents in a colour space we don't read open as Krita's merged image, flagged as a preview. -use photocraft_codecs::{self as codecs, DecodeOptions, Format}; +#[cfg(test)] +mod tests; +mod tiles; + +use photocraft_color::{BlendMode, PixelFormat}; +use photocraft_doc::{Document, Layer, LayerContent, LayerMask}; use photocraft_format::zip::ZipReader; -use photocraft_raster::Interrupt; +use photocraft_geom::Size; +use photocraft_raster::{Interrupt, Surface}; +use crate::ora::{MAX_NODES, MAX_PIXEL_BYTES, MAX_SIDE, Warnings, krita_blend}; use crate::{ImportResult, IoError}; +use tiles::{Space, TileFile}; -const MIMETYPE: &[u8] = b"application/x-krita"; -const MAX_ENTRY: usize = 512 << 20; -const MAX_PIXELS_BYTES: u64 = 1 << 30; +type Result = std::result::Result; + +/// The `mimetype` entry of a Krita archive. +pub(crate) const MIMETYPE: &[u8] = b"application/x-krita"; +pub(crate) const EXTENSIONS: [&str; 2] = ["kra", "krz"]; +const MAX_MAINDOC: usize = 32 << 20; +const MAX_LAYER_FILE: usize = 1 << 30; fn invalid(message: impl Into) -> IoError { IoError::Kra(message.into()) } -/// Krita writes its mimetype as the first, uncompressed ZIP entry. +/// `true` for a Krita archive (by its `mimetype` entry). pub(crate) fn is_kra(bytes: &[u8]) -> bool { crate::ora::zip_mimetype(bytes).is_some_and(|mime| mime.trim_ascii() == MIMETYPE) } -/// Import Krita's official merged preview as a flattened, editable PhotoCraft document. -/// Do not silently pretend this imported a layered or lossless Krita document. -pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { - ctl.check().map_err(|_| IoError::Cancelled)?; - let archive = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; - let mime = archive.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; +fn attr<'a>(node: roxmltree::Node<'a, '_>, name: &str) -> Option<&'a str> { + node.attribute(name).map(str::trim) +} + +fn number(node: roxmltree::Node<'_, '_>, name: &str, default: f64) -> Result { + match attr(node, name) { + None | Some("") => Ok(default), + Some(v) => v.parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))), + } +} + +fn offset(node: roxmltree::Node<'_, '_>, name: &str) -> Result { + let v = number(node, name, 0.0)?.round(); + if v.abs() > f64::from(MAX_SIDE) * 4.0 { + return Err(invalid(format!("{name} offset {v} is out of range"))); + } + Ok(v as i32) +} + +fn truthy(node: roxmltree::Node<'_, '_>, name: &str) -> bool { + matches!(attr(node, name), Some("1" | "true")) +} + +/// Child elements named `tag` inside the node's `` element (`layers` or `masks`). +fn children<'a, 'i>(node: roxmltree::Node<'a, 'i>, container: &str, tag: &'static str) -> impl Iterator> { + node.children().filter(move |c| c.has_tag_name(container)).flat_map(move |c| c.children().filter(move |n| n.has_tag_name(tag))) +} + +struct Reader<'a, 'c> { + zip: ZipReader<'a>, + ctl: &'c Interrupt<'c>, + /// `/layers/`. + dir: String, + /// The document's pixel format (layers in another supported space are converted to it). + fmt: PixelFormat, + nodes: usize, + budget: usize, + warnings: Warnings, +} + +impl Reader<'_, '_> { + fn file(&self, name: &str) -> Result>> { + let path = format!("{}{name}", self.dir); + match self.zip.find(&path) { + None => Ok(None), + Some(e) => self.zip.read(e, MAX_LAYER_FILE).map(Some).map_err(|err| invalid(format!("{path}: {err}"))), + } + } + + /// The default pixel Krita stores next to a tiled file, decoded to `space` samples. + fn default_pixel(&self, name: &str, space: Space) -> Result>> { + let Some(bytes) = self.file(&format!("{name}.defaultpixel"))? else { return Ok(None) }; + if bytes.len() != space.pixel_size() { + return Err(invalid(format!("{name}.defaultpixel has {} bytes, expected {}", bytes.len(), space.pixel_size()))); + } + Ok(Some(space.normalised(&bytes))) + } + + /// A tiled pixel file in `space` as a surface in `fmt`, offset by (`dx`, `dy`). + fn surface(&mut self, name: &str, space: Space, fmt: PixelFormat, dx: i32, dy: i32) -> Result> { + let Some(bytes) = self.file(name)? else { return Ok(None) }; + let file = TileFile::parse(&bytes)?; + if file.pixel_size != space.pixel_size() { + return Err(invalid(format!("{name}: pixel size {} does not match its colour space", file.pixel_size))); + } + let cost = file.decoded_bytes(fmt.bytes_per_pixel())?; + self.budget = self.budget.checked_sub(cost).ok_or_else(|| invalid("decoded layers exceed the 1 GiB limit"))?; + let mut surface = match self.default_pixel(name, space)? { + Some(px) if px.iter().any(|v| *v != 0.0) => Surface::with_default(fmt, &space.to_format(&px, fmt)), + _ => Surface::new(fmt), + }; + file.decode_into(&mut surface, space, dx, dy, self.ctl)?; + surface.prune(); + Ok(Some(surface)) + } + + fn props(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer) -> Result<()> { + layer.name = attr(node, "name").filter(|n| !n.is_empty()).map_or_else(|| format!("Layer {}", self.nodes), str::to_string); + layer.visible = attr(node, "visible") != Some("0"); + layer.opacity = (number(node, "opacity", 255.0)? / 255.0).clamp(0.0, 1.0) as f32; + if truthy(node, "locked") { + layer.locks.all = true; + } + let op = attr(node, "compositeop").unwrap_or("normal"); + layer.blend = match krita_blend(op) { + Some((mode, approx)) => { + if approx { + self.warnings.note(&format!("Krita's {op} blend mode is approximated by {}", mode.label())); + } + mode + } + None => { + self.warnings.layer(&layer.name, &format!("Krita's {op} blend mode is not supported; it opens as Normal")); + BlendMode::Normal + } + }; + if attr(node, "channelflags").is_some_and(|f| f.contains('0')) { + self.warnings.layer(&layer.name, "channel locks (Krita's per-channel blending) are ignored"); + } + if truthy(node, "inheritalpha") { + self.warnings.layer(&layer.name, "Inherit Alpha is not supported; these layers blend normally"); + } + if attr(node, "keyframes").is_some_and(|k| !k.is_empty()) { + self.warnings.layer(&layer.name, "animated layers open with the frame Krita saved in the layer"); + } + Ok(()) + } + + /// The node's transparency masks, combined into one layer mask. + fn masks(&mut self, node: roxmltree::Node<'_, '_>, layer: &mut Layer, dx: i32, dy: i32) -> Result<()> { + for mask in children(node, "masks", "mask") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + let kind = attr(mask, "nodetype").unwrap_or(""); + match kind { + "transparencymask" => {} + // A layer's local selection: not part of its look. + "selectionmask" => continue, + other => { + let what = match other { + "filtermask" => "filter masks", + "transformmask" => "transform masks", + "colorizemask" => "colorize masks", + _ => "unknown masks", + }; + self.warnings.layer(&layer.name, &format!("{what} are not supported and were left out")); + continue; + } + } + if layer.mask.is_some() { + self.warnings.layer(&layer.name, "only the first transparency mask of a layer is kept"); + continue; + } + let Some(file) = attr(mask, "filename") else { continue }; + let (mx, my) = (dx.saturating_add(offset(mask, "x")?), dy.saturating_add(offset(mask, "y")?)); + match self.surface(&format!("{file}.pixelselection"), Space::Alpha8, PixelFormat::GRAY8, mx, my)? { + Some(surface) => { + layer.mask = Some(LayerMask { surface, enabled: attr(mask, "visible") != Some("0"), ..LayerMask::reveal_all() }); + } + None => self.warnings.layer(&layer.name, "a transparency mask's pixels are missing from the file"), + } + } + Ok(()) + } + + /// The `` of `node`, bottom-to-top (maindoc.xml lists the topmost first). + fn layers(&mut self, node: roxmltree::Node<'_, '_>, depth: usize, dx: i32, dy: i32) -> Result> { + if depth > photocraft_doc::MAX_GROUP_DEPTH { + return Err(IoError::Unsupported(format!("layer groups nested deeper than {}", photocraft_doc::MAX_GROUP_DEPTH))); + } + let mut out = Vec::new(); + for child in children(node, "layers", "layer") { + self.ctl.check().map_err(|_| IoError::Cancelled)?; + self.nodes += 1; + if self.nodes > MAX_NODES { + return Err(invalid(format!("more than {MAX_NODES} layers"))); + } + let (x, y) = (dx.saturating_add(offset(child, "x")?), dy.saturating_add(offset(child, "y")?)); + let kind = attr(child, "nodetype").unwrap_or(""); + let mut layer = match kind { + "paintlayer" => { + let mut layer = Layer::raster("", self.fmt); + self.props(child, &mut layer)?; + let space = attr(child, "colorspacename").and_then(Space::from_name); + match (space, attr(child, "filename")) { + (Some(space), Some(file)) => match self.surface(file, space, self.fmt, x, y)? { + Some(s) => layer.content = LayerContent::Raster(s), + None => self.warnings.layer(&layer.name, "the layer's pixels are missing from the file; it opens empty"), + }, + (None, _) => { + let cs = attr(child, "colorspacename").unwrap_or("?"); + self.warnings.layer(&layer.name, &format!("layers in Krita's {cs} colour space are not supported and were left out")); + continue; + } + (_, None) => self.warnings.layer(&layer.name, "the layer names no pixel file; it opens empty"), + } + layer + } + "grouplayer" => { + let mut layer = Layer::group("", Vec::new()); + self.props(child, &mut layer)?; + if truthy(child, "passthrough") { + if layer.blend != BlendMode::Normal { + self.warnings.layer(&layer.name, "a pass-through group's blend mode is ignored, as in Krita"); + } + layer.blend = BlendMode::PassThrough; + } + // A group's own x/y is not applied to its children: Krita moves a group by + // moving their pixels, and its merged image ignores the group offset. + let kids = self.layers(child, depth + 1, dx, dy)?; + if let LayerContent::Group(g) = &mut layer.content { + g.children = kids; + g.expanded = !truthy(child, "collapsed"); + } + layer + } + other => { + let name = attr(child, "name").unwrap_or(other).to_string(); + let what = match other { + "shapelayer" => "vector layers", + "adjustmentlayer" => "filter layers", + "generatorlayer" => "fill layers", + "filelayer" => "file layers", + "clonelayer" => "clone layers", + _ => "unknown layer kinds", + }; + self.warnings.layer(&name, &format!("Krita {what} are not supported yet and were left out")); + continue; + } + }; + self.masks(child, &mut layer, x, y)?; + out.push(layer); + } + out.reverse(); + Ok(out) + } +} + +/// Krita's merged image as a one-layer stand-in, for documents we can't read layer by layer. +fn preview(name: &str, zip: &ZipReader<'_>, why: String) -> Result { + let png = zip.read_by_name("mergedimage.png", MAX_LAYER_FILE).map_err(|e| invalid(format!("{why}, and the merged image can't be read: {e}")))?; + let mut r = crate::flat::import_flat(name, &png)?; + r.document.name = name.to_string(); + r.warnings.insert(0, format!("{why}; the file opens as Krita's merged image, without its layers")); + r.source_read_only = true; + r.preview_only = true; + Ok(r) +} + +pub(crate) fn import(name: &str, bytes: &[u8], ctl: &Interrupt<'_>) -> Result { + let zip = ZipReader::new(bytes).map_err(|e| invalid(e.to_string()))?; + let mime = zip.read_by_name("mimetype", 256).map_err(|e| invalid(e.to_string()))?; if mime.trim_ascii() != MIMETYPE { return Err(invalid("the archive's mimetype is not application/x-krita")); } - ctl.progress(0.10); - let merged = archive.read_by_name("mergedimage.png", MAX_ENTRY).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - if codecs::detect(&merged) != Some(Format::Png) { - return Err(invalid("mergedimage.png is not a PNG image")); - } - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut opts = DecodeOptions::default(); - opts.limits.max_width = crate::ora::MAX_SIDE; - opts.limits.max_height = crate::ora::MAX_SIDE; - opts.limits.max_alloc = MAX_PIXELS_BYTES; - let image = codecs::decode_with(&merged, &opts).map_err(|e| invalid(format!("mergedimage.png: {e}")))?; - ctl.check().map_err(|_| IoError::Cancelled)?; - let mut result = crate::flat::image_to_document(name, &image)?; - result.source_read_only = true; - result.warnings.push("Krita layers, masks and editable document structure were not imported; only mergedimage.png is available".into()); - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::*; - use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType}; - use photocraft_format::zip::ZipWriter; - - fn png() -> Vec { - let image = Image::from_raw(2, 1, ChannelLayout::Rgba, SampleType::U8, vec![255, 0, 0, 255, 0, 0, 255, 128]).unwrap(); - codecs::encode(&image, Format::Png, &EncodeOptions::default()).unwrap() - } - - fn archive(mime: &[u8], with_merged: bool) -> Vec { - let mut zip = ZipWriter::new(); - zip.add("mimetype", mime).unwrap(); - zip.add("maindoc.xml", b"").unwrap(); - if with_merged { - zip.add("mergedimage.png", &png()).unwrap(); + let xml = zip.read_by_name("maindoc.xml", MAX_MAINDOC).map_err(|e| invalid(e.to_string()))?; + let xml = std::str::from_utf8(&xml).map_err(|_| invalid("maindoc.xml is not UTF-8"))?; + // maindoc.xml declares Krita's DTD by public id; roxmltree only reads internal subsets and + // guards entity expansion. + let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let tree = roxmltree::Document::parse_with_options(xml, opts).map_err(|e| invalid(format!("maindoc.xml: {e}")))?; + let image = tree.root_element().children().find(|n| n.has_tag_name("IMAGE")).ok_or_else(|| invalid("maindoc.xml has no IMAGE"))?; + let side = |a: &str| -> Result { + let v = number(image, a, 0.0)?; + if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 { + return Err(invalid(format!("image {a} {v} must be a whole number of pixels from 1 to {MAX_SIDE}"))); } - zip.finish().unwrap() + Ok(v as u32) + }; + let (w, h) = (side("width")?, side("height")?); + if (w as usize).saturating_mul(h as usize).saturating_mul(8) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); } - - #[test] - fn imports_krita_composite_without_claiming_to_preserve_layers() { - let bytes = archive(MIMETYPE, true); - assert!(is_kra(&bytes)); - // The mimetype is authoritative even without a .kra extension. - for name in ["scene.kra", "download"] { - let r = crate::import(name, &bytes).unwrap(); - assert_eq!((r.document.size.width, r.document.size.height), (2, 1)); - assert_eq!(r.document.name, name); - assert_eq!(r.document.layers.len(), 1); - assert_eq!(r.document.layers[0].name, "Layer 0", "alpha-bearing composite"); - let pixels = r.document.layers[0].surface().unwrap(); - assert!(pixels.rgba(0, 0)[0] > 0.99); - assert!(pixels.rgba(1, 0)[2] > 0.99); - assert!((pixels.rgba(1, 0)[3] - 128.0 / 255.0).abs() < 0.01); - assert!(r.source_read_only, ".kra must not be overwritten by a flat save"); - assert!(!r.preview_only, "the flattened image is editable"); - assert!(r.warnings.iter().any(|w| w.contains("layers") && w.contains("not imported"))); + let cs = attr(image, "colorspacename").unwrap_or(""); + let Some(space) = Space::from_name(cs) else { + return preview(name, &zip, format!("Krita's {cs} colour space is not supported yet")); + }; + let (mode, depth) = space.model(); + let image_name = attr(image, "name").unwrap_or(""); + let mut reader = Reader { + zip, + ctl, + dir: format!("{image_name}/layers/"), + fmt: PixelFormat::new(mode, depth, true), + nodes: 0, + budget: MAX_PIXEL_BYTES, + warnings: Warnings::default(), + }; + let layers = reader.layers(image, 1, 0, 0)?; + ctl.progress(0.9); + let mut document = Document::new(name, Size::new(w, h), mode, depth); + document.layers = layers; + let icc = format!("{image_name}/annotations/icc"); + if let Some(e) = reader.zip.find(&icc) { + let profile = reader.zip.read(e, 16 << 20).map_err(|err| invalid(format!("{icc}: {err}")))?; + if !profile.is_empty() { + document.icc_profile = Some(std::sync::Arc::new(profile)); } } - - #[test] - fn malformed_or_non_krita_archives_do_not_open_as_krita() { - let wrong = archive(b"image/openraster", true); - assert!(!is_kra(&wrong)); - assert!(import("wrong.kra", &wrong, &Interrupt::NONE).is_err()); - assert!(import("missing.kra", &archive(MIMETYPE, false), &Interrupt::NONE).is_err()); - assert!(import("broken.kra", b"not a zip", &Interrupt::NONE).is_err()); + let (xres, yres) = (number(image, "x-res", 72.0)?, number(image, "y-res", 72.0)?); + if xres > 0.0 && xres < 1.0e6 { + document.resolution_dpi = xres as f32; + if let Some(w) = crate::unequal_resolution_warning(xres, yres) { + reader.warnings.note(&w); + } } - - #[test] - fn a_png_with_a_misleading_kra_extension_remains_a_png() { - let r = crate::import("renamed.kra", &png()).unwrap(); - assert_eq!(r.document.size.width, 2); - assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + if document.layers.is_empty() { + reader.warnings.note("no layers of the Krita document could be imported"); } + if image.children().any(|n| n.has_tag_name("assistants") || n.has_tag_name("ReferenceImages")) { + reader.warnings.note("drawing assistants and reference images were left out"); + } + Ok(ImportResult { document, warnings: reader.warnings.finish(), source_read_only: true, preview_only: false }) } diff --git a/crates/io/src/kra/tests.rs b/crates/io/src/kra/tests.rs new file mode 100644 index 0000000000..4316a74c10 --- /dev/null +++ b/crates/io/src/kra/tests.rs @@ -0,0 +1,287 @@ +use super::tiles::{Space, lzf_decompress}; +use super::*; +use photocraft_codecs::{ChannelLayout, Format, Image, SampleType as CSample}; +use photocraft_color::{ColorMode, SampleType}; +use photocraft_format::zip::ZipWriter; +use photocraft_geom::Rect; + +const T: usize = 4; // small tiles keep the fixtures short + +/// LZF with literal runs only (valid liblzf output). +fn lzf_literal(data: &[u8]) -> Vec { + let mut out = Vec::new(); + for run in data.chunks(32) { + out.push((run.len() - 1) as u8); + out.extend_from_slice(run); + } + out +} + +/// A tiled pixel file from (x, y, interleaved Krita pixels) tiles; odd tiles LZF, even raw. +fn tile_file(pixel_size: usize, tiles: &[(i32, i32, Vec)]) -> Vec { + let mut out = format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE {pixel_size}\nDATA {}\n", tiles.len()).into_bytes(); + for (i, (x, y, px)) in tiles.iter().enumerate() { + let n = T * T; + let mut planar = vec![0; n * pixel_size]; + for p in 0..n { + for k in 0..pixel_size { + planar[k * n + p] = px[p * pixel_size + k]; + } + } + let body: Vec = if i % 2 == 1 { [vec![1], lzf_literal(&planar)].concat() } else { [vec![0], planar].concat() }; + out.extend(format!("{x},{y},LZF,{}\n", body.len()).into_bytes()); + out.extend(body); + } + out +} + +fn archive(maindoc: &str, files: &[(&str, Vec)]) -> Vec { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.add("maindoc.xml", maindoc.as_bytes()).unwrap(); + for (name, data) in files { + z.add(name, data).unwrap(); + } + z.finish().unwrap() +} + +fn maindoc(cs: &str, layers: &str) -> String { + format!( + "\n\n\ + {layers}" + ) +} + +/// One tile of BGRA8 pixels: pixel p = (b, g, r, a) from `f(p)`. +fn bgra(f: impl Fn(usize) -> [u8; 4]) -> Vec { + (0..T * T).flat_map(f).collect() +} + +fn layer<'a>(d: &'a Document, name: &str) -> &'a Layer { + fn find<'a>(ls: &'a [Layer], name: &str) -> Option<&'a Layer> { + ls.iter().find_map(|l| if l.name == name { Some(l) } else { l.children().and_then(|c| find(c, name)) }) + } + find(&d.layers, name).unwrap_or_else(|| panic!("no layer {name}")) +} + +#[test] +fn reads_layers_groups_masks_and_properties() { + let doc = maindoc( + "RGBA", + r#" + + + + + + + "#, + ); + let red = bgra(|p| [0, 0, 255, (p * 16) as u8]); + let files = [ + ("img/layers/layer2", tile_file(4, &[(0, 0, red.clone()), (4, 0, bgra(|_| [10, 20, 30, 255]))])), + ("img/layers/layer2.defaultpixel", vec![0; 4]), + ("img/layers/layer3", tile_file(4, &[(-4, 4, bgra(|_| [1, 2, 3, 200]))])), + ("img/layers/mask4.pixelselection", tile_file(1, &[(-4, 4, (0..T * T).map(|p| (p * 10) as u8).collect())])), + ("img/layers/mask4.pixelselection.defaultpixel", vec![0]), + ("img/annotations/icc", b"not really a profile".to_vec()), + ]; + let r = crate::import("art.kra", &archive(&doc, &files)).unwrap(); + let d = &r.document; + assert!(r.source_read_only && !r.preview_only); + assert_eq!((d.size.width, d.size.height, d.mode, d.depth), (12, 8, ColorMode::Rgb, SampleType::U8)); + assert_eq!(d.resolution_dpi, 300.0); + assert!(d.icc_profile.is_some()); + // Bottom-to-top: the unsupported nodes are left out. + let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Masked", "Through"]); + let through = layer(d, "Through"); + assert_eq!(through.blend, BlendMode::PassThrough); + assert!(matches!(&through.content, LayerContent::Group(g) if !g.expanded)); + let inner = layer(d, "Inner"); + assert_eq!((inner.blend, inner.locks.all), (BlendMode::Screen, true)); + assert!((inner.opacity - 128.0 / 255.0).abs() < 1e-6); + // The layer's offset (1, 0) moves its tiles; the group's (2, 1) does not, as in Krita. + let s = inner.surface().unwrap(); + assert_eq!(s.content_bounds(), Rect::from_xywh(1, 0, 8, 4)); + let px = s.to_interleaved(Rect::from_xywh(1, 0, 2, 1)); + assert_eq!(px, [255, 0, 0, 0, 255, 0, 0, 16]); + assert_eq!(s.to_interleaved(Rect::from_xywh(5, 0, 1, 1)), [30, 20, 10, 255]); + let masked = layer(d, "Masked"); + assert!(!masked.visible); + assert_eq!(masked.blend, BlendMode::Difference); + let mask = masked.mask.as_ref().unwrap(); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(-4, 4, 2, 1)), [0, 10]); + assert_eq!(mask.surface.to_interleaved(Rect::from_xywh(5, 5, 1, 1)), [0], "outside the tiles the default (hide) applies"); + let all = r.warnings.join("\n"); + for needle in ["vector layers", "LABA", "pass-through group's blend mode"] { + assert!(all.contains(needle), "{needle} not in {all}"); + } +} + +#[test] +fn sixteen_bit_and_gray_spaces_convert_exactly() { + let px16: Vec = (0..T * T).flat_map(|p| [1000u16 + p as u16, 2000, 3000, 65535].iter().flat_map(|v| v.to_le_bytes()).collect::>()).collect(); + let doc = maindoc( + "RGBA16", + r#""#, + ); + let gray: Vec = (0..T * T).flat_map(|p| [p as u8 * 10, 255]).collect(); + let r = + crate::import("a.kra", &archive(&doc, &[("img/layers/l", tile_file(8, &[(0, 0, px16)])), ("img/layers/g", tile_file(2, &[(0, 0, gray)]))])).unwrap(); + let d = &r.document; + assert_eq!(d.depth, SampleType::U16); + let s = layer(d, "L").surface().unwrap(); + assert_eq!(s.format(), PixelFormat::RGBA16); + let got: Vec = s.to_interleaved(Rect::from_xywh(1, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + // Krita's B, G, R, A becomes R, G, B, A. + assert_eq!(got, [3000, 2000, 1001, 65535]); + // A gray layer in an RGB document is converted. + let g = layer(d, "G").surface().unwrap(); + assert_eq!(g.format(), PixelFormat::RGBA16); + let v: Vec = g.to_interleaved(Rect::from_xywh(2, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [20 * 257, 20 * 257, 20 * 257, 65535]); + + let doc = maindoc("GRAYAU16", r#""#); + let g16: Vec = (0..T * T).flat_map(|p| [(p as u16 * 4000).to_le_bytes(), 40000u16.to_le_bytes()].concat()).collect(); + let r = crate::import("g.kra", &archive(&doc, &[("img/layers/g", tile_file(4, &[(0, 0, g16)]))])).unwrap(); + assert_eq!((r.document.mode, r.document.depth), (ColorMode::Grayscale, SampleType::U16)); + let s = layer(&r.document, "G16").surface().unwrap(); + let v: Vec = s.to_interleaved(Rect::from_xywh(3, 0, 1, 1)).as_chunks::<2>().0.iter().map(|b| u16::from_ne_bytes(*b)).collect(); + assert_eq!(v, [12000, 40000]); +} + +#[test] +fn a_nonzero_default_pixel_fills_untouched_areas() { + let doc = maindoc("RGBA", r#""#); + let files = [("img/layers/l", tile_file(4, &[])), ("img/layers/l.defaultpixel", vec![255, 0, 0, 255])]; + let r = crate::import("a.kra", &archive(&doc, &files)).unwrap(); + let s = layer(&r.document, "L").surface().unwrap(); + assert_eq!(s.to_interleaved(Rect::from_xywh(100, 100, 1, 1)), [0, 0, 255, 255]); +} + +fn merged_png() -> Vec { + let img = Image::from_raw(12, 8, ChannelLayout::Rgba, CSample::U8, vec![90; 12 * 8 * 4]).unwrap(); + photocraft_codecs::encode(&img, Format::Png, &Default::default()).unwrap() +} + +#[test] +fn unsupported_colour_spaces_open_as_the_merged_image() { + let doc = maindoc("CMYKA", ""); + let r = crate::import("c.kra", &archive(&doc, &[("mergedimage.png", merged_png())])).unwrap(); + assert!(r.preview_only && r.source_read_only); + assert_eq!(r.document.layers.len(), 1); + assert!(r.warnings.first().is_some_and(|w| w.contains("CMYKA") && w.contains("merged image")), "{:?}", r.warnings); + assert!(crate::import("c.kra", &archive(&doc, &[])).is_err(), "no merged image to fall back to"); +} + +#[test] +fn export_is_refused() { + let d = Document::new("x", Size::new(2, 2), ColorMode::Rgb, SampleType::U8); + for name in ["x.kra", "x.KRZ"] { + assert!(matches!(crate::export(&d, name, &Default::default()), Err(IoError::Unsupported(_))), "{name}"); + } +} + +#[test] +fn lzf_decodes_literals_and_overlapping_references() { + // "abc" then a 6-byte back-reference 3 back: abcabcabc. + assert_eq!(lzf_decompress(&[2, b'a', b'b', b'c', 0x80, 2], 9).unwrap(), b"abcabcabc"); + // A long back-reference (len 7 + extra) over a single byte: run-length. + assert_eq!(lzf_decompress(&[0, b'x', 0xe0, 3, 0], 13).unwrap(), vec![b'x'; 13]); + for bad in [&[0x20, 5][..], &[5, 1, 2], &[0xe0], &[2, 1, 2, 3, 0x20]] { + assert!(lzf_decompress(bad, 64).is_err(), "{bad:?}"); + } + assert!(lzf_decompress(&[2, 1, 2, 3], 2).is_err(), "overrun"); + assert!(lzf_decompress(&[2, 1, 2, 3], 4).is_err(), "short"); +} + +#[test] +fn malformed_files_fail_cleanly() { + let one = |cs: &str, data: Vec| { + archive(&maindoc(cs, r#""#), &[("img/layers/l", data)]) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("not a zip", b"nope".to_vec()), + ("no maindoc", { + let mut z = ZipWriter::new(); + z.add("mimetype", MIMETYPE).unwrap(); + z.finish().unwrap() + }), + ("bad xml", archive("", &[])), + ("zero size", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"0\""), &[])), + ("huge", archive(&maindoc("RGBA", "").replace("width=\"12\"", "width=\"300000\"").replace("height=\"8\"", "height=\"300000\""), &[])), + ("version 1", one("RGBA", b"VERSION 1\n".to_vec())), + ("no header", one("RGBA", b"garbage".to_vec())), + ("big tiles", one("RGBA", b"VERSION 2\nTILEWIDTH 99999\nTILEHEIGHT 64\nPIXELSIZE 4\nDATA 0\n".to_vec())), + ("pixel size", one("RGBA", tile_file(2, &[]))), + ("too many tiles", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 999999\n").into_bytes())), + ("bad tile line", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,ZIP,4\n").into_bytes())), + ("truncated tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,400\n\0").into_bytes())), + ("raw size", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,3\n\0ab").into_bytes())), + ("flag", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n0,0,LZF,1\n\x07").into_bytes())), + ("far tile", one("RGBA", format!("VERSION 2\nTILEWIDTH {T}\nTILEHEIGHT {T}\nPIXELSIZE 4\nDATA 1\n2000000000,0,LZF,1\n\x01").into_bytes())), + ("bad opacity", archive(&maindoc("RGBA", r#""#), &[])), + ]; + for (what, bytes) in cases { + assert!(crate::import("x.kra", &bytes).is_err(), "{what}"); + } + let deep = maindoc("RGBA", &format!("{}{}", r#""#.repeat(150), "".repeat(150))); + assert!(matches!(crate::import("x.kra", &archive(&deep, &[])), Err(IoError::Unsupported(_)))); +} + +#[test] +fn truncated_and_corrupted_files_never_panic() { + let doc = maindoc( + "RGBA", + r#""#, + ); + let bytes = archive( + &doc, + &[ + ("img/layers/l", tile_file(4, &[(0, 0, bgra(|p| [p as u8; 4])), (4, 4, bgra(|_| [9; 4]))])), + ("img/layers/m.pixelselection", tile_file(1, &[(0, 0, vec![7; T * T])])), + ("mergedimage.png", merged_png()), + ], + ); + assert!(crate::import("ok.kra", &bytes).is_ok()); + for cut in (0..bytes.len()).step_by((bytes.len() / 300).max(1)) { + let _ = crate::import("x.kra", bytes.get(..cut).unwrap_or_default()); + } + let mut state = 0x1234_5678_u32; + for _ in 0..400 { + let mut b = bytes.clone(); + for _ in 0..3 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let i = state as usize % b.len(); + b[i] ^= (state >> 24) as u8 | 1; + } + let _ = crate::import("x.kra", &b); + } +} + +#[test] +fn space_names() { + assert_eq!(Space::from_name("RGBA"), Some(Space::Rgba8)); + assert_eq!(Space::from_name("RGBAF32"), None); + assert!(is_kra(&archive("", &[]))); +} + +#[test] +fn a_png_with_a_misleading_kra_extension_remains_a_png() { + for name in ["renamed.kra", "renamed.krz"] { + let r = crate::import(name, &merged_png()).unwrap(); + assert_eq!(r.document.size.width, 12); + assert!(!r.source_read_only, "a real PNG must not be treated as a Krita source"); + } +} + +#[test] +fn the_mimetype_wins_over_the_extension() { + let bytes = archive(&maindoc("RGBA", ""), &[]); + assert!(is_kra(&bytes)); + assert!(crate::import("download", &bytes).is_ok_and(|r| r.source_read_only)); +} diff --git a/crates/io/src/kra/tiles.rs b/crates/io/src/kra/tiles.rs new file mode 100644 index 0000000000..b369341b99 --- /dev/null +++ b/crates/io/src/kra/tiles.rs @@ -0,0 +1,259 @@ +//! Krita's tiled pixel files and the LZF compression inside them. + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_geom::Rect; +use photocraft_raster::{Interrupt, Surface}; + +use super::{Result, invalid}; +use crate::IoError; + +/// The pixel layouts we read, by Krita colour-space id. Samples are straight (unpremultiplied). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Space { + /// `RGBA`: B, G, R, A bytes. + Rgba8, + /// `RGBA16`: B, G, R, A as little-endian u16. + Rgba16, + /// `GRAYA`: gray, alpha bytes. + Gray8, + /// `GRAYAU16`: gray, alpha as little-endian u16. + Gray16, + /// Masks and selections: one byte. + Alpha8, +} + +impl Space { + pub(crate) fn from_name(name: &str) -> Option { + Some(match name { + "RGBA" => Space::Rgba8, + "RGBA16" => Space::Rgba16, + "GRAYA" => Space::Gray8, + "GRAYAU16" => Space::Gray16, + _ => return None, + }) + } + pub(crate) fn pixel_size(self) -> usize { + match self { + Space::Rgba8 => 4, + Space::Rgba16 => 8, + Space::Gray8 => 2, + Space::Gray16 => 4, + Space::Alpha8 => 1, + } + } + /// The document colour mode and depth for an image in this space. + pub(crate) fn model(self) -> (ColorMode, SampleType) { + match self { + Space::Rgba8 => (ColorMode::Rgb, SampleType::U8), + Space::Rgba16 => (ColorMode::Rgb, SampleType::U16), + Space::Gray8 | Space::Alpha8 => (ColorMode::Grayscale, SampleType::U8), + Space::Gray16 => (ColorMode::Grayscale, SampleType::U16), + } + } + /// The surface format whose bytes [`Self::convert`] writes directly. + fn native(self) -> PixelFormat { + match self { + Space::Alpha8 => PixelFormat::GRAY8, + s => { + let (mode, depth) = s.model(); + PixelFormat::new(mode, depth, true) + } + } + } + /// One interleaved Krita pixel as straight RGBA (0..1). + fn rgba(self, px: &[u8]) -> [f32; 4] { + let b = |i: usize| f32::from(px.get(i).copied().unwrap_or(0)) / 255.0; + let w = |i: usize| f32::from(u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)])) / 65535.0; + match self { + Space::Rgba8 => [b(2), b(1), b(0), b(3)], + Space::Rgba16 => [w(4), w(2), w(0), w(6)], + Space::Gray8 => [b(0), b(0), b(0), b(1)], + Space::Gray16 => [w(0), w(0), w(0), w(2)], + Space::Alpha8 => [b(0), b(0), b(0), 1.0], + } + } + /// A default pixel's samples (Krita order, 0..1), for [`Self::to_format`]. + pub(crate) fn normalised(self, px: &[u8]) -> Vec { + self.rgba(px).to_vec() + } + /// RGBA samples from [`Self::normalised`] as `fmt`'s channels. + pub(crate) fn to_format(self, rgba: &[f32], fmt: PixelFormat) -> Vec { + let p = [0, 1, 2, 3].map(|i| rgba.get(i).copied().unwrap_or(0.0)); + photocraft_raster::from_rgba(&fmt, p) + } + /// Interleaved Krita pixels as encoded `fmt` bytes. + fn convert(self, src: &[u8], fmt: PixelFormat, out: &mut Vec) { + out.clear(); + let ps = self.pixel_size(); + if fmt == self.native() { + for px in src.chunks_exact(ps) { + match self { + Space::Rgba8 => out.extend([2, 1, 0, 3].map(|i| px.get(i).copied().unwrap_or(0))), + Space::Rgba16 => { + for i in [4, 2, 0, 6] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray16 => { + for i in [0, 2] { + let v = u16::from_le_bytes([px.get(i).copied().unwrap_or(0), px.get(i + 1).copied().unwrap_or(0)]); + out.extend(v.to_ne_bytes()); + } + } + Space::Gray8 | Space::Alpha8 => out.extend_from_slice(px), + } + } + return; + } + let mut enc = vec![0; fmt.bytes_per_pixel()]; + for px in src.chunks_exact(ps) { + let v = photocraft_raster::from_rgba(&fmt, self.rgba(px)); + photocraft_raster::encode_pixel(&fmt, &v, &mut enc); + out.extend_from_slice(&enc); + } + } +} + +/// LZF decompression (the liblzf format) into exactly `expected` bytes. +pub(crate) fn lzf_decompress(src: &[u8], expected: usize) -> Result> { + let bad = || invalid("corrupt LZF tile data"); + let mut out = Vec::with_capacity(expected); + let mut ip = 0; + while ip < src.len() { + let ctrl = usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + if ctrl < 32 { + let len = ctrl + 1; + let lit = src.get(ip..ip + len).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + out.extend_from_slice(lit); + ip += len; + } else { + let mut len = ctrl >> 5; + if len == 7 { + len += usize::from(*src.get(ip).ok_or_else(bad)?); + ip += 1; + } + len += 2; + let back = ((ctrl & 0x1f) << 8) + usize::from(*src.get(ip).ok_or_else(bad)?) + 1; + ip += 1; + let start = out.len().checked_sub(back).ok_or_else(bad)?; + if out.len() + len > expected { + return Err(bad()); + } + // Back-references may overlap the bytes they produce, so copy one at a time. + for i in start..start + len { + let b = *out.get(i).ok_or_else(bad)?; + out.push(b); + } + } + } + if out.len() != expected { + return Err(invalid(format!("LZF tile data decodes to {} bytes, expected {expected}", out.len()))); + } + Ok(out) +} + +/// A parsed tiled pixel file (`VERSION 2`), borrowing the tile payloads. +pub(crate) struct TileFile<'a> { + pub(crate) width: usize, + pub(crate) height: usize, + pub(crate) pixel_size: usize, + tiles: Vec<(i32, i32, &'a [u8])>, +} + +fn line<'a>(bytes: &'a [u8], pos: &mut usize) -> Result<&'a str> { + let rest = bytes.get(*pos..).ok_or_else(|| invalid("truncated tile file"))?; + let end = rest.iter().take(256).position(|b| *b == b'\n').ok_or_else(|| invalid("truncated or malformed tile file header"))?; + *pos += end + 1; + std::str::from_utf8(rest.get(..end).unwrap_or_default()).map_err(|_| invalid("tile file header is not text")) +} + +fn header_value(bytes: &[u8], pos: &mut usize, key: &str, max: usize) -> Result { + let l = line(bytes, pos)?; + let v = l.strip_prefix(key).map(str::trim).ok_or_else(|| invalid(format!("tile file: expected {key}, found `{l}`")))?; + v.parse::().ok().filter(|v| (1..=max).contains(v)).ok_or_else(|| invalid(format!("tile file: invalid {key} `{v}`"))) +} + +impl<'a> TileFile<'a> { + pub(crate) fn parse(bytes: &'a [u8]) -> Result> { + let mut pos = 0; + let version = line(bytes, &mut pos)?; + if version.trim() != "VERSION 2" { + return Err(IoError::Unsupported(format!("Krita tile file `{}` (only VERSION 2, Krita 2.0 and later)", version.trim()))); + } + let width = header_value(bytes, &mut pos, "TILEWIDTH", 1024)?; + let height = header_value(bytes, &mut pos, "TILEHEIGHT", 1024)?; + let pixel_size = header_value(bytes, &mut pos, "PIXELSIZE", 64)?; + let l = line(bytes, &mut pos)?; + let count = l + .strip_prefix("DATA") + .map(str::trim) + .and_then(|v| v.parse::().ok()) + .ok_or_else(|| invalid(format!("tile file: invalid DATA line `{l}`")))?; + // Every tile needs at least a header line and a flag byte. + if count > bytes.len() / 8 + 1 { + return Err(invalid("tile count exceeds the file size")); + } + let mut tiles = Vec::with_capacity(count); + for _ in 0..count { + let l = line(bytes, &mut pos)?; + let mut f = l.split(','); + let (x, y, method, len) = (f.next(), f.next(), f.next(), f.next()); + let coord = |v: Option<&str>| v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.unsigned_abs() <= super::MAX_SIDE * 4); + let (Some(x), Some(y), Some("LZF"), Some(len), None) = + (coord(x), coord(y), method.map(str::trim), len.and_then(|v| v.trim().parse::().ok()), f.next()) + else { + return Err(invalid(format!("tile file: malformed tile header `{l}`"))); + }; + let data = bytes.get(pos..pos.checked_add(len).ok_or_else(|| invalid("tile size overflow"))?).ok_or_else(|| invalid("truncated tile data"))?; + pos += len; + tiles.push((x, y, data)); + } + Ok(TileFile { width, height, pixel_size, tiles }) + } + + /// Bytes the decoded tiles take at `bpp` bytes per pixel. + pub(crate) fn decoded_bytes(&self, bpp: usize) -> Result { + self.tiles.len().checked_mul(self.width * self.height).and_then(|n| n.checked_mul(bpp)).ok_or_else(|| invalid("tile size overflow")) + } + + /// Writes every tile into `surface` (format `surface.format()`), offset by (`dx`, `dy`). + pub(crate) fn decode_into(&self, surface: &mut Surface, space: Space, dx: i32, dy: i32, ctl: &Interrupt<'_>) -> Result<()> { + let fmt = surface.format(); + let n = self.width * self.height; + let expected = n * self.pixel_size; + let mut interleaved = vec![0u8; expected]; + let mut out = Vec::new(); + for &(x, y, data) in &self.tiles { + ctl.check().map_err(|_| IoError::Cancelled)?; + let (flag, payload) = data.split_first().ok_or_else(|| invalid("empty tile"))?; + let planar = match flag { + 1 => lzf_decompress(payload, expected)?, + 0 if payload.len() == expected => payload.to_vec(), + 0 => return Err(invalid("raw tile has the wrong size")), + f => return Err(invalid(format!("unknown tile compression flag {f}"))), + }; + // Byte planes: byte k of pixel i is at k * n + i. + for (k, plane) in planar.chunks_exact(n).enumerate() { + for (i, b) in plane.iter().enumerate() { + if let Some(slot) = interleaved.get_mut(i * self.pixel_size + k) { + *slot = *b; + } + } + } + space.convert(&interleaved, fmt, &mut out); + let (x0, y0) = + (x.checked_add(dx).ok_or_else(|| invalid("tile offset overflow"))?, y.checked_add(dy).ok_or_else(|| invalid("tile offset overflow"))?); + let rect = Rect::from_xywh(x0, y0, self.width as u32, self.height as u32); + if rect.width() as usize != self.width || rect.height() as usize != self.height || out.len() != n * fmt.bytes_per_pixel() { + return Err(invalid("tile does not fit its position")); + } + surface.write_interleaved(rect, &out); + } + Ok(()) + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index b6120eb088..87f40b12df 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -18,7 +18,8 @@ //! * Paint.NET PDN3 documents open as editable bitmap layers (import only). //! * OpenRaster (`.ora`) documents open and save with their layers, groups, blend modes, //! opacity and visibility; see `ora`. -//! * Krita (`.kra`) documents open as their merged composite; source layers are not imported. +//! * Krita (`.kra`, `.krz`) documents open with their paint layers, groups and transparency +//! masks (import only); see `kra`. //! * Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`) open natively //! with no source save path, what isn't imported listed in the warnings; a file //! whose native data can't be read opens as its embedded preview; see `affinity`. @@ -82,12 +83,12 @@ pub enum IoError { /// Paint.NET document decode failure. #[error("PDN: {0}")] Pdn(String), + /// Krita document read failure. + #[error("Krita: {0}")] + Kra(String), /// OpenRaster read failure. #[error("OpenRaster: {0}")] Ora(String), - /// Krita document decode failure. - #[error("Krita: {0}")] - Kra(String), /// PSD parse/write failure. #[error("PSD: {0}")] Psd(#[from] PsdError), @@ -288,9 +289,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m if ora::is_ora(bytes) || has_extension(name, "ora") { return ora::import(name, bytes, ctl); } - // Krita files are ZIP containers carrying a merged PNG. A recognized raster signature - // still wins over a misleading .kra suffix (as with SVG imports). - if kra::is_kra(bytes) || (has_extension(name, "kra") && photocraft_codecs::detect(bytes).is_none()) { + // Krita files are ZIP containers. A recognized raster signature still wins over a + // misleading .kra suffix (as with SVG imports). + if kra::is_kra(bytes) || (kra::EXTENSIONS.iter().any(|e| has_extension(name, e)) && photocraft_codecs::detect(bytes).is_none()) { return kra::import(name, bytes, ctl); } if has_extension(name, "pdn") || bytes.starts_with(b"PDN3") { @@ -382,6 +383,9 @@ fn export_stages(doc: &Document, name_or_ext: &str, opts: &ExportOptions, ctl: & if affinity::EXTENSIONS.contains(&ext.as_str()) { return Err(IoError::Unsupported("Affinity export is not implemented; save a new PSD, PNG or .pcraft copy".into())); } + if kra::EXTENSIONS.contains(&ext.as_str()) { + return Err(IoError::Unsupported("Krita files are import-only; save as .ora to open the layers in Krita, or .pcraft to keep everything".into())); + } if ext == "ora" { return ora::export(doc); } diff --git a/crates/io/tests/krita_corpus.rs b/crates/io/tests/krita_corpus.rs index cafbe2c718..b0b3a01d63 100644 --- a/crates/io/tests/krita_corpus.rs +++ b/crates/io/tests/krita_corpus.rs @@ -1,4 +1,4 @@ -//! Krita-authored oracles: OpenRaster (`.ora`) files whose archives carry +//! Krita-authored oracles: OpenRaster (`.ora`) and Krita (`.kra`) files whose archives carry //! Krita's own rendering of the layer stack (`mergedimage.png`). The files are generated by //! `tools/krita-oracles/generate.py` in https://github.com/storytold/photocraft-corpus and are //! not bundled. Run with @@ -47,6 +47,10 @@ const KNOWN: &[(&str, f32, &str)] = &[ ("blend-lighter_color.ora", 0.02, "Krita weighs the channels differently when it picks the lighter colour; near-ties pick the other pixel"), ("blend-hard_mix_photoshop.ora", 0.02, "a channel sum of exactly 1 goes up in Photoshop's Hard Mix (ours) and down in Krita's"), ("depth-rgba-u16.ora", 1.0, "Krita blends this linear-light 16-bit document in linear light, but its ORA export stores sRGB 8-bit layers"), + ("blend-darker_color.kra", 0.02, "as for the .ora"), + ("blend-lighter_color.kra", 0.02, "as for the .ora"), + ("blend-hard_mix_photoshop.kra", 0.02, "as for the .ora"), + ("nodes-vector-unsupported.kra", 0.2, "vector layers are left out of .kra imports (reported); Krita's merged image has the shape"), ]; fn run(ext: &str, tolerance: f32) { @@ -85,3 +89,9 @@ fn run(ext: &str, tolerance: f32) { fn krita_ora_files_match_their_merged_image() { run("ora", 3.0 / 255.0); } + +#[test] +#[ignore = "requires KRITA_TEST_DIR with Krita-authored .kra files"] +fn krita_kra_files_match_their_merged_image() { + run("kra", 3.0 / 255.0); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc8d8bdf27..ea40171b9a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1259,7 +1259,8 @@ impl PhotocraftApp { return self.save_to(path, tga_bits); } let st = self.session.active().ok_or("no document")?; - // PDN imports default to our native format, which preserves Paint.NET's blend modes. + // PDN imports default to our native format, which preserves Paint.NET's blend modes; + // Krita imports to OpenRaster, which Krita opens with its layers. // Other files keep their format only when it can retain their layers. A plain raster // (including an unlocked transparent PNG) can keep its flat format; editable contents, // masks and layer appearance settings need a layered format even with just one layer. @@ -1273,7 +1274,11 @@ impl PhotocraftApp { Some(p) if writable => p.clone(), p => { let source = p.as_deref().unwrap_or(&st.doc.name); - let ext = if photocraft_engine::file_cmds::extension(source).as_deref() == Some("pdn") { "pcraft" } else { "psd" }; + let ext = match photocraft_engine::file_cmds::extension(source).as_deref() { + Some("pdn") => "pcraft", + Some("kra" | "krz") => "ora", + _ => "psd", + }; std::path::Path::new(source).with_extension(ext).to_string_lossy().into_owned() } }; diff --git a/docs/kra.md b/docs/kra.md new file mode 100644 index 0000000000..847a1f32d2 --- /dev/null +++ b/docs/kra.md @@ -0,0 +1,77 @@ +# Krita documents + +PhotoCraft opens Krita's native documents (`.kra`, and the `.krz` archive variant) with their +layers. File › Open, recent files, drops, the CLI (`convert`, `info`, `batch`) and automation +(`doc.open`, MCP) all use the reader in `photocraft-io`; the browser build accepts them too. A +file is recognised by its `mimetype` entry (`application/x-krita`). Krita files are import-only: +Save suggests OpenRaster (`.ora`), which Krita opens with its layers, and `.pcraft` keeps +everything. + +## What is read + +| Krita | PhotoCraft | +|---|---| +| Paint layers, 8 and 16-bit RGBA, 8 and 16-bit gray | pixel layers, at the document's depth (a gray layer in an RGB document is converted) | +| Layer position (`x`, `y`), tiles anywhere, also off-canvas | kept | +| Groups, nested; pass-through groups; collapsed state | groups; pass-through; expanded state | +| Names, visibility, opacity, lock | kept | +| Blend modes | Krita's ids, mapped as for OpenRaster (`docs/ora.md`); a few are approximations, reported | +| Transparency masks | the layer mask (the first one; more are reported and left out) | +| Selection masks | ignored (a layer's local selection, not part of its look) | +| The image's ICC profile, resolution | kept | + +Left out with a warning per layer: vector, filter, fill, file and clone layers, filter, transform +and colorize masks, layers in another colour space than those above, Inherit Alpha, per-channel +blending flags (channel locks), and animation beyond the frame saved in the layer. Drawing +assistants and reference images are reported as left out. Krita's merged image still shows +the original look, so the result can differ where those were used. + +Documents whose image colour space we don't read yet (CMYK, Lab, XYZ, YCbCr, 16 and 32-bit +float) open as Krita's merged image (`mergedimage.png`), flagged as a preview, with a warning. + +## The format, as observed + +A ZIP whose first, stored entry is `mimetype`; `maindoc.xml` holds the node tree (topmost first) +and points each paint layer and mask at a file under `/layers/`. Those files are a +text header (`VERSION 2`, `TILEWIDTH`, `TILEHEIGHT`, `PIXELSIZE`, `DATA `) and per tile a +`x,y,LZF,` line followed by a compression flag and the tile's pixels in byte planes, LZF +compressed. RGB pixels are B, G, R, A; 16-bit samples are little-endian. A `.defaultpixel` file +next to each holds the colour outside its tiles. A group's own `x`/`y` does not move its +children (checked against Krita's merged image). + +## Limits and safety + +The reader uses the bounded ZIP reader of `photocraft-format` (no ZIP64, CRC-checked), parses +`maindoc.xml` with `roxmltree` (32 MiB cap; the DTD is declared by public id and not fetched), +and decodes tiles with a bounds-checked LZF decoder that must produce exactly one tile. Tiles +are at most 1024 px a side; canvases at most 300,000 px a side and 1 GiB of decoded pixels; +8,192 nodes; nesting at most `MAX_GROUP_DEPTH`. Version 1 tile files (Krita 1.x) are refused. +Malformed archives, XML, numbers, tile headers and tile data return errors; truncated and +corrupted files are covered by tests. + +## Verification + +Unit tests (`crates/io/src/kra/tests.rs`) build Krita archives in code (raw and LZF tiles) and +check layers, groups, pass-through, offsets, opacity, visibility, locks, masks, default pixels, +16-bit and gray conversion, the merged-image fallback, LZF edge cases and malformed input. +`crates/automation/tests/krita.rs` opens a `.kra` through the agent surface, edits it and saves +it as OpenRaster. `crates/engine/tests/kra.rs` (opt-in) opens a Krita-authored 16-bit file in a +session, checks it is read-only, edits with undo and saves as `.ora` and `.pcraft`. + +Real-file checks use the Krita-authored oracles in +[photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`krita/`, made by +`tools/krita-oracles/generate.py`, which removes Krita's embedded third-party ICC profiles so +the files are sRGB): + +```sh +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture +KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-engine --test kra -- --ignored +``` + +2026-10-09, Krita 5.2.9: 31 of 35 `.kra` files render within 3/255 of Krita's merged image; the +others differ for known reasons (the three blend-mode tie-breaks as for OpenRaster, and the +file with a vector layer, which is left out). Before the profiles were stripped, a linear-light +16-bit document with its embedded profile matched Krita's merged image within 0.5/255 after +flattening and converting to sRGB. + +The reader is written from files Krita writes; no Krita code was used. diff --git a/docs/ora.md b/docs/ora.md index ac83b847b8..263befc00c 100644 --- a/docs/ora.md +++ b/docs/ora.md @@ -57,13 +57,12 @@ Real-file checks use Krita-authored oracles from KRITA_TEST_DIR=/path/to/photocraft-corpus/krita cargo test -p photocraft-io --test krita_corpus -- --ignored --nocapture ``` -2026-10-09, Krita 5.2.9: 30 of 34 `.ora` files render within 3/255 of Krita's merged image; the -other four differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's -tie-break in Darker and Lighter Color, Hard Mix at a channel sum of exactly 1, and a -linear-light 16-bit document that Krita blends in linear light). In the other direction, -Krita opened every PhotoCraft re-export of those files and rendered 33 of them identically to -its original; the exception is Photoshop's Soft Light, which is written as `svg:soft-light`. -MyPaint and GIMP files have not been checked yet. +2026-10-09, Krita 5.2.9: 32 of 35 `.ora` files render within 3/255 of Krita's merged image; the +other three differ for known reasons listed in `crates/io/tests/krita_corpus.rs` (Krita's +tie-break in Darker and Lighter Color, and Hard Mix at a channel sum of exactly 1). In the other +direction, Krita opened every PhotoCraft re-export of the first 34 of those files and rendered 33 +of them identically to its original; the exception is Photoshop's Soft Light, which is written +as `svg:soft-light`. MyPaint and GIMP files have not been checked yet. Format reference: the [OpenRaster specification](https://www.openraster.org/). The reader and writer are written from the specification and from files Krita writes; no application code diff --git a/packaging/linux/ai.storyteller.photocraft.desktop b/packaging/linux/ai.storyteller.photocraft.desktop index a32b55cef5..bee0496f39 100644 --- a/packaging/linux/ai.storyteller.photocraft.desktop +++ b/packaging/linux/ai.storyteller.photocraft.desktop @@ -12,7 +12,7 @@ StartupNotify=false StartupWMClass=ai.storyteller.photocraft Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;image;editor;psd;layers;retouch;paint; -MimeType=application/x-photocraft;image/x-paintnet;image/openraster;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; +MimeType=application/x-photocraft;image/x-paintnet;image/openraster;application/x-krita;image/vnd.adobe.photoshop;image/x-psd;image/x-psb;image/png;image/jpeg;image/tiff;image/webp;image/gif;image/bmp;image/x-tga;image/x-icon;image/vnd.microsoft.icon;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-pixmap;image/x-exr;image/vnd.radiance;image/avif;image/qoi; # AppRun registers the desktop entry and icons itself (#593); tell external # AppImage integrators (AppImageLauncher, appimaged) not to add a duplicate (#3623). X-AppImage-Integrate=false diff --git a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in index 6fbba13511..932ff44e4c 100644 --- a/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.photocraft.metainfo.xml.in @@ -64,6 +64,7 @@ application/x-photocraft image/x-paintnet image/openraster + application/x-krita image/vnd.adobe.photoshop image/x-psb diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index 3a190a4b17..f573b316f4 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -112,6 +112,21 @@ image/openraster + + UTTypeIdentifier + ai.storyteller.photocraft.kra + UTTypeDescription + Krita Document + UTTypeConformsTo + public.datapublic.image + UTTypeTagSpecification + + public.filename-extension + krakrz + public.mime-type + application/x-krita + + UTTypeIdentifier ai.storyteller.photocraft.pdn @@ -212,6 +227,7 @@ ai.storyteller.photocraft.qoi ai.storyteller.photocraft.pdn ai.storyteller.photocraft.ora + ai.storyteller.photocraft.kra diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index 98dc1fdfd3..8be9ff893c 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -103,6 +103,8 @@ + + @@ -126,6 +128,8 @@ + + From 20539b5dc366c70f3ee8fd0502fa2f9b5f7ef17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dejan=20Koci=C4=87?= Date: Tue, 13 Oct 2026 04:50:20 +0200 Subject: [PATCH 314/523] i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436) * i18n(sr): add a Serbian (Latin, Ekavian) UI catalog Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures plus the union of the dynamic labels the other catalogs translate. The catalog is written in Latin script, Ekavian; every Serbian locale (sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it. Plurals use the one/few/many rule (plural_russian) with three forms per @plural entry. Original translations from the plain meaning of the English labels; no proprietary localisation resources were used (ATTRIBUTION.md and LICENSE-translations.txt updated). Tests cover locale resolution, contextual and id overrides, placeholders, and plurals across teens and compound counts. Co-Authored-By: Claude Opus 5.5 * Info.plist: declare the Serbian localization Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Dejan Port Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + .../ui-egui/src/i18n/LICENSE-translations.txt | 9 +- crates/ui-egui/src/i18n/live_tests.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 66 +- crates/ui-egui/src/i18n/sr.tsv | 4169 +++++++++++++++++ crates/ui-egui/src/i18n/system.rs | 2 + docs/localization-parity.md | 7 +- docs/localization.md | 4 +- packaging/macos/Info.plist.in | 1 + xtask/src/i18n_coverage.rs | 1 + 10 files changed, 4254 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/i18n/sr.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 4b88e0901b..aa9f590ceb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,6 +23,7 @@ next to them. | `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `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) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | 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 | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | | `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/crates/ui-egui/src/i18n/LICENSE-translations.txt b/crates/ui-egui/src/i18n/LICENSE-translations.txt index 0177bcb56d..c33e7a21e9 100644 --- a/crates/ui-egui/src/i18n/LICENSE-translations.txt +++ b/crates/ui-egui/src/i18n/LICENSE-translations.txt @@ -1,6 +1,6 @@ -PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and -Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full -license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. +PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish, +Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. +The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE. Korean translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. @@ -28,3 +28,6 @@ PhotoCraft's English labels; no proprietary translation resources were used. Hungarian translations: PhotoCraft contributors, original translations of PhotoCraft's English labels; no proprietary translation resources were used. + +Serbian translations: PhotoCraft contributors, original translations of +PhotoCraft's English labels; no proprietary translation resources were used. diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index 6dcec32e92..6ab8b340ad 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -20,6 +20,7 @@ fn requested_languages_resolve_regional_preferences() { ("fr-CA", "fr"), ("id-ID", "id"), ("vi-VN", "vi"), + ("sr-RS", "sr"), ] { assert_eq!(Lang::from_pref(tag), lang(code), "{tag}"); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index bdf4ef5565..88ad43715f 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 20] = [ +pub static LANGUAGES: [LangInfo; 21] = [ LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -198,6 +198,10 @@ pub static LANGUAGES: [LangInfo; 20] = [ LangInfo { code: "ru", name: "Русский", source: catalog!("ru"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, // Ukrainian has the same one/few/many rule for integer counts. LangInfo { code: "uk", name: "Українська", source: catalog!("uk"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, + // Serbian (Latin script, Ekavian) has the same one/few/many rule for integer counts. `sr-RS`, + // `sr-Latn-*` and `sr_RS@latin` resolve here; so do Cyrillic `sr` locales, which read Latin + // rather than English. + LangInfo { code: "sr", name: "Srpski", source: catalog!("sr"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() }, LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, @@ -680,6 +684,66 @@ mod tests { } } + #[test] + fn serbian_resolves_locales_and_preferences() { + let sr = Lang::from_code("sr").expect("sr registered"); + assert_eq!(sr.name(), "Srpski"); + assert!(sr.complete_menus()); + for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] { + assert_eq!(lang_from_tag(tag), Some(sr), "{tag}"); + assert_eq!(Lang::from_pref(tag), sr, "{tag}"); + } + assert_eq!(tr(sr, "File"), "Datoteka"); + assert_eq!(tr(sr, "Layer"), "Sloj"); + assert_eq!(tr(sr, "New document…"), "Novi dokument…"); + assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label"); + assert_eq!(tr(sr, "All"), "Sve"); + assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo"); + assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano"); + assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)"); + for key in ["Alt", "⌥"] { + assert_eq!( + fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]), + format!("Dodaj masku (iz selekcije; {key} invertuje)") + ); + } + } + + #[test] + fn serbian_plural_messages_handle_teens_and_compound_counts() { + let sr = Lang::from_code("sr").expect("sr registered"); + for (n, item, layer) in [ + (0, "stavki", "slojeva"), + (1, "stavka", "sloj"), + (2, "stavke", "sloja"), + (4, "stavke", "sloja"), + (5, "stavki", "slojeva"), + (11, "stavki", "slojeva"), + (12, "stavki", "slojeva"), + (14, "stavki", "slojeva"), + (21, "stavka", "sloj"), + (22, "stavke", "sloja"), + (25, "stavki", "slojeva"), + (101, "stavka", "sloj"), + (111, "stavki", "slojeva"), + (112, "stavki", "slojeva"), + (122, "stavke", "sloja"), + ] { + assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}")); + assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}")); + assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}")); + } + for (n, expected) in [ + (1, "Uklonjena je 1 tačka uzorkovanja"), + (3, "Uklonjene su 3 tačke uzorkovanja"), + (5, "Uklonjeno je 5 tačaka uzorkovanja"), + (13, "Uklonjeno je 13 tačaka uzorkovanja"), + (21, "Uklonjena je 21 tačka uzorkovanja"), + ] { + assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected); + } + } + #[test] fn catalog_kinds_are_parsed_and_looked_up() { let c = Catalog::parse("# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n"); diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv new file mode 100644 index 0000000000..9d8aebef63 --- /dev/null +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -0,0 +1,4169 @@ +# Serbian (sr) catalog, Latin script, Ekavian. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists +# the Serbian forms "one|few|many" (1, 21, 101 … | 2–4, 22–24 … but +# not 12–14 | everything else), see `plural` in i18n/mod.rs +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Serbian computing vocabulary (sloj, maska, selekcija, četkica, gradijent, zamućenje, +# neprozirnost, providnost ...). No proprietary localisation resource, string table or product UI +# was consulted or copied; in particular not the Serbian localisation of Adobe Photoshop. Add +# entries the same way. +# Conventions: Serbian Latin script (latinica), Ekavian (ekavica), sentence case. Menus are nouns +# (Datoteka, Uređivanje, Slika ...), commands are imperatives (Otvori…, Sačuvaj kao…), tools read +# "Alatka …" (Alatka četkica, Alatka za pomeranje), presets are "šabloni", patterns "šare" and +# swatches "uzorci boja". Serbian quotation marks „…“ around names. Product and technology names +# (PhotoCraft, ArtCraft, Camera Raw, OpenType, Lab ...) stay as they are; RGB/CMYK stay as they +# are; the trailing "…" of dialog commands is kept. A count that is not an @plural entry is kept +# out of noun agreement ("Broj: {n}", "slojeva: {n}") so every number reads correctly. + +# Guide layout dialog + Rows Redovi + Number Broj + Margin Margina + Top: Gore: + Left: Levo: + Bottom: Dole: + Right: Desno: + Target: Cilj: + Selected Artboards Izabrane radne table + Center Columns Centriraj kolone + Clear Existing Guides Ukloni postojeće vodilje + Invalid guide layout Neispravan raspored vodilja + Leave empty for automatic size Ostavite prazno za automatsku veličinu + 5 Columns, 3 Rows 5 kolona, 3 reda + 8 Columns 8 kolona + 12 Columns 12 kolona + + File Datoteka + Edit Uređivanje + Image Slika + Layer Sloj + Type Tekst + Select Selekcija + Selected Izabrano + Scope Opseg + Top Left Gore levo + Top Right Gore desno + Bottom Left Dole levo + Bottom Right Dole desno + Filter Filter + View Prikaz + Window Prozor + Help Pomoć + (coming soon) (uskoro) + New… Novo… + Open… Otvori… + Open As… Otvori kao… + Open EXR Parts… Otvori EXR delove… + Add beauty precomp from the AOVs Dodaj „beauty“ predkompoziciju iz AOV prolaza + Both Oba + Left Levo + Right Desno + Open Recent Otvori nedavno + Clear Recent Files Obriši spisak nedavnih datoteka + Remove from Recent Ukloni iz nedavnih + Remove {name} from Recent Ukloni {name} iz nedavnih + Close Zatvori + Minimize Umanji + Maximize Uvećaj + Restore Vrati + Close All Zatvori sve + Close Others Zatvori ostale + Save Sačuvaj + Save As… Sačuvaj kao… + Save a Copy… Sačuvaj kopiju… + Revert Vrati sačuvano + Export Izvoz + Quick Export as PNG Brzi izvoz u PNG + Export As… Izvezi kao… + Export Preferences… Podešavanja izvoza… + Save for Web (Legacy)… Sačuvaj za veb (staro)… + Artboards to Files… Radne table u datoteke… + Artboards to PDF… Radne table u PDF… + Layers to Files… Slojevi u datoteke… + Layer Comps to Files… Kompozicije slojeva u datoteke… + Color Lookup Tables… Tabele preslikavanja boja… + Data Sets as Files… Skupovi podataka kao datoteke… + Paths to Illustrator… Putanje u Illustrator… + Render Video… Renderuj video… + Generate Generisanje + Image Assets Slikovni resursi + Place Embedded… Postavi ugrađeno… + Place Linked… Postavi povezano… + Package… Spakuj… + Automate Automatizacija + Batch… Grupna obrada… + Create Droplet… Napravi droplet… + Crop and Straighten Photos Opseci i ispravi fotografije + Contact Sheet II… Kontakt-list II… + Conditional Mode Change… Uslovna promena režima… + Fit Image… Uklopi sliku… + Lens Correction… Korekcija objektiva… + Merge to HDR Pro… Spoji u HDR Pro… + Photomerge… Spajanje fotografija… + Scripts Skripte + Image Processor… Obrada slika… + Delete All Empty Layers Obriši sve prazne slojeve + Flatten All Layer Effects Spljošti sve efekte slojeva + Flatten All Masks Spljošti sve maske + Script Events Manager… Upravljač događajima skripti… + Import Uvoz + Browse… Pregledaj… + Reveal in Finder Prikaži u Finderu + Show in Explorer Prikaži u Exploreru + Show in Folder Prikaži u fascikli + File Info… Informacije o datoteci… + Print… Štampaj… + Print One Copy Štampaj jedan primerak + Exit Izađi + Undo Opozovi + Redo Ponovi + Toggle Last State Prebaci na poslednje stanje + Fade… Ublaži… + Cut Iseci + Copy Kopiraj + Copy Merged Kopiraj spojeno + Paste Nalepi + Paste Special Specijalno lepljenje + Paste in Place Nalepi na isto mesto + Paste Into Nalepi unutra + Paste Outside Nalepi spolja + Paste Lorem Ipsum Nalepi Lorem Ipsum + Clear Obriši + Search… Pretraga… + Check Spelling… Proveri pravopis… + Find and Replace Text… Pronađi i zameni tekst… + Fill… Popuni… + Stroke… Ocrtaj… + Content-Aware Fill… Popuna prema sadržaju… + Delete and Fill Selection Obriši i popuni selekciju + Content-Aware Scale Skaliranje prema sadržaju + Puppet Warp Lutkarsko krivljenje + Perspective Warp Perspektivno krivljenje + Free Transform Slobodna transformacija + Transform Transformacija + Again Ponovo + Scale Skaliranje + Rotate Rotacija + Skew Iskošenje + Distort Izobličenje + Perspective Perspektiva + Warp Krivljenje + Flip Horizontal Okreni horizontalno + Flip Vertical Okreni vertikalno + Rotate 180° Rotiraj za 180° + Rotate 90° Clockwise Rotiraj za 90° udesno + Rotate 90° Counter Clockwise Rotiraj za 90° ulevo + 180° 180° + 90° Clockwise 90° udesno + 90° Counter Clockwise 90° ulevo + Arbitrary… Proizvoljno… + Auto-Align Layers… Automatski poravnaj slojeve… + Auto-Blend Layers… Automatski stopi slojeve… + Define Brush Preset… Definiši šablon četkice… + Define Pattern… Definiši šaru… + Define Custom Shape… Definiši prilagođeni oblik… + Purge Isprazni + Clipboard Ostava + Histories Istorije + Video Cache Video keš + All Sve + Presets Šabloni + Preset Manager… Upravljač šablonima… + Migrate Presets Prenesi šablone + Export/Import Presets… Izvoz/uvoz šablona… + Color Settings… Podešavanja boja… + Assign Profile… Dodeli profil… + Convert to Profile… Pretvori u profil… + Keyboard Shortcuts… Prečice na tastaturi… + Menus… Meniji… + Toolbar… Traka alatki… + Preferences Podešavanja + General… Opšte… + Interface… Interfejs… + Workspace… Radni prostor… + Tools… Alatke… + History Log… Dnevnik istorije… + File Handling… Rukovanje datotekama… + Export… Izvoz… + Performance… Performanse… + Scratch Disks… Radni diskovi… + Cursors… Kursori… + Transparency & Gamut… Providnost i gamut… + Units & Rulers… Jedinice i lenjiri… + Guides, Grid & Slices… Vodilje, mreža i isečci… + Plug-ins… Dodaci… + Type… Tekst… + Enhanced Controls… Napredne kontrole… + Camera Raw… Camera Raw… + Raw Defaults… Podrazumevana Raw podešavanja… + Integrations… Integracije… + AI Integrations… AI integracije… + Install Plug-in… Instaliraj dodatak… + Language Options Jezičke opcije + Settings… Podešavanja… + Mode Režim + Bitmap Bitmapa + Grayscale Nijanse sive + Duotone Duoton + Indexed Color Indeksirane boje + RGB Color RGB boje + CMYK Color CMYK boje + Lab Color Lab boje + Multichannel Višekanalno + 8 Bits/Channel 8 bita/kanal + 16 Bits/Channel 16 bita/kanal + 32 Bits/Channel 32 bita/kanal + Color Table… Tabela boja… + Bitmap… Bitmapa… + Duotone… Duoton… + Indexed Color… Indeksirane boje… + Adjustments Korekcije + Brightness/Contrast… Osvetljenost/kontrast… + Levels… Nivoi… + Curves… Krive… + Exposure… Ekspozicija… + Vibrance… Živost… + Hue/Saturation… Nijansa/zasićenost… + Color Balance… Balans boja… + Black & White… Crno-belo… + Photo Filter… Foto-filter… + Channel Mixer… Mešač kanala… + Color Lookup… Preslikavanje boja… + Invert Invertuj + Posterize… Posterizuj… + Threshold… Prag… + Gradient Map… Mapa gradijenta… + Selective Color… Selektivna boja… + Shadows/Highlights… Senke/svetla… + HDR Toning… HDR toniranje… + Desaturate Ukloni zasićenost + Match Color… Uskladi boje… + Replace Color… Zameni boju… + Equalize Izjednači + Auto Tone Automatski ton + Auto Contrast Automatski kontrast + Auto Color Automatske boje + Image Size… Veličina slike… + Canvas Size… Veličina platna… + Image Rotation Rotacija slike + Flip Canvas Horizontal Okreni platno horizontalno + Flip Canvas Vertical Okreni platno vertikalno + Crop Opseci + Trim… Odseci ivice… + Reveal All Otkrij sve + Duplicate… Dupliraj… + Apply Image… Primeni sliku… + Calculations… Proračuni… + Variables Promenljive + Define… Definiši… + Data Sets… Skupovi podataka… + Apply Data Set… Primeni skup podataka… + Variable Data Sets… Promenljivi skupovi podataka… + Trap… Trepovanje… + Analysis Analiza + Set Measurement Scale… Postavi razmeru merenja… + Select Data Points… Izaberi tačke podataka… + Record Measurements Zabeleži merenja + Ruler Tool Alatka lenjir + Count Tool Alatka za brojanje + Place Scale Marker… Postavi oznaku razmere… + Custom… Prilagođeno… + New Novo + Layer… Sloj… + Group… Grupa… + Group from Layers… Grupa od slojeva… + Artboard… Radna tabla… + Artboard from Group… Radna tabla od grupe… + Artboard from Layers… Radna tabla od slojeva… + Layer via Copy Sloj kopiranjem + Layer via Cut Sloj isecanjem + Layer From Background… Sloj od pozadine… + Layer from Background… Sloj od pozadine… + Background From Layer Pozadina od sloja + New Shape Layer Novi sloj oblika + New Type Layer Novi tekstualni sloj + New Fill Layer Novi sloj popune + New Adjustment Layer Novi korekcioni sloj + New Layer Based Slice Novi isečak zasnovan na sloju + New Smart Object via Copy Novi pametni objekat kopiranjem + New Blank Video Layer Novi prazan video sloj + New Video Layer from File… Novi video sloj iz datoteke… + Duplicate Layer… Dupliraj sloj… + Delete Layer Obriši sloj + Delete Obriši + Rename Layer Preimenuj sloj + Create Layer Napravi sloj + Layer Style Stil sloja + Blending Options… Opcije stapanja… + Bevel & Emboss… Kosina i reljef… + Inner Shadow… Unutrašnja senka… + Inner Glow… Unutrašnji sjaj… + Satin… Saten… + Color Overlay… Prekrivanje bojom… + Gradient Overlay… Prekrivanje gradijentom… + Pattern Overlay… Prekrivanje šarom… + Outer Glow… Spoljašnji sjaj… + Drop Shadow… Bačena senka… + Copy Layer Style Kopiraj stil sloja + Paste Layer Style Nalepi stil sloja + Clear Layer Style Ukloni stil sloja + Global Light… Globalno svetlo… + Hide All Effects Sakrij sve efekte + Scale Effects… Skaliraj efekte… + Smart Filter Pametni filter + Convert for Smart Filters Pretvori za pametne filtere + Clear Smart Filters Ukloni pametne filtere + Disable Smart Filters Onemogući pametne filtere + Delete Filter Mask Obriši masku filtera + Disable Filter Mask Onemogući masku filtera + Solid Color… Puna boja… + Gradient… Gradijent… + Pattern… Šara… + Layer Mask Maska sloja + Vector Mask Vektorska maska + Reveal Selection Otkrij selekciju + Hide Selection Sakrij selekciju + Hide All Sakrij sve + From Transparency Iz providnosti + Delete Vector Mask Obriši vektorsku masku + Enable Layer Mask Omogući masku sloja + Disable Layer Mask Onemogući masku sloja + Enable Vector Mask Omogući vektorsku masku + Link Layer Mask Poveži masku sloja + Unlink Layer Mask Razveži masku sloja + Link Vector Mask Poveži vektorsku masku + Add Vector Mask Dodaj vektorsku masku + Vector Mask from Current Path Vektorska maska od tekuće putanje + Create Clipping Mask Napravi masku isecanja + Release Clipping Mask Otpusti masku isecanja + Smart Objects Pametni objekti + Smart Object Pametni objekat + Convert to Smart Object Pretvori u pametni objekat + Convert to Embedded Pretvori u ugrađeni + Convert to Layers Pretvori u slojeve + Convert to Linked… Pretvori u povezani… + Edit Contents Uredi sadržaj + Replace Contents… Zameni sadržaj… + Relink to File… Ponovo poveži sa datotekom… + Export Contents… Izvezi sadržaj… + Update Modified Content Ažuriraj izmenjeni sadržaj + Update All Modified Content Ažuriraj sav izmenjeni sadržaj + Stack Mode Režim steka + Entropy Entropija + Kurtosis Kurtozis + Maximum Maksimum + Mean Srednja vrednost + Median Medijana + Minimum Minimum + Range Opseg + Skewness Asimetrija + Standard Deviation Standardna devijacija + Summation Zbir + Variance Varijansa + Video Layers Video slojevi + Interpret Footage… Tumači snimak… + Replace Footage… Zameni snimak… + Reload Frame Ponovo učitaj kadar + Show Altered Video Prikaži izmenjeni video + Restore Frame Vrati kadar + Restore All Frames Vrati sve kadrove + Insert Blank Frame Umetni prazan kadar + Delete Frame Obriši kadar + Duplicate Frame Dupliraj kadar + Frame from Layers Okvir od slojeva + Video Frames to Layers… Video kadrovi u slojeve… + Rasterize Rasterizuj + Rasterize Type Rasterizuj tekst + Rasterize Type Layer Rasterizuj tekstualni sloj + Rasterize Shape Rasterizuj oblik + New Guide Layout… Novi raspored vodilja… + New Guides From Shape Nove vodilje od oblika + Lock Layers… Zaključaj slojeve… + Link Layers Poveži slojeve + Select Linked Layers Izaberi povezane slojeve + Merge Layers Spoji slojeve + Merge Down Spoji nadole + Merge Group Spoji grupu + Merge Visible Spoji vidljive + Stamp Visible Utisni vidljive + Stamp Down Utisni nadole + Flatten Image Spljošti sliku + Matting Čišćenje ivica + Color Decontaminate… Dekontaminacija boja… + Remove Black Matte Ukloni crni oreol + Remove White Matte Ukloni beli oreol + Defringe… Ukloni rub… + Group Layers Grupiši slojeve + Ungroup Layers Razgrupiši slojeve + Hide Layers Sakrij slojeve + Arrange Raspored + Bring to Front Premesti na vrh + Bring Forward Pomeri napred + Send Backward Pomeri nazad + Send to Back Premesti na dno + Reverse Obrni + Combine Shapes Kombinuj oblike + Unite Shapes Ujedini oblike + Subtract Front Shape Oduzmi prednji oblik + Intersect Shape Areas Presek oblasti oblika + Exclude Overlapping Shapes Isključi preklapajuće oblike + Merge Shape Components Spoji komponente oblika + Align Poravnaj + Top Edges Gornje ivice + Vertical Centers Vertikalni centri + Bottom Edges Donje ivice + Left Edges Leve ivice + Horizontal Centers Horizontalni centri + Right Edges Desne ivice + Distribute Raspodeli + Vertically Vertikalno + Horizontally Horizontalno + Layer Edges Ivice sloja + Isolate Layers Izoluj slojeve + Layer Content Options… Opcije sadržaja sloja… + Fill Content Sadržaj popune + Deselect Layers Poništi izbor slojeva + Find Layers Pronađi slojeve + Layer Comps Kompozicije slojeva + Layers Slojevi + All Layers Svi slojevi + Hidden Layers Skriveni slojevi + Mask All Objects Maskiraj sve objekte + Subject Subjekat + Sky Nebo + Panels Paneli + Character Panel Panel znakova + Paragraph Panel Panel pasusa + Character Styles Panel Panel stilova znakova + Paragraph Styles Panel Panel stilova pasusa + Glyphs Panel Panel glifova + Anti-Alias Uglađivanje ivica + Sharp Oštro + Crisp Jasno + Strong Snažno + Smooth Glatko + Windows LCD Windows LCD + Windows Windows + Orientation Orijentacija + Horizontal Horizontalno + Vertical Vertikalno + OpenType OpenType + Standard Ligatures Standardne ligature + Contextual Alternates Kontekstualne alternative + Discretionary Ligatures Opcione ligature + Swash Kićeni oblici + Oldstyle Starinske cifre + Stylistic Alternates Stilske alternative + Titling Alternates Naslovne alternative + Ornaments Ornamenti + Ordinals Redni brojevi + Fractions Razlomci + Create Work Path Napravi radnu putanju + Convert to Shape Pretvori u oblik + Warp Text… Krivljenje teksta… + Convert to Point Text Pretvori u tačkasti tekst + Convert to Paragraph Text Pretvori u tekst pasusa + Default Features Podrazumevane funkcije + East Asian Features Funkcije za istočnoazijska pisma + Middle Eastern Features Funkcije za bliskoistočna pisma + Middle Eastern & South Asian Composer Slagač za bliskoistočna i južnoazijska pisma + Update All Text Layers Ažuriraj sve tekstualne slojeve + Replace All Missing Fonts Zameni sve fontove koji nedostaju + Resolve Missing Fonts… Razreši fontove koji nedostaju… + Font Preview Size Veličina pregleda fonta + None Ništa + Small Mala + Medium Srednja + Large Velika + Extra Large Veoma velika + Huge Ogromna + Load Default Type Styles Učitaj podrazumevane stilove teksta + Save Default Type Styles Sačuvaj podrazumevane stilove teksta + Deselect Poništi selekciju + Reselect Ponovo izaberi + Inverse Inverzno + Inverse Selection Obrni selekciju + Similar Slično + Color Range… Opseg boja… + Focus Area… Oblast fokusa… + Select and Mask… Izaberi i maskiraj… +# Select and Mask dialog labels + Smart Radius Pametni poluprečnik + Shift Edge Pomeri ivicu + Decontaminate Očisti boje + Output Izlaz + Refine Edge Doteraj ivicu + Modify Izmeni + Border… Obod… + Smooth… Izgladi… + Expand… Proširi… + Contract… Suzi… + Feather… Omekšaj ivice… + Grow Uvećaj + Transform Selection Transformiši selekciju + Edit in Quick Mask Mode Uredi u režimu brze maske + Load Selection… Učitaj selekciju… + Save Selection… Sačuvaj selekciju… + Match All Uskladi sve + Last Filter Poslednji filter + Filter Gallery… Galerija filtera… + Adaptive Wide Angle… Prilagodljivi široki ugao… + Camera Raw Filter… Camera Raw filter… + Liquify… Tečno izobličavanje… + Vanishing Point… Tačka nestajanja… + Blur Zamućenje + Blur Gallery Galerija zamućenja + Field Blur… Zamućenje polja… + Iris Blur… Iris zamućenje… + Tilt-Shift… Tilt-shift… + Path Blur… Zamućenje duž putanje… + Spin Blur… Rotaciono zamućenje… + Blur More Jače zamućenje + Box Blur… Pravougaono zamućenje… + Gaussian Blur… Gausovo zamućenje… + Lens Blur… Zamućenje objektiva… + Motion Blur… Zamućenje pokreta… + Radial Blur… Radijalno zamućenje… + Shape Blur… Zamućenje oblikom… + Smart Blur… Pametno zamućenje… + Surface Blur… Površinsko zamućenje… + Average Prosek + Displace… Premeštanje… + Pinch… Skupljanje… + Polar Coordinates… Polarne koordinate… + Ripple… Talasanje… + Shear… Smicanje… + Spherize… Sferizacija… + Twirl… Uvrtanje… + Wave… Talas… + ZigZag… Cik-cak… + Noise Šum + Add Noise… Dodaj šum… + Despeckle Ukloni mrlje + Dust & Scratches… Prašina i ogrebotine… + Median… Medijana… + Reduce Noise… Smanji šum… + Pixelate Pikselizacija + Color Halftone… Polutonski raster u boji… + Crystallize… Kristalizacija… + Facet Fasete + Fragment Fragmentacija + Mezzotint… Mecotinta… + Mosaic… Mozaik… + Pointillize… Poentilizam… + Render Renderovanje + Clouds Oblaci + Difference Clouds Oblaci razlike + Fibers… Vlakna… + Flame… Plamen… + Lens Flare… Odsjaj objektiva… + Lighting Effects… Efekti osvetljenja… + Relight… Ponovno osvetljavanje… + Picture Frame… Ram za sliku… + Tree… Drvo… + Sharpen Izoštravanje + Sharpen Edges Izoštri ivice + Sharpen More Jače izoštravanje + Smart Sharpen… Pametno izoštravanje… + Unsharp Mask… Maska neoštrine… + High Pass… Visokopropusni filter… + Stylize Stilizacija + Diffuse… Rasipanje… + Emboss… Reljef… + Extrude… Istiskivanje… + Find Edges Pronađi ivice + Oil Paint… Uljana boja… + Solarize Solarizacija + Tiles… Pločice… + Trace Contour… Praćenje konture… + Wind… Vetar… + Video Video + De-Interlace… Uklanjanje preplitanja… + NTSC Colors NTSC boje + Other Ostalo + Maximum… Maksimum… + Minimum… Minimum… + Offset… Pomak… + Plug-ins Dodaci + Proof Setup Podešavanje probnog prikaza + Proof Colors Probne boje + Gamut Warning Upozorenje o gamutu + Working CMYK Radni CMYK + Working Cyan Plate Radna cijan ploča + Working Magenta Plate Radna magenta ploča + Working Yellow Plate Radna žuta ploča + Working Black Plate Radna crna ploča + Working CMY Plate Radna CMY ploča + Legacy Macintosh RGB Stari Macintosh RGB + Internet Standard RGB Internet standard RGB + Monitor RGB RGB monitora + Color Blindness — Protanopia-type Daltonizam — tip protanopije + Color Blindness — Deuteranopia-type Daltonizam — tip deuteranopije + Zoom In Uvećaj prikaz + Zoom Out Umanji prikaz + Fit on Screen Uklopi u ekran + Fit Layer(s) on Screen Uklopi sloj(eve) u ekran + Fit Artboard on Screen Uklopi radnu tablu u ekran + 100% 100% + 200% 200% + 75% 75% + 80% 80% + 85% 85% + 90% 90% + 95% 95% + 125% 125% + 150% 150% + 175% 175% + 250% 250% + 300% 300% + Print Size Veličina za štampu + Pixel Aspect Ratio Odnos stranica piksela + Pixel Aspect Ratio Correction Korekcija odnosa stranica piksela + Custom Pixel Aspect Ratio… Prilagođeni odnos stranica piksela… + Square Kvadrat + D1/DV NTSC (0.91) D1/DV NTSC (0.91) + D1/DV NTSC Widescreen (1.21) D1/DV NTSC, široki ekran (1.21) + D1/DV PAL (1.09) D1/DV PAL (1.09) + D1/DV PAL Widescreen (1.46) D1/DV PAL, široki ekran (1.46) + Anamorphic 2:1 (2) Anamorfno 2:1 (2) + HDV 1080/DVCPRO HD 720 (1.33) HDV 1080/DVCPRO HD 720 (1.33) + DVCPRO HD 1080/HDV 1080 (1.5) DVCPRO HD 1080/HDV 1080 (1.5) + 32-bit Preview Options… Opcije 32-bitnog pregleda… + Screen Mode Režim ekrana + Standard Screen Mode Standardni režim ekrana + Full Screen Mode With Menu Bar Režim celog ekrana sa trakom menija + Full Screen Mode Režim celog ekrana + Cycle Screen Mode Smenjuj režime ekrana + Show Prikaži + Extras Dodatni elementi + Selection Edges Ivice selekcije + Target Path Ciljna putanja + Current Path Tekuća putanja + Grid Mreža + Guides Vodilje + Canvas Guides Vodilje platna + Artboard Guides Vodilje radne table + Count Broj + Smart Guides Pametne vodilje + Slices Isečci + Notes Beleške + Pixel Grid Mreža piksela + Pixel Art Piksel-art + Pixel Art Preview Pregled piksel-arta + Pattern Preview Pregled šare + Brush Preview Pregled četkice + Mesh Mrežica + Edit Pins Uredi čiode + Document Bounds Granice dokumenta + Show Extras Options… Opcije prikaza dodatnih elemenata… + Rulers Lenjiri + Snap Prianjanje + Snap To Prianjaj uz + Lock Guides Zaključaj vodilje + Clear Guides Ukloni vodilje + Clear Selected Artboard Guides Ukloni vodilje izabranih radnih tabli + Clear Canvas Guides Ukloni vodilje platna + New Guide… Nova vodilja… + Lock Slices Zaključaj isečke + Clear Slices Ukloni isečke + Match Zoom Uskladi zum + Match Location Uskladi položaj + Match Rotation Uskladi rotaciju + Cascade Kaskadno + Tile Popločaj + Tile All Horizontally Popločaj sve horizontalno + Tile All Vertically Popločaj sve vertikalno + 2-up Horizontal Po 2, horizontalno + 2-up Vertical Po 2, vertikalno + 3-up Horizontal Po 3, horizontalno + 3-up Stacked Po 3, naslagano + 3-up Vertical Po 3, vertikalno + 4-up Po 4 + 6-up Po 6 + Consolidate All to Tabs Objedini sve u kartice + Float in Window Odvoji u prozor + Float All in Windows Odvoji sve u prozore + New Window for Document Novi prozor za dokument + Workspace Radni prostor + Essentials Osnovno + Graphic and Web Grafika i veb + Painting Slikanje + Photography Fotografija + New Workspace… Novi radni prostor… + Delete Workspace… Obriši radni prostor… + Reset Workspace Resetuj radni prostor + Reset Tool Resetuj alatku + Reset All Tools Resetuj sve alatke + Reset all tools to their default settings? Vratiti sve alatke na podrazumevana podešavanja? + Lock Workspace Zaključaj radni prostor + Theme Tema + Pro Theme Pro tema + Pro Medium Gray Theme Pro srednje siva tema + Pro Light Gray Theme Pro svetlosiva tema + Pro Light Theme Pro svetla tema + Studio Theme Studio tema + Studio Light Theme Studio svetla tema + Adwaita Light Theme Adwaita svetla tema + Adwaita Dark Theme Adwaita tamna tema + Classic Theme Klasična tema + Solarized dark Solarized tamna + Solarized Dark Theme Solarized tamna tema + Breeze dark Breeze tamna + Breeze Dark Theme Breeze tamna tema + Breeze light Breeze svetla + Breeze Light Theme Breeze svetla tema + Next Theme Sledeća tema + Actions Radnje + Brush Settings Podešavanja četkice + Brushes Četkice + Channels Kanali + Character Znak + Character Styles Stilovi znakova + Clone Source Izvor kloniranja + Color Boja + Glyphs Glifovi + Gradients Gradijenti + Histogram Histogram + History Istorija + Info Informacije + Measurement Log Dnevnik merenja + Modifier Keys Modifikatorski tasteri + Navigator Navigator + Options Opcije + Paragraph Pasus + Paragraph Styles Stilovi pasusa + Paths Putanje + Patterns Šare + Properties Svojstva + Shapes Oblici + Styles Stilovi + Swatches Uzorci boja + Timeline Vremenska linija + Tool Presets Šabloni alatki + Tools Alatke + About PhotoCraft O programu PhotoCraft + PhotoCraft Website Veb-sajt PhotoCraft-a + PhotoCraft on GitHub PhotoCraft na GitHub-u + ArtCraft Website Veb-sajt ArtCraft-a + Join the ArtCraft Discord… Pridružite se ArtCraft Discord zajednici… + Report an Issue… Prijavi problem… + WIA Support… WIA podrška… + Apply Primeni + HSB/HSL HSB/HSL + Invert… Invertuj… + Load Files into Stack… Učitaj datoteke u stek… + Motion Pokret + Notes… Beleške… + Other… Ostalo… + Remove Warp Split Ukloni podelu krivljenja + Shape Oblik + Split Warp Crosswise Podeli krivljenje unakrsno + Split Warp Horizontally Podeli krivljenje horizontalno + Split Warp Vertically Podeli krivljenje vertikalno + Statistics… Statistika… + Refine Strokes Doteraj poteze +@plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki + Auto Automatski + (print to the printer) (štampaj na štampaču) + + Add variable + Dodaj promenljivu + 30 frames @ 30 fps 30 kadrova pri 30 fps + 3D LUT File 3D LUT datoteka + Absolute Apsolutno + Absolute Colorimetric Apsolutno kolorimetrijski + Action Radnja + Adaptive Prilagodljivo + Adaptive Wide Angle isn't open Prilagodljivi široki ugao nije otvoren + Add Dodaj + Add Layer Mask Dodaj masku sloja + Add Stop Dodaj tačku + Add a layer style Dodaj stil sloja + Add a mask (from the selection; {key} inverts) Dodaj masku (iz selekcije; {key} invertuje) + Add an adjustment Dodaj korekciju + Add disk Dodaj disk + Add space after paragraph Dodaj razmak posle pasusa + Add space before paragraph Dodaj razmak pre pasusa + Add to Sample Dodaj u uzorak + Add to selection ({key}) Dodaj u selekciju ({key}) + Adjust for Zoom Prilagodi zumu + Adjustment visible Korekcija je vidljiva + Adobe RGB (1998) compatible Kompatibilno sa Adobe RGB (1998) + Align and Distribute Poravnanje i raspodela + Align and distribute Poravnanje i raspodela + Align horizontal centers Poravnaj horizontalne centre + Align left edges Poravnaj leve ivice + Align right edges Poravnaj desne ivice + Aligned Poravnato + All Caps Sve velika slova + All Slices Svi isečci + All User Slices Svi korisnički isečci + All shortcuts reset to PhotoCraft defaults. Sve prečice su vraćene na podrazumevane vrednosti PhotoCraft-a. + Alt Alt + Always use pressure for opacity Uvek koristi pritisak za neprozirnost + Always use pressure for size Uvek koristi pritisak za veličinu + Amount Količina + Anchor: Sidro: + Angle Ugao + Angle Gradient Ugaoni gradijent + Angle Jitter Kolebanje ugla + Answer the unsaved-changes prompt first Prvo odgovorite na upit o nesačuvanim izmenama + Anti-alias Uglađivanje ivica + Appearance Izgled + Appearance (Layer Style) Izgled (stil sloja) + Apply Layer Mask Primeni masku sloja + Apply Per Tip Primeni po vrhu + Apply next selected layer comp Primeni sledeću izabranu kompoziciju slojeva + Apply previous selected layer comp Primeni prethodnu izabranu kompoziciju slojeva + Artboard Radna tabla + Artboard Background Color Boja pozadine radne table + Assign working space Dodeli radni prostor boja + Author: Autor: + Auto Hide Automatski sakrij + Auto-Select: Automatski izbor: + Automatically level near-horizontal line segments Automatski izravnaj skoro horizontalne segmente linija + Automatically straighten and level Automatski ispravi i izravnaj + Automatically straighten near-vertical line segments Automatski ispravi skoro vertikalne segmente linija + Background Pozadina + Background Color Boja pozadine + Background Contents Sadržaj pozadine + Balance Balans + Baseline Osnovna linija + Baseline shift Pomak osnovne linije + Basic Osnovno + Before (Y) Pre (Y) + Begin recording Započni snimanje + Bend: Savijanje: + Bicubic Bikubično + Bicubic (smooth gradients) Bikubično (glatki prelazi) + Automatic Automatski + Bicubic Smoother (enlargement) Bikubično glađe (uvećanje) + Bicubic Sharper (reduction) Bikubično oštrije (smanjenje) + Bilinear Bilinearno + Black Crna + Black & White Crno-belo + Black Point Compensation Kompenzacija crne tačke + Black, White Crna, bela + Blacks Crne + Blending Stapanje + Blending Options Opcije stapanja + Blue Plava + Blue, Red, Yellow Plava, crvena, žuta + Both Axes Obe ose + Bottom Dole + Brightness Osvetljenost + Brightness Jitter Kolebanje osvetljenosti + Brightness/Contrast Osvetljenost/kontrast + Bring the selected pin forward Pomeri izabranu čiodu napred + Brush Preset picker Birač šablona četkice + Brush Reconstruct Options Opcije četkice za rekonstrukciju + Brush Tool Options Opcije alatke četkica + Cache Level: Nivo keša: + Camera Raw isn't open Camera Raw nije otvoren + Cancel Otkaži + Cancel (Esc) Otkaži (Esc) + Cancel any current edits (Esc) Otkaži sve tekuće izmene (Esc) + Cancel current crop operation (Esc) Otkaži tekuće opsecanje (Esc) + Cancel transform (Esc) Otkaži transformaciju (Esc) + Cancel warp (Esc) Otkaži krivljenje (Esc) + Canvas Platno + Canvas extension color: Boja proširenja platna: + Capture Zabeleži + Capture the visibility, position and style of every layer with + below, then switch between versions. Zabeležite vidljivost, položaj i stil svakog sloja dugmetom + ispod, a zatim se prebacujte između verzija. + Catch-up on Stroke End Sustizanje na kraju poteza + Center Centar + Center Crop Marks Središnje oznake za sečenje + Center text Centriraj tekst + Change Promeni + Change All Promeni sve + Change Screen Mode (F) Promeni režim ekrana (F) + Change To: Promeni u: + Channel Mixer Mešač kanala + Channel: Kanal: + Check All Layers Proveri sve slojeve + Check Spelling Provera pravopisa + Clarity Jasnoća + Clear All Obriši sve + Clear Override Ukloni nadjačavanje + Clear Samples Obriši uzorke + Click sets foreground · right-click sets background Klik postavlja boju prednjeg plana · desni klik postavlja boju pozadine + Click the first point or press {key} to close · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · Esc otkazuje + Click to add a point · drag off or {key}-click or Delete to remove Kliknite da dodate tačku · prevucite van, {key}-klik ili Delete da uklonite + Click to apply, {key}-click to clear overrides, double-click for Style Options Kliknite da primenite, {key}-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to edit the gradient Kliknite da uredite gradijent + Click to restore the current colour Kliknite da vratite tekuću boju + Click to zoom in · {key}-click to zoom out · drag right/left to zoom in/out Kliknite da uvećate · {key}-klik da umanjite · prevucite udesno/ulevo da uvećate/umanjite + Click with the Note tool to add a note. Kliknite alatkom za beleške da dodate belešku. + Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · {key} završi · Esc otkaži + Click: corner · Drag: smooth · {alt}-drag: break handle · Click or drag first point: close · {key} finish · Esc cancel Klik: ugao · Prevlačenje: glatko · {alt}-prevlačenje: razdvoji ručicu · Klik ili prevlačenje prve tačke: zatvori · {key} završi · Esc otkaži + Clip to layer below Iseci prema sloju ispod + Clipped Isečeno + Close Style Options Zatvori opcije stila + Cmd Cmd + Coated CMYK Premazani CMYK + Color & Swatches Boja i uzorci + Color Balance Balans boja + Color Detail Detalji boje + Color Grading Gradacija boja + Color Lookup Preslikavanje boja + Color Management Upravljanje bojama + Color Mixer Mešač boja + Color Mode Režim boja + Color Priority Prioritet boje + Color Range preview Pregled opsega boja + Color: Boja: + Colorize Oboji + Colors Boje + Commit Perspective Warp ({key}) Potvrdi perspektivno krivljenje ({key}) + Commit Puppet Warp ({key}) Potvrdi lutkarsko krivljenje ({key}) + Commit any current edits ({key}) Potvrdi sve tekuće izmene ({key}) + Commit current crop operation ({key}) Potvrdi tekuće opsecanje ({key}) + Commit transform ({key}) Potvrdi transformaciju ({key}) + Commit warp ({key}) Potvrdi krivljenje ({key}) + Constant Konstantno + Constrain proportions Zadrži proporcije + Contact Sheet II Kontakt-list II + Content-Aware Prema sadržaju + Contiguous Susedno + Contrast Kontrast + Control: Kontrola: + Convert document's colors to the working space Pretvori boje dokumenta u radni prostor + Convert to sRGB Pretvori u sRGB + Copies Primerci + Copper Bakar + Copyright Autorska prava + Copyright and Contact Info Autorska prava i kontakt podaci + Corner Crop Marks Ugaone oznake za sečenje + Corner radius Poluprečnik ugla + Correction Korekcija + Count Jitter Kolebanje broja + Create Napravi + Create Droplet Napravi droplet + Create Texture Napravi teksturu + Create Video Timeline Napravi video vremensku liniju + Create a new count group Napravi novu grupu brojanja + Create a new document or open an existing file. Napravite novi dokument ili otvorite postojeću datoteku. + Create a new group Napravi novu grupu + Create a new layer Napravi novi sloj + Create new action Napravi novu radnju + Create new channel Napravi novi kanal + Create new document from current state Napravi novi dokument od tekućeg stanja + Create new fill or adjustment layer Napravi novi sloj popune ili korekcioni sloj + Create new gradient Napravi novi gradijent + Create new group Napravi novu grupu + Create new layer comp Napravi novu kompoziciju slojeva + Create new pattern from the selection Napravi novu šaru od selekcije + Create new shape from the current path Napravi novi oblik od tekuće putanje + Create new snapshot Napravi novi snimak stanja + Create new tool preset Napravi novi šablon alatke + Crop Factor Faktor izreza + Ctrl Ctrl + Current & Below Tekući i ispod + Current Layer Tekući sloj + Current Size Trenutna veličina + Current Tool Only Samo tekuća alatka + Curve Kriva + Curves Krive + Custom Prilagođeno + Custom Shapes Prilagođeni oblici + Cyan Cijan + Cyan · Red Cijan · crvena + Darken Zatamnjenje + Darks Tamni tonovi + Data Sets Skupovi podataka + Default Podrazumevano + Default colours (D) Podrazumevane boje (D) + Default printer Podrazumevani štampač + Default restored. Podrazumevano stanje je vraćeno. + Define Definiši + Define at least one variable first. Prvo definišite bar jednu promenljivu. + Dehaze Uklanjanje izmaglice + Delete Cropped Pixels Obriši isečene piksele + Delete Group Obriši grupu + Delete Layer Mask Obriši masku sloja + Delete Layers Obriši slojeve + Delete Shortcut Obriši prečicu + Delete Timeline Obriši vremensku liniju + Delete Tool Preset Obriši šablon alatke + Delete count group Obriši grupu brojanja + Delete current channel Obriši tekući kanal + Delete current state Obriši tekuće stanje + Delete effect layer Obriši sloj efekta + +# Layers-panel trash confirmation + Delete layer ‘{name}’? Obrisati sloj „{name}“? + Delete {count} layers? {names} Obrisati izabrane slojeve ({count})? {names} + Delete layer? Obrisati sloj? + The document is closed Dokument je zatvoren + The document or selected layers changed. Please request deletion again. Dokument ili izabrani slojevi su se promenili. Ponovo zatražite brisanje. + Missing delete command Nedostaje komanda za brisanje + Invalid delete command Neispravna komanda za brisanje + Layer deleted, but confirmation preference could not be saved: {error} Sloj je obrisan, ali podešavanje potvrde nije moglo da se sačuva: {error} + + Delete layer Obriši sloj + Delete layer comp Obriši kompoziciju slojeva + Delete note Obriši belešku + Delete selected measurements Obriši izabrana merenja + Delete style Obriši stil + Delete tool preset Obriši šablon alatke + Density Gustina + Density: Gustina: + Depth Dubina + Depth Jitter Kolebanje dubine + Description Opis + Deselect All Poništi sav izbor + Detail Detalji + Diamond Gradient Romboidni gradijent + Difference Razlika + Diffusion Difuzija + Discard the embedded profile (don't color manage) Odbaci ugrađeni profil (bez upravljanja bojama) + Discontiguous Nesusedno + Discord Discord + Dismiss Odbaci + Display P3 Display P3 + Display Text Tekst za prikaz + Dither Diterovanje + Divide… Podeli… + Document Dokument + Don't Save Nemoj sačuvati + No Ne + Yes Da + Done Gotovo + Dots Tačke + Drag a rectangle around the object Prevucite pravougaonik oko objekta + Drag on the image to add a constraint; Shift for horizontal/vertical; right-click to delete. Prevucite po slici da dodate ograničenje; Shift za horizontalno/vertikalno; desni klik za brisanje. + Drag to move the active shape's path or the Work Path Prevucite da pomerite putanju aktivnog oblika ili radnu putanju + Click: select anchor · Drag: move anchor, handle or segment · {shift}-click: add · {alt}-click: whole subpath Klik: izaberi sidrišnu tačku · Prevlačenje: pomeri tačku, ručicu ili segment · {shift}-klik: dodaj · {alt}-klik: cela podputanja + Draw with the Pen tool (P) or make a work path from a selection. Crtajte alatkom pero (P) ili napravite radnu putanju od selekcije. + Drop an image or PSD anywhere to open it. Prevucite sliku ili PSD bilo gde da biste je otvorili. + Duplicate Layers… Dupliraj slojeve… + Duplicate style Dupliraj stil + Edit Toolbar… Uredi traku alatki… + Edit in Quick Mask Mode (Q) Uredi u režimu brze maske (Q) + Edit in Standard Mode (Q) Uredi u standardnom režimu (Q) + Effects Efekti + Embed Color Profile Ugradi profil boja + Embedded Profile Mismatch Neslaganje ugrađenog profila + Enable airbrush-style build-up effects Omogući postepeno nagomilavanje kao kod vazdušne četkice + Enhance Edge Poboljšaj ivicu + Entire Font Ceo font + Exact Tačno + Expansion: Proširenje: + Export Presets Izvezi šablone + Export selected measurements (CSV) Izvezi izabrana merenja (CSV) + Exposure Ekspozicija + Exposure and Gamma Ekspozicija i gama + Exposure: Ekspozicija: + Eyedropper Pipeta + Factor: Faktor: + Faux Bold Lažno podebljano + Faux Italic Lažni kurziv + Feather Omekšavanje ivica + Fewer Points Manje tačaka + File Settings Podešavanja datoteke + Fill Popuna + Fill: Popuna: + Filter for adjustment layers Filtriraj korekcione slojeve + Filter for pixel layers Filtriraj piksel slojeve + Filter for shape layers Filtriraj slojeve oblika + Filter for smart objects Filtriraj pametne objekte + Filter for type layers Filtriraj tekstualne slojeve + Filter: Filter: + Finger Painting Slikanje prstom + Fisheye Riblje oko + Fit Uklopi + Fit Screen Uklopi u ekran + Fill Screen Popuni ekran + Fit in View Uklopi u prikaz + Fixed Fiksno + Fixed Ratio Fiksni odnos + Fixed Size Fiksna veličina + Flip Okreni + Flip X Okreni po X + Flip X Jitter Kolebanje okretanja po X + Flip Y Okreni po Y + Flip Y Jitter Kolebanje okretanja po Y + Flow Protok + Flow Jitter Kolebanje protoka + Focal Length Žižna daljina + Font Font + Font Size: Veličina fonta: + Font size Veličina fonta + Foreground Prednji plan + Foreground to Background Od prednjeg plana do pozadine + Foreground/Background Jitter Kolebanje prednjeg plana/pozadine + Format Format + Frame from Layers… Okvir od slojeva… + Free Slobodno + Full Spherical Potpuno sferno + Fuzziness: Rasplinutost: + GPU GPU + Gamma Correction Korekcija game + Gamma: Gama: + Global Globalno + Glow Color Boja sjaja + Gradient Map Mapa gradijenta + Grain Zrno + Gray Siva + Gray Gamma 2.2 Siva gama 2.2 + Green Zelena + Group Grupa + Hardness Tvrdoća + Heart Srce + Height Visina + Height: Visina: + Hide Auto Slices Sakrij automatske isečke + Hide Properties Sakrij svojstva + Highlight Compression Kompresija svetlih tonova + Highlight Priority Prioritet svetlih tonova + Highlights Svetla + Home Početna + Horizontal scale Horizontalna razmera + Hue Nijansa + Hue Jitter Kolebanje nijanse + Hue/Saturation Nijansa/zasićenost + Hyphenate Deljenje reči na slogove + Ignore Zanemari + Ignore All Zanemari sve + Image Size Veličina slike + Import Brushes… Uvezi četkice… + Import brushes (.abr) Uvezi četkice (.abr) + Import Presets Uvezi šablone + Imported Uvezeno + Indent First Uvlačenje prvog reda + Indent Left Levo uvlačenje + Indent Right Desno uvlačenje + Indent first line Uvuci prvi red + Indent left margin Uvlačenje leve margine + Indent right margin Uvlačenje desne margine + Input: Ulaz: + Inside Unutra + Interlaced Isprepletano + Interpolation: Interpolacija: + Intersect with selection ({key}) Presek sa selekcijom ({key}) + Invert All Invertuj sve + Invert has no settings. Invertovanje nema podešavanja. + Italic Kurziv + Join us on Discord Pridružite nam se na Discordu + Justify all Obostrano poravnaj sve redove + Justify last centered Obostrano, poslednji red centriran + Justify last left Obostrano, poslednji red levo + Justify last right Obostrano, poslednji red desno + Kerning Kerning + Keyboard Shortcuts Prečice na tastaturi + Keyboard Shortcuts and Menus Prečice na tastaturi i meniji + Kind Vrsta + Label Size Veličina oznake + Labels Oznake + Lanczos (sharp) Lanczos (oštro) + Landscape Položeno + Last Document State Poslednje stanje dokumenta + Layout Raspored + Leading Prored + Leading (set to the font size × auto-leading when Auto) Prored (veličina fonta × automatski prored kada je Automatski) + Leave as is (don't color manage) Ostavi kako jeste (bez upravljanja bojama) + Left align text Poravnaj tekst levo + Length: Dužina: + Level Nivo + Levels Nivoi + Lighten Posvetljenje + Lightness Svetlina + Lights Svetli tonovi + Limits: Granice: + Linear Gradient Linearni gradijent + Link layers Poveži slojeve + Link width and height Poveži širinu i visinu + Liquify is not open Tečno izobličavanje nije otvoreno + Load Učitaj + Load 3D LUT… Učitaj 3D LUT… + Load channel as selection Učitaj kanal kao selekciju + Load… Učitaj… + Localized Color Clusters Lokalizovane grupe boja + Lock Zaključaj + Lock all Zaključaj sve + Lock image pixels Zaključaj piksele slike + Lock layer Zaključaj sloj + Lock position Zaključaj položaj + Lock transparent pixels Zaključaj providne piksele + Lock: Zaključaj: + Logical Length: Logička dužina: + Logical Units: Logičke jedinice: + Luminance Luminansa + Luminance Detail Detalji luminanse + Magenta Magenta + Magenta · Green Magenta · zelena + Maintain aspect ratio Zadrži odnos stranica + Marker Size Veličina markera + Mask All Maskiraj sve + Mask Options Opcije maske + Masking Maskiranje + Master Glavno + Matte Podloga + Median: Medijana: + Menus Meniji + Mesh Size Veličina mrežice + Metadata Metapodaci + Metrics Metrika + Midpoint Središnja tačka + Midtones Srednji tonovi + Midtones from: Srednji tonovi od: + Midtones to: Srednji tonovi do: + Minimum Depth Minimalna dubina + Minimum Diameter Minimalni prečnik + Minimum Roundness Minimalna zaobljenost + Mode: Režim: + Monochrome Jednobojno + More Points Više tačaka + Move effect layer down Pomeri sloj efekta nadole + Move effect layer up Pomeri sloj efekta nagore + Move to Premesti u + Name Ime + Name: Ime: + Nearest Neighbor Najbliži sused + Nearest Neighbor (hard edges) Najbliži sused (oštre ivice) + New Size Nova veličina + New document… Novi dokument… + New effect layer Novi sloj efekta + New selection Nova selekcija + Next frame Sledeći kadar + Next note Sledeća beleška + No Color Bez boje + No Color Management Bez upravljanja bojama + No Dither Bez diterovanja + No author Nema autora + No character style Bez stila znakova + No document Nema dokumenta + No matching commands Nema odgovarajućih komandi + No presets for the current tool. Nema šablona za tekuću alatku. + No presets of this type. Nema šablona ove vrste. + No properties Nema svojstava + No tool presets. Nema šablona alatki. + Noise Reduction Smanjenje šuma + Normal Normalno + Not in Dictionary: Nije u rečniku: + OK U redu + Offset Pomak + Only Web Colors Samo veb boje + Opacity Neprozirnost + Opacity Jitter Kolebanje neprozirnosti + Opacity: Neprozirnost: + Open a document to define variables. Otvorite dokument da biste definisali promenljive. + Open a document to select a colour range. Otvorite dokument da biste izabrali opseg boja. + Open a document to use the timeline. Otvorite dokument da biste koristili vremensku liniju. + Optical Optički + Optimized Optimizovano + Option-click to define a source point to clone from Option-klik da odredite izvornu tačku za kloniranje + Original Original + Output Channel: Izlazni kanal: + Output Levels: Izlazni nivoi: + Output: Izlaz: + Outside Spolja + Overlay Color Boja preklopa + Overlay Opacity Neprozirnost preklopa + Overlay: Rule of Thirds Preklop: pravilo trećina + Override Pressure Nadjačaj pritisak + Override Rotation Nadjačaj rotaciju + Override Tilt Nadjačaj nagib + PRESET DETAILS DETALJI ŠABLONA + PSD PSD + Paint Overlay Preklop za slikanje + Panel locations are saved in the workspace. Keyboard shortcuts, menus and toolbar are optional. Položaji panela se čuvaju u radnom prostoru. Prečice na tastaturi, meniji i traka alatki su opcioni. + Paper Papir + Path Putanja + Pattern Šara + Pattern Size Veličina šare + Pause Pauza + Percent Procenat + Perceptual Perceptivno + Perspective Warp is not active Perspektivno krivljenje nije aktivno + Perspective Warp: draw planes along the image's perspective, then switch to Warp Perspektivno krivljenje: nacrtajte ravni duž perspektive slike, a zatim pređite na krivljenje + Photo Filter Foto-filter + PhotoCraft PhotoCraft + PhotoCraft Manages Colors PhotoCraft upravlja bojama + PhotoCraft — an open-source, native image editor written in Rust. PhotoCraft — izvorni uređivač slika otvorenog koda, napisan u Rust-u. + Pin Depth: Dubina čiode: + Pixel Length: Dužina u pikselima: + Pixels/Centimeter Piksela/centimetar + Pixels/Inch Piksela/inč + Pixels: Pikseli: + Play Pusti + Play selection Pusti izabrano + Portrait Uspravno + Position Položaj + Position and Size Položaj i veličina + Posterize Posterizacija + Preserve Details Očuvaj detalje + Preserve Luminosity Očuvaj osvetljenje + Preset Šablon + Preset Type Vrsta šablona + Preset: Šablon: + Presets: Šabloni: + Press keys… Pritisnite tastere… + Pressure Pritisak + Pressure for Opacity Pritisak za neprozirnost + Pressure for Size Pritisak za veličinu + Prevent auto-nesting in and out of Artboards and Frames Spreči automatsko ugnežđavanje u radne table i okvire i izvlačenje iz njih + Preview Pregled + Previous frame Prethodni kadar + Previous note Prethodna beleška + Print Štampaj + Printer Štampač + Printer Manages Colors Štampač upravlja bojama + Printer Setup Podešavanje štampača + Printing Marks Oznake za štampu + Progressive Progresivno + Promote Unapredi + Protect Detail Zaštiti detalje + Protect Foreground Color Zaštiti boju prednjeg plana + Protect Tones Zaštiti tonove + Proximity Match Podudaranje po blizini + Pulled String Mode Režim zategnute niti + Puppet Warp is not active Lutkarsko krivljenje nije aktivno + Purity Čistoća + Quality Kvalitet + Quick Actions Brze radnje + Quick Export As PNG Brzi izvoz u PNG + Radial Gradient Radijalni gradijent + Radius Poluprečnik + Radius: Poluprečnik: + Range: Opseg: + Rate Brzina + Recently Used Nedavno korišćeno + Reconstruct Rekonstruiši + Record ● a sequence of edits, then play ▶ it on any document. Snimite ● niz izmena, a zatim ga pustite ▶ na bilo kom dokumentu. + Red Crvena + Redefine style by current selection Redefiniši stil prema tekućoj selekciji + Refine Doteraj + Reflected Gradient Reflektovani gradijent + Registration Marks Oznake registracije + Regular Obično + Relative Relativno + Relative Colorimetric Relativno kolorimetrijski + Remove Ukloni + Remove All Pins Ukloni sve čiode + Remove Background Ukloni pozadinu + Remove Planes Ukloni ravni + Remove Tool Alatka za uklanjanje + Remove stop Ukloni tačku + Remove variable Ukloni promenljivu + Rename Preimenuj + Rename Group… Preimenuj grupu… + Rename Layer… Preimenuj sloj… + Rename Tool Preset… Preimenuj šablon alatke… + Rename… Preimenuj… + Resample Ponovo uzorkuj + Reset All to Defaults Vrati sve na podrazumevano + Import Shortcuts… Uvezi prečice… + Load a keyboard shortcut set saved as .kys Učitaj skup prečica sačuvan kao .kys + Imported {n} shortcuts from {file}. Click OK to keep them. Uvezeno {n} prečica iz {file}. Kliknite U redu da ih zadržite. + {n} commands are not in PhotoCraft. {n} komandi ne postoji u PhotoCraft-u. + Keyboard shortcuts imported Prečice na tastaturi su uvezene + Your keyboard shortcut set {set} is in use here: {n} shortcuts differ from the defaults. Edit › Keyboard Shortcuts shows them; Reset All to Defaults undoes this. Ovde se koristi vaš skup prečica {set}: {n} prečica se razlikuje od podrazumevanih. Uređivanje › Prečice na tastaturi ih prikazuje; Vrati sve na podrazumevano poništava ovo. + Import keyboard shortcuts? Uvesti prečice na tastaturi? + PhotoCraft found your keyboard shortcut set {set} from another image editor on this computer: {n} of its shortcuts differ from PhotoCraft's. Import it? PhotoCraft je na ovom računaru pronašao vaš skup prečica {set} iz drugog uređivača slika: {n} prečica se razlikuje od PhotoCraft-ovih. Uvesti ga? + Edit › Keyboard Shortcuts › Reset All to Defaults undoes an import. Uređivanje › Prečice na tastaturi › Vrati sve na podrazumevano poništava uvoz. + Don't Import Nemoj uvesti + The keyboard shortcut set was not imported: {error} Skup prečica nije uvezen: {error} + Reset Section Resetuj odeljak + Reset Transform Resetuj transformaciju + Reset to defaults Vrati na podrazumevano + Resolution Rezolucija + Resolution: Rezolucija: + Restore All Vrati sve + Restore Defaults Vrati podrazumevano + Restrictive (Web) Restriktivno (veb) + Right align text Poravnaj tekst desno + Rigid Kruto + Rotate: Rotacija: + Rotation Rotacija + Roughness Hrapavost + Roundness Zaobljenost + Roundness Jitter Kolebanje zaobljenosti + Rulers & Grids Lenjiri i mreže + Sample All Layers Uzorkuj sve slojeve + Sample: Uzorak: + Sampling: Background Swatch Uzorkovanje: uzorak pozadine + Sampling: Continuous Uzorkovanje: neprekidno + Sampling: Once Uzorkovanje: jednom + Saturate Zasiti + Saturation Zasićenost + Saturation Jitter Kolebanje zasićenosti + Save HTML and Images Sačuvaj HTML i slike + Save as PDF Sačuvaj kao PDF + Save selection as channel Sačuvaj selekciju kao kanal + Save… Sačuvaj… + Scale to Fit Media Skaliraj prema medijumu + Scatter Rasejavanje + Scratch disks Radni diskovi + Search Pretraga + Search commands Pretraži komande + Search commands, tools and panels… Pretraži komande, alatke i panele… + Search fonts Pretraži fontove + Select All Izaberi sve + Select Subject Izaberi subjekat + Select a type layer or click into text to insert glyphs Izaberite tekstualni sloj ili kliknite u tekst da umetnete glifove + Select text to apply a character style Izaberite tekst da primenite stil znakova + Select: Izaberi: + Selections Selekcije + Selective Selektivno + Selective Color Selektivna boja + Send the selected pin backward Pomeri izabranu čiodu nazad + Set additional smoothing options Podesi dodatne opcije izglađivanja + Set background color Postavi boju pozadine + Set foreground color Postavi boju prednjeg plana + Set painting symmetry options Podesi opcije simetrije slikanja + Symmetry Off Simetrija isključena + Set shape fill type Podesi vrstu popune oblika + Set shape stroke type Podesi vrstu obruba oblika + Set the text color Postavi boju teksta + Shadows Senke + Shape: Oblik: + Sharpening Izoštravanje + Shift Shift + Shortcut removed. Prečica je uklonjena. + Show All Menu Items Prikaži sve stavke menija + Show Backdrop Prikaži podlogu + Show Guides Prikaži vodilje + Show Mask Prikaži masku + Show Mesh Prikaži mrežicu + Show Overlay Prikaži preklop + Show Transform Controls Prikaži kontrole transformacije + Show or hide the Notes panel Prikaži ili sakrij panel Beleške + Show: Prikaži: + Sides Stranice + Sides: Stranice: + Size Veličina + Size Jitter Kolebanje veličine + Size: Veličina: + Slice Options… Opcije isečka… + Slices From Guides Isečci od vodilja + Slices are locked (View › Lock Slices) Isečci su zaključani (Prikaz › Zaključaj isečke) + Small Caps Kapitalke + Smoothing Izglađivanje + Space After Razmak posle + Space Before Razmak pre + Spacing Razmak + Spell check complete. Provera pravopisa je završena. + Split: Podela: + Star ratio Odnos zvezde + Stop playing/recording Zaustavi puštanje/snimanje + Straighten Ispravi + Straighten Layer Ispravi sloj + Strength: Jačina: + Strikethrough Precrtano + Stroke Obrub + Stroke Catch-up Sustizanje poteza + Stroke: Obrub: + Location: Položaj: + Style Stil + Style Options Opcije stila + Style Options… Opcije stila… + Style: Normal Stil: normalno + Subtract from Sample Oduzmi od uzorka + Subtract from selection ({key}) Oduzmi od selekcije ({key}) + Suggestions: Predlozi: + Swap colours (X) Zameni boje (X) + Swaps height and width Zamenjuje visinu i širinu + Switch between free transform and warp modes Prebaci između režima slobodne transformacije i krivljenja + Switch theme Promeni temu + Temperature Temperatura + Tetrahedral Tetraedarski + Text Position: Položaj teksta: + Texture Tekstura + Texture Each Tip Tekstura za svaki vrh + Threshold Prag + Threshold Level Nivo praga + Thumbnail size (smallest: list) Veličina sličica (najmanja: spisak) + Tilt X Nagib X + Tilt Y Nagib Y + Tint Preliv + Toggle count group visibility Uključi/isključi vidljivost grupe brojanja + Toggle text orientation Promeni orijentaciju teksta + Tolerance Tolerancija + Tolerance: Tolerancija: + Tone: Ton: + Toolbar Traka alatki + Top Gore + Total: Ukupno: + Tracking Razmak između slova + Tracking (1/1000 em) Razmak između slova (1/1000 em) + Transparency Providnost + Transparent Providno + Trilinear Trilinearno + Type Styles Stilovi teksta + Type: Vrsta: + Underline Podvučeno + Untitled Bez naslova + Untitled-1 Bez naslova-1 + Update layer comp (visibility, position and appearance) Ažuriraj kompoziciju slojeva (vidljivost, položaj i izgled) + Use Default Koristi podrazumevano + Use Legacy Koristi staro + Use Measurement Scale Koristi razmeru merenja + Use the embedded profile (instead of the working space) Koristi ugrađeni profil (umesto radnog prostora) + Variables bind a layer's visibility, text or pixels to a named data slot. Promenljive vezuju vidljivost, tekst ili piksele sloja za imenovano polje podataka. + Vertical scale Vertikalna razmera + Vibrance Živost + View Options Opcije prikaza + Vignetting Vinjetiranje + Violet, Orange Ljubičasta, narandžasta + Visibility Vidljivost + Visible Vidljivo + Warp: Krivljenje: + Web Snap Prianjanje uz veb boje + Weight Debljina + Weight: Debljina: + White Bela + White Balance: As Shot Balans bele: kao pri snimanju + Whites Bele + Width Širina + Slant Iskošenost + Width: Širina: + Work Path Radna putanja + Yellow Žuta + Yellow · Blue Žuta · plava + Zoom Zum + Zoom in Uvećaj + Zoom out Umanji + command or shortcut komanda ili prečica + current tekuće + image file path putanja datoteke slike + modified izmenjeno + name ime + new novo + {name} options Opcije: {name} + ↑↓ navigate {key} run esc close ↑↓ kretanje {key} pokreni esc zatvori + Version {version} Verzija {version} + New Document Novi dokument + New Document With Mask Novi dokument sa maskom + New Layer With Mask Novi sloj sa maskom + New Layer Novi sloj + Preset Name Ime šablona + No saved presets yet. Još nema sačuvanih šablona. + Error Greška + Command Komanda + Adjustment layers have nothing to transform Korekcioni slojevi nemaju ništa što bi se transformisalo + Altitude Visina + Android 1080p Android 1080p + Apple Watch 45mm Apple Watch 45 mm + Aqua Akva + Art & Illustration Umetnost i ilustracija + ArtCraft ArtCraft + Background Eraser Tool Alatka gumica za pozadinu + Bevel & Emboss Kosina i reljef + Blend Mode Režim stapanja + Blues Plave + Blur Tool Alatka za zamućivanje + Bold Podebljano + Brush Četkica + Block Blok + Brush Pose Položaj četkice + Brush Tip Shape Oblik vrha četkice + Brush Tool Alatka četkica + Build-up Nagomilavanje + Burn Tool Alatka za zatamnjivanje + Centimeters Centimetri + Choke Sužavanje + Classic Klasično + Click to add text Kliknite da dodate tekst + Click to fill similar colours Kliknite da popunite slične boje + Clone Stamp Tool Alatka pečat za kloniranje + Color Burn Zatamnjivanje boje + Color Dodge Posvetljivanje boje + Color Dynamics Dinamika boje + Color Overlay Prekrivanje bojom + Comic Book Strip + Conform Usaglašeno + Content-Aware Fill needs a selection on a pixel layer Popuna prema sadržaju zahteva selekciju na piksel sloju + Could not transform: the layer is empty Transformacija nije moguća: sloj je prazan + Could not use Perspective Warp: the layer is empty Perspektivno krivljenje nije moguće: sloj je prazan + Could not use Puppet Warp: no opaque pixels Lutkarsko krivljenje nije moguće: nema neprozirnih piksela + Could not use Puppet Warp: the layer is empty Lutkarsko krivljenje nije moguće: sloj je prazan + Crop Tool Alatka za opsecanje + Crosswise Unakrsno + Current Tool Tekuća alatka + Custom Shape Tool Alatka za prilagođeni oblik + Cyans Cijan + Default PhotoCraft Size Podrazumevana veličina PhotoCraft-a + Delete Workspace Obriši radni prostor + Diamond Romb + Dimensions: Dimenzije: + Direction Smer + Distance Udaljenost + Document Dimensions Dimenzije dokumenta + Document Profile Profil dokumenta + Document Sizes Veličine dokumenta + Dodge Tool Alatka za posvetljivanje + Down Nadole + Drag to draw a gradient Prevucite da nacrtate gradijent + Drag to move the active layer Prevucite da pomerite aktivni sloj + Drag to pan · hold Space with any tool Prevucite da pomerate prikaz · držite razmaknicu uz bilo koju alatku + Drop Shadow Bačena senka + Dual Brush Dvostruka četkica + Edge Ivica + Efficiency Efikasnost + Efficiency: 100% Efikasnost: 100% + Ellipse Tool Alatka elipsa + Elliptical Marquee Tool Alatka za eliptičnu selekciju + Single Row Marquee Tool Alatka za selekciju jednog reda + Single Column Marquee Tool Alatka za selekciju jedne kolone + Emboss Reljef + Eraser Gumica + Eraser Tool Alatka gumica + ExtraBold Ekstra podebljano + ExtraLight Ekstra lagano + Eyedropper Tool Alatka pipeta + Fade Iščezavanje + Fill Opacity Neprozirnost popune + Film & Video Film i video + From Od + General Opšte + GitHub GitHub + Gradient Overlay Prekrivanje gradijentom + Gradient Tool Alatka gradijent + Greens Zelene + Hand Tool Alatka ruka + Hard Tvrdo + Hard Mix Oštro mešanje + Healing Brush Tool Alatka četkica za popravku + Highlights from: Svetla od: + History Brush Tool Alatka četkica istorije + Horizontal Type Tool Alatka za horizontalni tekst + Horizontal Type Mask Tool Alatka za horizontalnu masku teksta + Vertical Type Mask Tool Alatka za vertikalnu masku teksta + Image Size: Veličina slike: + Inches Inči + Indexed Indeksirano + Initial Direction Početni smer + Inner Bevel Unutrašnja kosina + Inner Glow Unutrašnji sjaj + Inner Shadow Unutrašnja senka + Lab Lab + Landscape, 10 x 8 Položeno, 10 x 8 + Landscape, 6 x 4 Položeno, 6 x 4 + Landscape, 7 x 5 Položeno, 7 x 5 + Lasso Laso + Lasso Tool Alatka laso + Layer Count Broj slojeva + Layer Knocks Out Drop Shadow Sloj izbija bačenu senku + Legal Legal + Letter Letter + Light Svetlo + Line Tool Alatka linija + Linear Linearno + Linear Burn Linearno zatamnjivanje + Linear Height Linearna visina + Link with Layer Poveži sa slojem + Lorem Ipsum Lorem Ipsum + Magentas Magenta + Magic Eraser Tool Alatka čarobna gumica + Magic Wand Tool Alatka čarobni štapić + Marquee Okvirna selekcija + Mean: Srednja vrednost: + Measurement Scale Razmera merenja + Measurement Scale Marker Oznaka razmere merenja + Millimeters Milimetri + Mobile Mobilni + Move Tool Alatka za pomeranje + Could not use the move tool because the layer is locked. Alatka za pomeranje ne može da se koristi jer je sloj zaključan. + Could not complete the Free Transform command because the layer is locked. Komanda Slobodna transformacija ne može da se izvrši jer je sloj zaključan. + Multiply Množenje + My Brushes Moje četkice + Neutrals Neutralne + New Workspace Novi radni prostor + Note Tool Alatka za beleške + Object Selection Tool Alatka za selekciju objekta + Off Isključeno + Open Otvori + Orange Narandžasta + Original Ratio Originalni odnos + Out Of Gamut Van gamuta + Outer Bevel Spoljašnja kosina + Outer Glow Spoljašnji sjaj + Overlay Preklapanje + Paint Bucket Tool Alatka kofica s bojom + Paints from the document's opening state Slika iz stanja dokumenta pri otvaranju + Path Selection Tool Alatka za selekciju putanje + Direct Selection Tool Alatka za direktnu selekciju + Add Anchor Point Tool Alatka za dodavanje sidrišne tačke + Delete Anchor Point Tool Alatka za brisanje sidrišne tačke + Click a segment to add an anchor point · {alt}-click an anchor to delete it Kliknite segment da dodate sidrišnu tačku · {alt}-klik na tačku da je obrišete + Click an anchor point to delete it · {alt}-click a segment to add one Kliknite sidrišnu tačku da je obrišete · {alt}-klik na segment da dodate novu + Pattern Overlay Prekrivanje šarom + Pen Pressure Pritisak olovke + Pen Tilt Nagib olovke + Pen Tool Alatka pero + Convert Point Tool Alatka za pretvaranje tačke + Photo Fotografija + PhotoCraft website Veb-sajt PhotoCraft-a + Picas Pike + Pillow Emboss Jastučasti reljef + Pixel Replacement Zamena piksela + Pixels Pikseli + Points Tačke + Polygon Tool Alatka mnogougao + Polygonal Lasso Tool Alatka mnogougaoni laso + Portrait, 4 x 6 Uspravno, 4 x 6 + Portrait, 5 x 7 Uspravno, 5 x 7 + PostScript (72 points/inch) PostScript (72 tačke/inč) + Postcard Razglednica + Poster Poster + Pro (Dark) Pro (tamna) + Pro (Light) Pro (svetla) + Pro (Light Gray) Pro (svetlosiva) + Pro (Medium Gray) Pro (srednje siva) + Protect Texture Zaštiti teksturu + Purple Ljubičasta + Quick Mask Brza maska + Quick Selection Tool Alatka za brzu selekciju + Radial Radijalno + Rasterize Layer Rasterizuj sloj + Rasterize Layers Rasterizuj slojeve + Ratio Odnos + Recent Nedavno + Rectangle Tool Alatka pravougaonik + Rectangular Marquee Tool Alatka za pravougaonu selekciju + Reds Crvene + Reflected Reflektovano + Sample: foreground colour Uzorak: boja prednjeg plana + Sampled Colors Uzorkovane boje + Satin Saten + Save Pixel Aspect Ratio Sačuvaj odnos stranica piksela + Scattering Rasejavanje + Scratch Sizes Veličine radnog diska + Select Data Points Izaberi tačke podataka + Selection Selekcija + SemiBold Polupodebljano + Shadows up to: Senke do: + Shape Dynamics Dinamika oblika + Sharpen Tool Alatka za izoštravanje + Show Extras Options Opcije prikaza dodatnih elemenata + Slice Select Tool Alatka za izbor isečka + Slice Tool Alatka za isecanje na isečke + Smart Filters Pametni filteri + Smudge Tool Alatka za razmazivanje + Soft Meko + Soften Omekšaj + Source Izvor + Source: set Izvor: postavljen + Sponge Tool Alatka sunđer + Spot Healing Brush Tool Alatka četkica za tačkastu popravku + Spread Raširenost + Square, 12 x 12 Kvadrat, 12 x 12 + Square, 5 x 5 Kvadrat, 5 x 5 + Std Dev: Std. devijacija: + Studio (Dark) Studio (tamna) + Studio (Light) Studio (svetla) + Stylus Wheel Točkić olovke + Subtract Oduzimanje + Tabloid Tabloid + Text Replacement Zamena teksta + Thin Tanko + Traditional (72.27 points/inch) Tradicionalno (72,27 tačaka/inč) + Transfer Prenos + Triangle Tool Alatka trougao + Unsaved changes Nesačuvane izmene + Uses the foreground and background colours. Koristi boje prednjeg plana i pozadine. + Web Veb + Web Large Veb, veliko + Web Minimum Veb, minimum + Web Most Common Veb, najčešće + WebP (lossless) WebP (bez gubitaka) + Wet Edges Vlažne ivice + Yellows Žute + Zoom Tool Alatka lupa + {name} Properties Svojstva: {name} + Revert “{name}” to the last saved version? Your changes will be lost. Vratiti „{name}“ na poslednju sačuvanu verziju? Vaše izmene će biti izgubljene. + Do you want to save the changes you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Do you want to save the changes you made to “{name}” before closing? Želite li da sačuvate izmene u „{name}“ pre zatvaranja? + Doc: {flat}/{layered} Dok.: {flat}/{layered} + Scratch: {size} Radni disk: {size} + Untagged {mode} {mode} bez oznake profila + {w} px x {h} px ({ppi} ppi) {w} px x {h} px ({ppi} ppi) + {w} {unit} x {h} {unit} ({ppi} ppi) {w} {unit} x {h} {unit} ({ppi} ppi) + Resolution: {dpi} pixels/inch Rezolucija: {dpi} piksela/inč + Doc: {w} × {h} px Dok.: {w} × {h} px + Frame {current} / {total} Kadar {current} / {total} + Count: {n} Broj: {n} + BLANK DOCUMENT PRESETS ({n}) ŠABLONI PRAZNIH DOKUMENATA ({n}) + Constraints ({n}) Ograničenja ({n}) + Align {what} Poravnaj: {what} +@plural {n} Layer|{n} Layers {n} sloj|{n} sloja|{n} slojeva +@plural {n} layer|{n} layers {n} sloj|{n} sloja|{n} slojeva +@plural Group · {n} layer|Group · {n} layers Grupa · {n} sloj|Grupa · {n} sloja|Grupa · {n} slojeva + Additional plugins folder Dodatna fascikla za dodatke + Adobe Adobe + Adobe rgb Adobe RGB + Allow agent control Dozvoli upravljanje agentima + Allow scripts to connect Dozvoli skriptama da se povežu + Always Uvek + Always create smart objects when placing Uvek pravi pametne objekte pri postavljanju + Animated zoom Animirano zumiranje + Apply auto tone Primeni automatski ton + Ask Pitaj + Ask before saving layered tiff Pitaj pre čuvanja TIFF-a sa slojevima + Auto collapse icon panels Automatski skupi panele sa ikonama + Auto show hidden panels Automatski prikaži skrivene panele + Auto show home screen Automatski prikaži početni ekran + Autosave Automatsko čuvanje + Autosave minutes Automatsko čuvanje (minuti) + Beep when done Zvučni signal po završetku + Bicubic automatic Bikubično automatski + Bicubic sharper Bikubično oštrije + Bicubic smoother Bikubično glađe + Bit depth Dubina bita + Brush preview color Boja pregleda četkice + Cache levels Nivoi keša + Cache tile size Veličina pločice keša + Camera Raw Defaults Podrazumevana Camera Raw podešavanja + Canvas border Ivica platna + Show canvas dots Prikaži tačke na platnu + Canvas color Boja platna + Canvas custom color Prilagođena boja platna + Color picker Birač boja + Color space Prostor boja + Column width Širina kolone + Concise Sažeto + Control port Kontrolni port + Convert to srgb Pretvori u sRGB + Cursors Kursori + Custom dark Prilagođena tamna + Custom light Prilagođena svetla + Dark Tamna + Dark gray Tamnosiva + Dashed lines Isprekidane linije + Destination Odredište + Detailed Detaljno + Disks Diskovi + Display p3 Display P3 + Double click layer mask launches select and mask Dvoklik na masku sloja otvara Izaberi i maskiraj + Drop shadow Bačena senka + Dynamic color sliders Dinamički klizači boja + East asian Istočnoazijsko + Effect cache mb Keš efekata (MB) + Enable flick panning Omogući pomeranje prikaza zamahom + Enable floating document window docking Omogući usidravanje plutajućih prozora dokumenata + Enable narrow options bar Omogući usku traku opcija + Enabled Omogućeno + Enhanced Controls Napredne kontrole + Export clipboard Izvezi ostavu + Extra large Veoma velika + File Handling Rukovanje datotekama + File path Putanja datoteke + Fill new type layers with placeholder Popuni nove tekstualne slojeve probnim tekstom + Font preview Pregled fonta + Full size tip Vrh pune veličine + Gamut warning color Boja upozorenja o gamutu + Gamut warning opacity Neprozirnost upozorenja o gamutu + Generator enabled Generator je omogućen + Gif GIF + Glossy Sjajno + Grid color Boja mreže + Grid colors Boje mreže + Grid size Veličina mreže + Grid style Stil mreže + Grid unit Jedinica mreže + Gridline every Linija mreže na svakih + Guide color Boja vodilja + Guide style Stil vodilja + Guides, Grid & Slices Vodilje, mreža i isečci + Gutter Žleb + History Log Dnevnik istorije + History states Stanja istorije + Ignore exif profile tag Zanemari EXIF oznaku profila + Image interpolation Interpolacija slike + Image previews Pregledi slika + Integrations Integracije + Interface Interfejs + Jpeg quality JPEG kvalitet + Jpg JPG + Language Jezik + Large tabs Velike kartice + Legacy compositing Staro komponovanje + Light gray Svetlosiva + Line Linija + Lines Linije + Lossless Bez gubitaka + Lowercase extension Ekstenzija malim slovima + Maximize psd compatibility Maksimalna kompatibilnost PSD-a + Medium gray Srednje siva + Memory usage mb Korišćenje memorije (MB) + Low resolution previews Pregledi niske rezolucije + Missing glyph protection Zaštita od glifova koji nedostaju + Modern Moderno + Nearest neighbor Najbliži sused + Never Nikad + Normal tip Normalan vrh + Open as smart object Otvori kao pametni objekat + Open in camera raw Otvori u Camera Raw-u + Open documents as tabs Otvaraj dokumente kao kartice + Overscroll Pomeranje preko ivice + Performance Performanse + Png PNG + Point size Veličina tačke + Precise Precizno + Preserve details Očuvaj detalje + Print resolution Rezolucija štampe + Pro Pro + Pro light Pro svetla + Pro light gray Pro svetlosiva + Pro medium Pro srednja + Pro photo ProPhoto + Quick export format Format brzog izvoza + Quick export location Lokacija brzog izvoza + Recent file count Broj nedavnih datoteka + Recent files Nedavne datoteke + Recent fonts Nedavni fontovi + Recover on launch Oporavi pri pokretanju + Remember workspace changes Zapamti izmene radnog prostora + Resize image during place Promeni veličinu slike pri postavljanju + Rotate view with trackpad Rotiraj prikaz pomoću dodirne table + Same folder Ista fascikla + Save in background Čuvaj u pozadini + Scratch Disks Radni diskovi + Screen Ekran + Screen resolution Rezolucija ekrana + Scrubby slider acceleration Ubrzanje klizača prevlačenjem + Sessions only Samo sesije + Sharpen for Izoštri za + Show channels in color Prikaži kanale u boji + Show crosshair in brush tip Prikaži krstić u vrhu četkice + Show extension panels Prikaži panele proširenja + Show font names in english Prikaži imena fontova na engleskom + Show menu colors Prikaži boje menija + Show only crosshair while painting Prikaži samo krstić tokom slikanja + Show slice numbers Prikaži brojeve isečaka + Show tooltips Prikaži opise alatki + Enable animations Omogući animacije + Show highlight on rollover Istakni pri prelasku pokazivačem + Show transformation values Prikaži vrednosti transformacije + Slice color Boja isečka + Smart guide color Boja pametnih vodilja + Smart quotes Pametni navodnici + Snap vector tools and transforms to pixel grid Prianjaj vektorske alatke i transformacije uz mrežu piksela + Srgb sRGB + Standard Standardno + Studio Studio + Studio light Studio svetla + Adwaita Adwaita + Adwaita dark Adwaita tamna + Subdivisions Pododeljci + System Sistem + Text engine Mašina za tekst + Text file Tekstualna datoteka + Tiny Sićušno + Touch gestures Pokreti na dodirnom ekranu + Transparency & Gamut Providnost i gamut + Type units Jedinice teksta + UI font size Veličina fonta interfejsa + UI scale Razmera interfejsa + Units & Rulers Jedinice i lenjiri + Use additional plugins folder Koristi dodatnu fasciklu za dodatke + Use esc to commit Koristi Esc za potvrdu + Use gpu Koristi GPU + Use legacy free transform Koristi staru slobodnu transformaciju + Use shift key for tool switch Koristi Shift za promenu alatke + Vary round brush hardness on hud Menjaj tvrdoću okrugle četkice na HUD-u + Webp WebP + World ready Za sva pisma sveta + Zoom clicked point to center Zumiraj tako da kliknuta tačka bude u centru + Zoom resizes windows Zumiranje menja veličinu prozora + Zoom with scroll wheel Zumiraj točkićem miša + Zoom with trackpad pinch Zumiraj štipanjem na dodirnoj tabli + Pass Through Propuštanje + Dissolve Rastapanje + Darker Color Tamnija boja + Linear Dodge (Add) Linearno posvetljivanje (sabiranje) + Lighter Color Svetlija boja + Soft Light Meko svetlo + Hard Light Oštro svetlo + Vivid Light Živo svetlo + Linear Light Linearno svetlo + Pin Light Tačkasto svetlo + Exclusion Isključenje + Divide Deljenje + Luminosity Svetlina tona +@plural {n} sample|{n} samples {n} uzorak|{n} uzorka|{n} uzoraka + {added}, {removed} subtracted {added}, oduzeto: {removed} + Adjustment Preview Pregled korekcije + Airbrush: keeps depositing paint while the pointer rests. Vazdušna četkica: nastavlja da nanosi boju dok pokazivač miruje. + Angle: Ugao: + Blend If Stapaj ako + Close Tab Group Zatvori grupu kartica + Collapse Skupi + Collapse All Groups Skupi sve grupe + Collapse Panel Group Skupi grupu panela + Collapse group ({key}-click: all groups) Skupi grupu ({key}-klik: sve grupe) + Create new brush from the current settings Napravi novu četkicu od tekućih podešavanja + Create new brush from these settings Napravi novu četkicu od ovih podešavanja + Update the current brush with these settings Ažuriraj tekuću četkicu ovim podešavanjima + Delete brush Obriši četkicu + Drag the arrow to set the angle, the dots to set the roundness Prevucite strelicu da podesite ugao, a tačke da podesite zaobljenost + Expand Proširi + Expand Panel Group Proširi grupu panela + Expand group ({key}-click: all groups) Proširi grupu ({key}-klik: sve grupe) + Fade steps Koraci iščezavanja + Hide the layer's effects ({key}-click: all layers) Sakrij efekte sloja ({key}-klik: svi slojevi) + Import Brushes… (.abr) Uvezi četkice… (.abr) + Move Group Down Pomeri grupu nadole + Move Group Up Pomeri grupu nagore + Reset the brush to the defaults Vrati četkicu na podrazumevano + Restore original size Vrati originalnu veličinu + Roundness: Zaobljenost: + Search Brushes Pretraži četkice + Show the layer's effects ({key}-click: all layers) Prikaži efekte sloja ({key}-klik: svi slojevi) + Brush picker Birač četkica + Erase Briši + Right click with painting tools Desni klik alatkama za slikanje + System Info… Informacije o sistemu… + System Info Informacije o sistemu + More panels Više panela + More documents Više dokumenata + These settings aren't available in PhotoCraft yet. Ova podešavanja još nisu dostupna u PhotoCraft-u. + Graphics Grafika + Reset GPU Backend Resetuj GPU pozadinski sistem + Applies at next launch. Primenjuje se pri sledećem pokretanju. + Click to sample the foreground colour · {key}-click for background Kliknite da uzorkujete boju prednjeg plana · {key}-klik za pozadinu + {key}-click to set the source {key}-klik da postavite izvor + {key} to subtract {key} za oduzimanje + Clone source {n} ({key}-click with Clone Stamp to set) Izvor kloniranja {n} ({key}-klik pečatom za kloniranje da postavite) + Source: not set ({key}-click with Clone Stamp) Izvor: nije postavljen ({key}-klik pečatom za kloniranje) + Pixel Layer Piksel sloj + Type Layer Tekstualni sloj + Shape Layer Sloj oblika + Group Layer Sloj grupe + Fill Layer Sloj popune + Smart Object Layer Sloj pametnog objekta + Create Warped Text Napravi iskrivljeni tekst + Rasterize Smart Object Rasterizuj pametni objekat + Rasterize Fill Content Rasterizuj sadržaj popune + Rasterize Layer Style Rasterizuj stil sloja + Unlock: picking a preset replaces these settings Otključaj: izbor šablona zamenjuje ova podešavanja + Lock: keep these settings when picking another preset Zaključaj: zadrži ova podešavanja pri izboru drugog šablona + Diameter: Prečnik: + Hardness: Tvrdoća: + No brush presets Nema šablona četkica + Toggle the Brush Settings panel (F5) Prikaži/sakrij panel Podešavanja četkice (F5) + Create new brush preset from the current settings Napravi novi šablon četkice od tekućih podešavanja + Brush Settings… (F5): every option of this brush Podešavanja četkice… (F5): sve opcije ove četkice + Rename Brush… Preimenuj četkicu… + Delete Brush Obriši četkicu + New Brush Preset… Novi šablon četkice… + Brush Preset Options Opcije šablona četkice + Group name Ime grupe + Brush name Ime četkice + Brush Name Ime četkice + Brush Stroke Potez četkicom + Off: the speed of the pointer sets the spacing Isključeno: brzina pokazivača određuje razmak + Tilt Scale Razmera nagiba + Brush Projection Projekcija četkice + Apply the pen's tilt and rotation to the tip shape Primeni nagib i rotaciju olovke na oblik vrha + Mixer Brush Četkica za mešanje + Wet Vlažnost + Mix Mešanje + Wetness Jitter Kolebanje vlažnosti + Mix Jitter Kolebanje mešanja + Show the layer mask as an overlay Prikaži masku sloja kao preklop + Toggle visibility Uključi/isključi vidljivost + Unlink the mask from the layer Razveži masku od sloja + Link the mask to the layer Poveži masku sa slojem + Gradient Gradijent + Gradient Options Opcije gradijenta + Align with layer Poravnaj sa slojem + Reset Alignment Resetuj poravnanje + Centre the gradient (offset 0, 0) Centriraj gradijent (pomak 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Uređivač gradijenta + Gradient picker Birač gradijenta + Save As Preset Sačuvaj kao šablon + The gradient the Gradient tool paints with Gradijent kojim slika alatka gradijent + Adds the current gradient to the Gradients panel Dodaje tekući gradijent u panel Gradijenti + Gradient stops Tačke gradijenta + Select a type layer to edit its character and paragraph settings. Izaberite tekstualni sloj da biste uredili podešavanja znakova i pasusa. + Text color Boja teksta + Type Options Opcije teksta + Anti-aliasing method Metod uglađivanja ivica + Horizontal type Horizontalni tekst + Vertical type Vertikalni tekst + Fill path with foreground color Popuni putanju bojom prednjeg plana + Stroke path with brush Ocrtaj putanju četkicom + Load path as a selection Učitaj putanju kao selekciju + Make work path from selection Napravi radnu putanju od selekcije + Add vector mask Dodaj vektorsku masku + Create new path Napravi novu putanju + Delete path Obriši putanju + Delete vector mask Obriši vektorsku masku + CPU (no GPU acceleration) CPU (bez GPU ubrzanja) + DirectX 12 DirectX 12 + GPU backend GPU pozadinski sistem + Linux display server Linux server prikaza + X11 X11 + Metal Metal + OpenGL OpenGL + Use tablet pressure Koristi pritisak na tabletu + Vulkan Vulkan + Alt-click to define a source point to clone from Alt-klik da odredite izvornu tačku za kloniranje + No path to make a selection from: draw one with the Pen tool or select one in the Paths panel. Nema putanje od koje bi se napravila selekcija: nacrtajte je alatkom pero ili je izaberite u panelu Putanje. + Cancel opening Otkaži otvaranje + Cancelled opening {name} Otkazano otvaranje: {name} + Developing {name} Razvijanje: {name} + Cancelled {label} Otkazano: {label} + Esc to cancel Esc za otkazivanje + Opened {name} Otvoreno: {name} + Opening {name} Otvaranje: {name} + Saved {name} Sačuvano: {name} + Saving {name} Čuvanje: {name} + Saving {percent} Čuvanje {percent} + A save to {name} is still running; try again when it finishes Čuvanje u {name} je još u toku; pokušajte ponovo kada se završi + {label} is writing the file and can no longer be cancelled {label}: datoteka se upisuje i više ne može da se otkaže + Opening {name}… Otvaranje: {name}… + Opening… Otvaranje… + Reading… Čitanje… + Working… Obrada… +@id select.all Izaberi sve +@id edit.purge.undo Podaci za opoziv + Delete Smart Filters Obriši pametne filtere + Enable Smart Filters Omogući pametne filtere + New Layer… Novi sloj… + Delete Layer… Obriši sloj… + Color Range Opseg boja + Select Similar Colors Izaberi slične boje + New Selection Nova selekcija + Adaptive Wide Angle Prilagodljivi široki ugao + Chalk & Charcoal Kreda i ugalj + Charcoal Ugalj + Chrome Hrom + Conté Crayon Konte krejon + Graphic Pen Grafičko pero + Halftone Pattern Polutonska šara + Note Paper Papir za beleške + Photocopy Fotokopija + Plaster Gips + Reticulation Retikulacija + Stamp Pečat + Torn Edges Pocepane ivice + Water Paper Vodeni papir + Canvas rotation Rotacija platna + Extensions Proširenja + Libraries Biblioteke + 3D 3D + Toolbars Trake alatki + Move Pomeranje + Wand Štapić + Pencil Olovka + Clone Stamp Pečat za kloniranje + Healing Popravka + Dodge Posvetljivanje + Burn Zatamnjivanje + Pen Pero + Hand Ruka + Sampler Uzorkivač + Note tool Alatka za beleške + Quick Selection Brza selekcija + Magnetic Lasso Magnetni laso + History Brush Četkica istorije + Art History Brush Umetnička četkica istorije + Pattern Stamp Pečat za šaru + Spot Healing Tačkasta popravka + Red Eye Crvene oči + Patch Zakrpa + Content-Aware Move Pomeranje prema sadržaju + Rotate View Rotiraj prikaz + Perspective Crop Perspektivno opsecanje + Color Replacement Zamena boje + Dodge/Burn Posvetljivanje/zatamnjivanje + Dynamics Dinamika + Tilt Nagib + Reset Resetuj + Defaults restored. Podrazumevana podešavanja su vraćena. + Stop Zaustavi + Create new set Napravi novi skup + Technology Previews Probne tehnologije + New Group Nova grupa + Layer Comp Kompozicija slojeva + Create new layer Napravi novi sloj + Group · {n} layer Grupa · slojeva: {n} + General Blend Opšte stapanje + Advanced Blending Napredno stapanje + RGB RGB + Alpha Alfa + Spot Channel Kanal spot boje + Working Gray Plate Radna siva ploča + HSL/Grayscale HSL/nijanse sive + Tone Curve Tonska kriva + Split Toning Podeljeno toniranje + Lens Corrections Korekcije objektiva + Camera Calibration Kalibracija fotoaparata + Snapshots Snimci stanja + Recovery Oporavak + Fill Light Svetlo popune + Convert to Grayscale Pretvori u nijanse sive + Sharpness Oštrina + Color Noise Reduction Smanjenje šuma boje + Luminance Smoothness Glatkoća luminanse + Make Proizvođač + Model Model + Lens Objektiv + F F + ISO ISO + Focal Length35mm Žižna daljina (35 mm) + Aspect Ratio Odnos stranica + Time Vreme + Artist Umetnik + Enter description… Unesite opis… + ColorSync ColorSync + ProPhoto ProPhoto + sRGB sRGB + ACES ACES + Industry Standard Industrijski standard + Uncoated CMYK Nepremazani CMYK + Newspaper Novinski papir + Working Spaces Radni prostori + Color Management Policies Pravila upravljanja bojama + Detect Faces Prepoznaj lica + Localize Color Clusters Lokalizuj grupe boja + Duplicated selection name Ime duplirane selekcije + Operation Operacija + New channel only Samo novi kanal + Masked Areas Maskirane oblasti + Selected Areas Izabrane oblasti + Spot Color Spot boja + Color indicates Boja označava + Create Snapshot Napravi snimak stanja + Delete Snapshot Obriši snimak stanja + New Snapshot Novi snimak stanja + Snapshot 1 Snimak stanja 1 + Step Forward Korak napred + Step Backward Korak nazad + Nonlinear History Nelinearna istorija + Set New Snapshot as Default Postavi novi snimak stanja kao podrazumevani + Automatically Create First Snapshot Automatski napravi prvi snimak stanja + Automatically Create New Snapshot When Saving Automatski napravi novi snimak stanja pri čuvanju + Show New Snapshot Dialog by Default Podrazumevano prikaži dijalog za novi snimak stanja + Clear History Obriši istoriju + Panel Options… Opcije panela… + View menu Meni prikaza + Keep view in sync Drži prikaz usklađenim + {zoom}% {zoom}% + Mouse Controls Kontrole mišem + Initial Direction of Stroke Početni smer poteza + Direction of Stroke Smer poteza + Humidity Vlažnost vazduha + Auto Clean Brush Automatski čisti četkicu + Spacing: Razmak: + Brush Tip Vrh četkice + New Layer from Brush Novi sloj od četkice + New Brush Nova četkica + New Tool Preset Novi šablon alatke + Auto-Leading Automatski prored + Scale Horizontally Skaliraj horizontalno + Scale Vertically Skaliraj vertikalno + Baseline Shift Pomak osnovne linije + Mac LCD Mac LCD + Mac Sharp Mac oštro + Mac Crisp Mac jasno + Mac Strong Mac snažno + Mac Smooth Mac glatko + Superscript Eksponent + Subscript Indeks + Ligature Ligatura + Alternative Alternativa + Roman Uspravno + Bold Italic Podebljani kurziv + Ultra Light Ultra lagano + Extra Light Ekstra lagano + Semi Bold Polupodebljano + Extra Bold Ekstra podebljano + Ultra Bold Ultra podebljano + Condensed Suženo + First Line Indent Uvlačenje prvog reda + Hanging Punctuation Viseća interpunkcija + Roman Hanging Punctuation Viseća interpunkcija za latinicu + Avoid Widows/Orphans Izbegavaj udovice/siročiće + Compose Slaganje + Adobe Single-line Composer Adobe slagač pojedinačnih redova + Adobe Every-line Composer Adobe slagač svih redova + Justification Obostrano poravnanje + Word Spacing Razmak između reči + Letter Spacing Razmak između slova + Glyph Scaling Skaliranje glifova + Auto-leading % Automatski prored % + Single Word Justification Obostrano poravnanje jedne reči + Alignment Poravnanje + Justify Poravnaj obostrano + Glyph Glif + Alternates for Current Selection Alternative za tekuću selekciju + Digits Cifre + Symbols Simboli + Punctuation Interpunkcija + Path: Putanja: + Path to Illustrator… Putanja u Illustrator… + Make Work Path Napravi radnu putanju + Make Selection Napravi selekciju + Fill Path Popuni putanju + Stroke Path Ocrtaj putanju + Clipping Path Putanja isecanja + New Path Nova putanja + Duplicate Path Dupliraj putanju + Delete Path Obriši putanju + Auto-Hide Automatski sakrij + W Š + H V + X X + Y Y + Frame Sync Sinhronizacija kadrova + Quick Sync Brza sinhronizacija + Audio Zvuk + Frame Duration Trajanje kadra + Loop Petlja + Split at Playhead Podeli na mestu glave za reprodukciju + Flatten Frames Into Clips Spljošti kadrove u klipove + Flatten Frames Into Layers Spljošti kadrove u slojeve + Convert Frames > Video Layers Pretvori kadrove > video slojevi + New Video Layer Novi video sloj + New Video Group Nova video grupa + Limit Playback Ograniči reprodukciju + Enable Timeline Quick Config Omogući brzo podešavanje vremenske linije + Set Start Timecode… Postavi početni vremenski kod… + Timecode Vremenski kod + 30 fps 30 fps + 25 fps 25 fps + 24 fps 24 fps + 15 fps 15 fps + 10 fps 10 fps + Current Time Trenutno vreme + Out Izlaz + In Ulaz + Duration Trajanje + Repeat Ponavljaj + Play Once Pusti jednom + Info Options… Opcije informacija… + First Color Readout Prvo očitavanje boje + Second Color Readout Drugo očitavanje boje + Actual Color Stvarna boja + Grayscale Slider Klizač nijansi sive + Web Color Veb boja + HSB Color HSB boja + Total Ink Ukupno mastilo + Opacity Mask Maska neprozirnosti + Ruler Lenjir + Document Size Veličina dokumenta + Timing Vreme izvršavanja + Status Information Statusne informacije + Saved Multichannel Sačuvano višekanalno + Mouse Coordinates Koordinate miša + Ruler Units Jedinice lenjira + Hide Sakrij + New Layer Comp Nova kompozicija slojeva + Duplicate Layer Comp Dupliraj kompoziciju slojeva + Rename Layer Comp Preimenuj kompoziciju slojeva + Apply Layer Comp Primeni kompoziciju slojeva + Update Layer Comp Ažuriraj kompoziciju slojeva + Restore last viewed comp Vrati poslednju prikazanu kompoziciju + Method Metod + Select All Layers Izaberi sve slojeve + Keep proportions Zadrži proporcije + Interpolation Interpolacija + Author Autor + Caption Natpis + Caption Credits Zasluge za natpis + Keywords Ključne reči + Copyright Info URL URL informacija o autorskim pravima + Copyright Status Status autorskih prava + Copyrighted Zaštićeno autorskim pravima + Public Domain Javno dobro + Unknown Nepoznato + Credit Zasluge + Transmission Reference Referenca prenosa + Urgency Hitnost + High Visoko + Low Nisko + Category Kategorija + Supplemental Categories Dodatne kategorije + Object Name / Title Ime objekta / naslov + Date Created Datum nastanka + City Grad + Location Lokacija + Province / State Pokrajina / država + Country Zemlja + Byline Potpis autora + Byline Title Zvanje autora + Phone Telefon + Email E-pošta + URLs URL adrese + Last Modified Poslednja izmena + EXIF EXIF + GPS GPS + Camera Data Podaci fotoaparata + Camera Model Model fotoaparata + Camera Make Proizvođač fotoaparata + EXIF Version EXIF verzija + Exposure Program Program ekspozicije + Aperture Otvor blende + Shutter Speed Brzina zatvarača + Metering Mode Režim merenja svetla + Light Source Izvor svetla + Flash Blic + Lens Info Podaci o objektivu + ISO Speed ISO osetljivost + Subject Distance Udaljenost objekta + DateTime Original Izvorni datum i vreme + Create Date Datum kreiranja + Modify Date Datum izmene + Software Softver + Raw Data Neobrađeni podaci + Advanced Napredno + Import… Uvezi… + PDF Presentation… PDF prezentacija… + Create Multiple Picture Packages Napravi više paketa slika + Picture Package… Paket slika… + Web Photo Gallery… Veb galerija fotografija… + Choose… Izaberi… + Select Folder… Izaberi fasciklu… + Override Action "Save" Commands Nadjačaj komande „Sačuvaj“ u radnji + File Naming Imenovanje datoteka + Compatibility Kompatibilnost + Mac OS Mac OS + Unix Unix + Include ICC Profile Uključi ICC profil + Error File Datoteka grešaka + Stop For Errors Zaustavi se kod grešaka + Log Errors To File Beleži greške u datoteku + Errors Greške + Settings Podešavanja + Settings: Podešavanja: + Working Space Radni prostor boja + RGB: RGB: + CMYK: CMYK: + Gray: Siva: + Spot: Spot: + Advanced Controls Napredne kontrole + Desaturate Monitor Colors By Smanji zasićenost boja monitora za + Blend RGB Colors Using Gamma 1.0 Stapaj RGB boje pomoću game 1.0 + Profile Mismatches: Ask When Opening Neslaganje profila: pitaj pri otvaranju + Ask When Pasting Pitaj pri lepljenju + Missing Profiles: Ask When Opening Profili koji nedostaju: pitaj pri otvaranju + Embed profiles Ugradi profile + Ask Before Saving Saved Embedded Pitaj pre čuvanja sa ugrađenim profilom + Coerce to sRGB Prinudno pretvori u sRGB + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + Adobe RGB (1998) Adobe RGB (1998) + ProPhoto RGB ProPhoto RGB + ColorMatch RGB ColorMatch RGB + Wide Gamut RGB Wide Gamut RGB + Apple RGB Apple RGB + NTSC (1953) NTSC (1953) + PAL/SECAM PAL/SECAM + SMPTE C SMPTE C + HDTV (Rec. 709) HDTV (Rec. 709) + CIE RGB CIE RGB + Graphic and web Grafika i veb + Minimalistic Minimalistički + Expand All Groups Proširi sve grupe + Photo (2 × 3 in) Fotografija (2 × 3 in) + Photo (4 × 6 in) Fotografija (4 × 6 in) + Photo (5 × 7 in) Fotografija (5 × 7 in) + Photo (8 × 10 in) Fotografija (8 × 10 in) + Recent Items Nedavne stavke + Save Preset… Sačuvaj šablon… + Delete Preset… Obriši šablon… + Preset Name: Ime šablona: + Width and Height Širina i visina + Color Profile Profil boja + Units Jedinice + Columns Kolone + Create new count group Napravi novu grupu brojanja + Adjustment Layer Korekcioni sloj + Pencil Tool Alatka olovka + Pixel Piksel + Adjustment Korekcija + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract Prevucite (laso) ili kliknite tačke (mnogougao) · {add} dodaje · {sub} oduzima + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · arrows nudge · X swaps orientation · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · strelice pomeraju malo · X menja orijentaciju · {commit} potvrđuje · Esc otkazuje + {mode} Color · {bits} bit {mode} boje · {bits} bita + Drag to move the layer Prevucite da pomerite sloj + Scrubby Zoom Zumiranje prevlačenjem + Auto Erase Automatsko brisanje + Color Adaptation Prilagođavanje boja + Preserve Transparency Očuvaj providnost + shortcut prečica + Go to Layer Idi na sloj + Saved Sačuvano + After (X) Posle (X) + Current Tekuće + Reference point location Položaj referentne tačke + Toggle reference point Uključi/isključi referentnu tačku + Arc Luk + Arch Svod + Bulge Ispupčenje + Shell Lower Školjka dole + Shell Upper Školjka gore + Flag Zastava + Fish Riba + Wave Talas + Rise Uzdizanje + Inflate Naduvavanje + Twist Zavrtanje + Squeeze Stiskanje + Add Pins Dodaj čiode + Delete Pins Obriši čiode + Rotate Auto Automatska rotacija + Rotate Fixed Fiksna rotacija + Create Plane Napravi ravan + Edit Plane Uredi ravan + Select Plane Izaberi ravan + Measure Meri + Heal Popravka + Heal Off Popravka isključena + Heal On Popravka uključena + Heal Auto Automatska popravka + Blend Stapanje + Brush Size Veličina četkice + Heal Color Popravka boje + Mask Maska + Channel Kanal + Rectangle Pravougaonik + Result Rezultat + New Channel Novi kanal + Save Selection Sačuvaj selekciju + Load Selection Učitaj selekciju + Duplicate Dupliraj + Fast Brzo + Accurate Precizno + Add to Dodaj u + Subtract from Oduzmi od + Intersect with Presek sa + Capture Comp Zabeleži kompoziciju + No Layer Comps Nema kompozicija slojeva + Adobe PDF Presets… Adobe PDF šabloni… + 3D… 3D… + Technology Previews… Probne tehnologije… + Background from Layer Pozadina od sloja + Add a mask (from the selection; ⌥ inverts) Dodaj masku (iz selekcije; ⌥ invertuje) + Add to selection (⇧) Dodaj u selekciju (⇧) + Airbrush: keeps painting while the pointer rests. Vazdušna četkica: nastavlja da slika dok pokazivač miruje. + Click the first point or press ↵ to close · Esc cancels Kliknite prvu tačku ili pritisnite ↵ da zatvorite · Esc otkazuje + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove Kliknite da dodate tačku · prevucite van, ⌘/Ctrl-klik ili Delete da uklonite + Click to apply, ⌥-click to clear overrides, double-click for Style Options Kliknite da primenite, ⌥-klik da uklonite nadjačavanja, dvoklik za opcije stila + Click to zoom in · ⌥-click to zoom out Kliknite da uvećate · ⌥-klik da umanjite + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel Klik: ugao · Prevlačenje: glatko · Klik na prvu tačku: zatvori · ↩ završi · Esc otkaži + Commit Perspective Warp (↩) Potvrdi perspektivno krivljenje (↩) + Commit Puppet Warp (↩) Potvrdi lutkarsko krivljenje (↩) + Commit any current edits (⌘↩) Potvrdi sve tekuće izmene (⌘↩) + Commit current crop operation (↵) Potvrdi tekuće opsecanje (↵) + Commit transform (↩) Potvrdi transformaciju (↩) + Commit warp (↩) Potvrdi krivljenje (↩) + Create a new layer (⇧⌘N) Napravi novi sloj (⇧⌘N) + Custom pattern Prilagođena šara + General Brushes Opšte četkice + Grid (⌘') Mreža (⌘') + Intersect with selection (⇧⌥) Presek sa selekcijom (⇧⌥) + New document… ⌘N Novi dokument… ⌘N + Open… ⌘O Otvori… ⌘O + Rulers (⌘R) Lenjiri (⌘R) + Search commands (⌘K) Pretraži komande (⌘K) + Show Guides (⌘;) Prikaži vodilje (⌘;) + Subtract from selection (⌥) Oduzmi od selekcije (⌥) + Uses the foreground and background colors. Koristi boje prednjeg plana i pozadine. + ↑↓ navigate ↵ run esc close ↑↓ kretanje ↵ pokreni esc zatvori + Adds grain to the soft edges of the brush tip. Dodaje zrno mekim ivicama vrha četkice. + Keeps the current texture when switching presets. Zadržava tekuću teksturu pri promeni šablona. + Watercolour look: lighter interior, darker rims. Izgled akvarela: svetlija unutrašnjost, tamniji rubovi. + Collapse group (⌥-click: all groups) Skupi grupu (⌥-klik: sve grupe) + Expand group (⌥-click: all groups) Proširi grupu (⌥-klik: sve grupe) + Hide the layer's effects (⌥-click: all layers) Sakrij efekte sloja (⌥-klik: svi slojevi) + Show the layer's effects (⌥-click: all layers) Prikaži efekte sloja (⌥-klik: svi slojevi) + +# Copy transforms, layer navigation and cancellable opening (from PR #582 by neko233-com). + New from Clipboard Novo iz ostave + Free Transform a Copy Slobodna transformacija kopije + Transform Again on a Copy Ponovi transformaciju na kopiji + Select Layer Above Izaberi sloj iznad + Select Layer Below Izaberi sloj ispod + Select Top Layer Izaberi gornji sloj + Select Bottom Layer Izaberi donji sloj + Add Layer Above to Selection Dodaj sloj iznad u izbor + Add Layer Below to Selection Dodaj sloj ispod u izbor + +# Labels added on main after this catalog (depth labels, Wayland drop, profiles, rendering mode) + 8 bit 8 bita + Integer Celobrojno + 16 bit 16 bita + 32 bit (float) 32 bita (pokretni zarez) + Floating point Pokretni zarez + Use Global Light Koristi globalno svetlo + Native file drag-and-drop is unavailable Izvorno prevlačenje i otpuštanje datoteka nije dostupno + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili kopirajte sliku u menadžeru datoteka i nalepite je pomoću {paste}. + To drop files, start PhotoCraft under XWayland: `{command}` Da biste otpuštali datoteke, pokrenite PhotoCraft pod XWayland-om: `{command}` + Or set Preferences › Performance › Linux display server to X11: PhotoCraft then always starts under XWayland. Ili postavite Podešavanja › Performanse › Linux server prikaza na X11: PhotoCraft će se tada uvek pokretati pod XWayland-om. + Edit › Paste Uređivanje › Nalepi + Use File › Open, or paste a copied image with {paste}. Koristite Datoteka › Otvori ili nalepite kopiranu sliku pomoću {paste}. + Mixer Brush Tool Alatka četkica za mešanje + Color Replacement Tool Alatka za zamenu boje + Invalid {mode} profile Neispravan {mode} profil + Unnamed {mode} profile Neimenovani {mode} profil + Show bounding box when dragging layer Prikaži granični okvir pri prevlačenju sloja + System title bar Sistemska naslovna traka + Freeze Lasso: drag to freeze an area, Alt-drag to thaw it (L) Laso za zamrzavanje: prevucite da zamrznete oblast, Alt-prevlačenje da je odmrznete (L) + Rendering Mode Režim renderovanja + Automatic (recommended) Automatski (preporučeno) + CPU / Compatibility CPU / kompatibilnost + Automatic uses GPU acceleration when available and falls back to CPU rendering on errors. Automatski režim koristi GPU ubrzanje kada je dostupno, a u slučaju grešaka prelazi na renderovanje procesorom. + Switched to CPU rendering Prebačeno na renderovanje procesorom + GPU acceleration could not continue. Your documents are unchanged. GPU ubrzanje nije moglo da se nastavi. Vaši dokumenti su nepromenjeni. + PhotoCraft is using the CPU image compositor. The window may still use your graphics adapter. PhotoCraft koristi CPU kompozitor slike. Prozor i dalje može da koristi vaš grafički adapter. + Retry GPU requires a restart. Save your work first. Ponovni pokušaj sa GPU-om zahteva ponovno pokretanje. Prvo sačuvajte svoj rad. + Keep Using CPU Nastavi sa CPU-om + Retry GPU Ponovo pokušaj sa GPU-om + GPU Backend GPU pozadinski sistem + Details Detalji + Rendering mode Režim renderovanja + +# Camera Raw scopes and the Patch tool + Shadow Clipping Warning (U) Upozorenje o odsecanju senki (U) + Highlight Clipping Warning (O) Upozorenje o odsecanju svetla (O) + Show Lab Color Readouts Prikaži Lab očitavanja boja + Show Vectorscope Prikaži vektorskop + Show Red at 3 o'clock Prikaži crvenu na poziciji 3 sata + Show Skin Tone Indicator Prikaži indikator tona kože + Tone Histogram Tonski histogram + Show Selected Region Prikaži izabranu oblast +cameraRaw Light Svetlo + Camera Raw Filter ({layer}) Camera Raw filter ({layer}) + Camera Raw ({name}) Camera Raw ({name}) +cameraRaw Lights Svetli tonovi +cameraRaw Darks Tamni tonovi +cameraRaw Blacks Crne +cameraRaw Amount Količina +cameraRaw Tint Preliv +cameraRaw Masking Maskiranje +cameraRaw Blending Stapanje + Patch Tool Alatka zakrpa + Patch: Zakrpa: + Lasso around an area, then drag the selection Zaokružite oblast lasom, a zatim prevucite selekciju + Transform On Drop Transformiši pri otpuštanju + Scale or rotate the moved area, then press {key} Skalirajte ili rotirajte pomerenu oblast, a zatim pritisnite {key} + Now drag the selection onto another area Sada prevucite selekciju na drugu oblast + +# Warp grid presets (#388) + 3 x 3 3 x 3 + 4 x 4 4 x 4 + 5 x 5 5 x 5 + Grid: Mreža: + Edit Type Uredi tekst + + New Style… Novi stil… + Style {name} saved Stil {name} je sačuvan + + Create new style (from the selected type) Napravi novi stil (od izabranog teksta) + Create new style from the selected layer Napravi novi stil od izabranog sloja + + Make Default Postavi kao podrazumevano + Reset to Default Vrati na podrazumevano + Full-resolution preview unavailable Pregled u punoj rezoluciji nije dostupan + No patterns Nema šara + Enable {name} Omogući {name} + Hold Alt while drawing for straight segments Držite Alt tokom crtanja za prave segmente + Drag or click polygon points · hold Alt during lasso for straight segments · {add} add · {sub} before drawing subtracts Prevucite ili kliknite tačke mnogougla · držite Alt tokom crtanja lasom za prave segmente · {add} dodaje · {sub} pre crtanja oduzima + Extend Proširi + Structure: Struktura: + Content-Aware Move Tool Alatka za pomeranje prema sadržaju + TIFF Options TIFF opcije + Targa Options Targa opcije + 24 bits/pixel 24 bita/piksel + 32 bits/pixel 32 bita/piksel + Including layers will increase file size. Uključivanje slojeva povećaće veličinu datoteke. + Discard Layers and Save a Copy Odbaci slojeve i sačuvaj kopiju + Don't show again Ne prikazuj ponovo + Save failed: {error} Čuvanje nije uspelo: {error} + Save cancelled Čuvanje je otkazano + Couldn't place {name}: {error} Postavljanje {name} nije uspelo: {error} + Magnetic Lasso Tool Alatka magnetni laso + Frequency Frekvencija + Use tablet pressure to change pen width Koristi pritisak na tabletu za promenu širine pera + +# About ▸ Contributors / Models (credits, craftrules standards/contributors.md) + About O programu + Contributors Saradnici + Models Modeli + Sort Sortiraj + Username Korisničko ime + Display name Prikazno ime + Real name Pravo ime + Reverse the order Obrni redosled + Grab bag Mešavina + Table Tabela + Name (A–Z) Ime (A–Z) + Merged PRs Spojeni PR-ovi + Commits Komitovi + Lines added Dodate linije + Lines deleted Obrisane linije + Line delta Razlika u linijama + Binary assets added Dodati binarni resursi + Binary assets removed Uklonjeni binarni resursi + First commit Prvi komit + First Prvi + Last commit Poslednji komit + Last Poslednji + Contributors: {contributors} · Commits: {commits} Saradnici: {contributors} · Komitovi: {commits} + No contributor data was built into this copy. U ovu kopiju nisu ugrađeni podaci o saradnicima. + No model credits were built into this copy. U ovu kopiju nisu ugrađene zasluge modela. + Company Kompanija + Version Verzija + % of all commits % svih komitova + Lines +/− Linije +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR-ova: {prs}, komitova: {commits}, linija: +{added} / −{deleted} (Δ {delta}), binarnih resursa: +{binAdded} / −{binDeleted}, {first} – {last} + +# Swatches panel + Create new swatch Napravi novi uzorak boje + New Swatch… Novi uzorak boje… + New Swatch Group… Nova grupa uzoraka boja… + Import Swatches… Uvezi uzorke boja… + Replace Swatches… Zameni uzorke boja… + Export Swatches… Izvezi uzorke boja… + Export Swatches for Exchange… Izvezi uzorke boja za razmenu… + Reset Swatches Resetuj uzorke boja + Add to Swatches Dodaj u uzorke boja +# Layer Style > Blending Options (Advanced Blending) + Blend If: Stapaj ako: + Channels: Kanali: + Knockout Izbijanje + Apply At Canvas Bounds Primeni na granicama platna + +# Shared screen eyedropper and text Color Picker (#1128). + Color Picker (Text Color) Birač boja (boja teksta) + Color Picker ({name}) Birač boja ({name}) + Color Picker (Fill Color) Birač boja (boja popune) + Fill color Boja popune + Color Picker (Stroke Color) Birač boja (boja obruba) + Stroke color Boja obruba + Pick screen color Uzmi boju sa ekrana + Sample a pixel anywhere on the screen Uzorkujte piksel bilo gde na ekranu + Click to pick · Esc to cancel Kliknite da uzmete boju · Esc za otkazivanje + Pick a screen pixel or press Esc to cancel Izaberite piksel na ekranu ili pritisnite Esc da otkažete + Scroll to zoom Pomerajte točkić za zumiranje + Show/Hide All Panels Prikaži/sakrij sve panele + Show/Hide Panels Prikaži/sakrij panele + Could not open Otvaranje nije uspelo + {path}: the path is not valid Unicode; rename the file and open it again. {path}: putanja nije ispravan Unicode; preimenujte datoteku i ponovo je otvorite. + +# Fill dialog, layer style contours and Image Size + Contents: Sadržaj: + Custom Pattern: Prilagođena šara: + Foreground Color Boja prednjeg plana + Color… Boja… + 50% Gray 50% siva + {size} (was {old}) {size} (bilo je {old}) + Cone Kupa + Cone (Inverted) Kupa (obrnuta) + Domed Kupola + Domed (Inverted) Kupola (obrnuta) + Diagonal (Descending) Dijagonala (opadajuća) + Contour Editor Uređivač konture + Anti-aliased Uglađeno + Corner Ugao + Gloss Contour Kontura sjaja + Contour Kontura + Cone - Inverted Kupa - obrnuta + Cove - Deep Udubljenje - duboko + Cove - Shallow Udubljenje - plitko + Gaussian Gausova + Half Round Polukrug + Ring Prsten + Ring - Double Prsten - dvostruki + Rolling Slope - Descending Talasasta kosina - opadajuća + Rounded Steps Zaobljeni stepenici + Sawtooth 1 Testera 1 + + Red Eye Tool Alatka za crvene oči + Pupil Size Veličina zenice + Darken Amount Količina zatamnjenja + Click a red pupil to neutralize it Kliknite crvenu zenicu da je neutrališete + open source otvoreni kod + …and {n} more …i još {n} + + Eyedroppers: Pipete: + Set Black Point Postavi crnu tačku + Set Neutral Gray Point Postavi tačku neutralne sive + Set White Point Postavi belu tačku + Unavailable in this color mode Nedostupno u ovom režimu boja + Selection Preview: Pregled selekcije: + Black Matte Crna podloga + White Matte Bela podloga + +# Layer colour labels. + Seafoam Morska pena + Indigo Indigo + Fuchsia Fuksija + Violet Ljubičasta + Large document: redrawing on the CPU Veliki dokument: ponovno iscrtavanje procesorom + This redraw took {seconds} s on the CPU. Ovo iscrtavanje je trajalo {seconds} s na procesoru. + Edits that change the whole document, such as a layer's blend mode, opacity or visibility, redraw all of it. Izmene koje menjaju ceo dokument, kao što su režim stapanja, neprozirnost ili vidljivost sloja, ponovo iscrtavaju ceo dokument. + Its layers don't fit the GPU memory budget. Raising Memory usage in Preferences › Performance raises the budget, up to a quarter of this computer's memory. Njegovi slojevi ne staju u budžet GPU memorije. Povećanje vrednosti Korišćenje memorije u Podešavanja › Performanse povećava budžet, do četvrtine memorije ovog računara. + The GPU compositor wasn't used: {reason} GPU kompozitor nije korišćen: {reason} +fontWeight Thin Tanko +fontWeight ExtraLight Ekstra lagano +fontWeight Light Lagano +fontWeight Regular Obično +fontWeight Medium Srednje +fontWeight SemiBold Polupodebljano +fontWeight Bold Podebljano +fontWeight ExtraBold Ekstra podebljano +fontWeight Black Crno +fontWeight Italic Kurziv + +# Pattern Stamp tool + Pattern Stamp Tool Alatka pečat za šaru + Impressionist Impresionistički + Rotate View Tool Alatka za rotiranje prikaza + Reset View Resetuj prikaz + Drag around the centre to rotate the view · Shift constrains to 15° Prevlačite oko centra da rotirate prikaz · Shift ograničava na korake od 15° + +# Painting symmetry presets. + Dual Axis Dvostruka osa + Diagonal Dijagonala + Transform Symmetry Transformiši simetriju + +# Eyedropper Sample Size and Sample (#1649). + Point Sample Uzorak tačke + 3 by 3 Average Prosek 3 x 3 + 5 by 5 Average Prosek 5 x 5 + 11 by 11 Average Prosek 11 x 11 + 31 by 31 Average Prosek 31 x 31 + 51 by 51 Average Prosek 51 x 51 + 101 by 101 Average Prosek 101 x 101 + Sample Size: Veličina uzorka: + All Layers No Adjustments Svi slojevi bez korekcija + Current & Below No Adjustments Tekući i ispod bez korekcija + Show Sampling Ring Prikaži prsten uzorkovanja + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Van gamuta za štampu: kliknite za najbližu boju koja može da se odštampa + Not a web colour: click for the nearest web colour Nije veb boja: kliknite za najbližu veb boju + Color to Alpha… Boja u alfu… + Transparency Threshold Prag providnosti + Opacity Threshold Prag neprozirnosti + PhotoCraft — Screen Color Picker PhotoCraft — birač boja sa ekrana + + Rasterize svg groups deeper than Rasterizuj SVG grupe dublje od + 0 turns this threshold off; SVG groups nested deeper than 100 levels are always rasterized. 0 isključuje ovaj prag; SVG grupe ugnežđene dublje od 100 nivoa uvek se rasterizuju. + Rule of Thirds Pravilo trećina + Triangle Trougao + Golden Ratio Zlatni presek + Golden Spiral Zlatna spirala + Auto Show Overlay Automatski prikaži preklop + Always Show Overlay Uvek prikaži preklop + Never Show Overlay Nikad ne prikazuj preklop + Cycle Overlay Smenjuj preklope + Cycle Orientation Smenjuj orijentaciju + Set the overlay options for the Crop tool Podesi opcije preklopa za alatku za opsecanje + Alt-click went to the desktop, which moves windows with Alt-drag. Set its window-move key to Super, or latch Alt in Window › Modifier Keys. Alt-klik je preuzelo radno okruženje, koje pomera prozore Alt-prevlačenjem. Postavite taster za pomeranje prozora u njemu na Super ili zaključajte Alt u Prozor › Modifikatorski tasteri. + +# Shared Layer Style color picker + Color Picker (Layer Style Color) Birač boja (boja stila sloja) + Auto Hide Notifications Automatski sakrij obaveštenja + Notification Duration (seconds) Trajanje obaveštenja (sekunde) + Appearance Mode Režim izgleda + Dark Theme Tamna tema + Light Theme Svetla tema + Pro Medium Pro srednja + Studio Light Studio svetla + Next Appearance Mode Sledeći režim izgleda + Sync with system Usklađeno sa sistemom + Active Aktivno + Color Picker (Solid Color) Birač boja (puna boja) + Straighten the image by drawing a line on it Ispravite sliku tako što ćete povući liniju preko nje + Set additional Crop options Podesi dodatne opcije opsecanja + Show Cropped Area Prikaži isečenu oblast + Enable Crop Shield Omogući zaštitni sloj opsecanja + Match Canvas Uskladi sa platnom + Auto Adjust Opacity Automatski podesi neprozirnost + Crop Shield color Boja zaštitnog sloja opsecanja + Color Picker (Crop Shield Color) Birač boja (boja zaštitnog sloja opsecanja) + From Center Od centra + Link radii Poveži poluprečnike + Star Ratio: Odnos zvezde: + Create Rectangle Napravi pravougaonik + Create Ellipse Napravi elipsu + Create Triangle Napravi trougao + Create Polygon Napravi mnogougao + Pressure curve Kriva pritiska + Click to add a point, right-click to remove Kliknite da dodate tačku, desni klik da je uklonite + Some brush presets could not be loaded Neki šabloni četkica nisu mogli da se učitaju + Retry saving presets Ponovo pokušaj čuvanje šablona + Brush preset changes are not saved: {error} Izmene šablona četkica nisu sačuvane: {error} + Keep this tab open to retain your brushes. Držite ovu karticu otvorenom da biste zadržali svoje četkice. + Browser storage is unavailable; brushes are session-only. Keep this tab open to retain them. Skladište pregledača nije dostupno; četkice važe samo za ovu sesiju. Držite ovu karticu otvorenom da biste ih zadržali. + Saving brush presets… Keep this tab open until saving finishes. Čuvanje šablona četkica… Držite ovu karticu otvorenom dok se čuvanje ne završi. + Reflect Refleksija + Glow Sjaj + Negation Negacija + XOR XOR + Color Burn (Paint.NET) Zatamnjivanje boje (Paint.NET) + Color Dodge (Paint.NET) Posvetljivanje boje (Paint.NET) + Stroke Options Opcije obruba + Solid Puna + Dashed Isprekidana + Dotted Tačkasta + Caps Završeci + Butt / Flat Ravno / tupo + Corners Uglovi + Miter Šiljato + Round Zaobljeno + Bevel Zakošeno + Miter limit Granica šiljatosti + Dashed line Isprekidana linija + Dash Crtica + Gap Razmak + Dash / gap lengths × stroke width Dužine crtica / razmaka × širina obruba + Add pair Dodaj par + Remove pair Ukloni par + Dash offset Pomak crtica + Open paths use centered strokes Otvorene putanje koriste centrirani obrub + Use finite lengths and a positive pattern total Koristite konačne dužine i pozitivan ukupni zbir šablona + No subject found — try Object Selection or the Lasso Subjekat nije pronađen — probajte selekciju objekta ili laso + Quick Selection found nothing there Brza selekcija tu nije ništa pronašla + Object Selection found nothing there — try a tighter rectangle Selekcija objekta tu nije ništa pronašla — probajte uži pravougaonik + No subject found — try a rectangle around the object Subjekat nije pronađen — probajte pravougaonik oko objekta + Click the first point or press {key} to close · {del} removes a point · Esc cancels Kliknite prvu tačku ili pritisnite {key} da zatvorite · {del} uklanja tačku · Esc otkazuje + Click, then move along an edge · click to fasten a point · {alt}-click for a straight segment · double-click to close Kliknite, pa se pomerajte duž ivice · kliknite da pričvrstite tačku · {alt}-klik za pravi segment · dvoklik za zatvaranje + Follows only edges this close to the pointer ([ and ] change it) Prati samo ivice na ovoj udaljenosti od pokazivača ([ i ] je menjaju) + Higher values follow only edges that contrast sharply with their surroundings Veće vrednosti prate samo ivice koje su u oštrom kontrastu sa okolinom + How often fastening points are placed by themselves Koliko se često tačke pričvršćivanja postavljaju same + Appearance Settings… Podešavanja izgleda… + Add to favorites Dodaj u omiljene + Remove from favorites Ukloni iz omiljenih +# File Info ▸ Camera Data (read-only EXIF fields; "Make" is the camera maker, not the verb). +cameraData Make Proizvođač +cameraData Model Model +cameraData Date Taken Datum snimanja +cameraData Exposure Ekspozicija +cameraData F-Stop F-broj +cameraData ISO Speed ISO osetljivost +cameraData Focal Length Žižna daljina +cameraData Focal Length (35 mm) Žižna daljina (35 mm) +cameraData Lens Objektiv +# Shared panel docking + Activate Panel Aktiviraj panel + Change Panel Group Promeni grupu panela + Change Panel Layout Promeni raspored panela + Close Panel Zatvori panel + Dock Panel Usidri panel + Float Panel Odvoji panel + Move Panel Premesti panel + Group with Grupiši sa + Panel menu Meni panela + Resize panel window Promeni veličinu prozora panela + Resize panels Promeni veličinu panela + Use Classic Mode Koristi klasični režim + Auto Center Preview Automatski centriraj pregled + Next Document Sledeći dokument + Previous Document Prethodni dokument + W x H x Resolution Š x V x rezolucija + Front Image Prednja slika + Frame Tool Alatka okvir + Create a new rectangular frame Napravi novi pravougaoni okvir + Create a new elliptical frame Napravi novi eliptični okvir + Include in playback Uključi u reprodukciju + Perspective Crop Tool Alatka za perspektivno opsecanje + Show Grid Prikaži mrežu + Drag a box or click four corners · drag the corners onto the edges of a skewed shape · {commit} commits · Esc cancels Prevucite okvir ili kliknite četiri ugla · prevucite uglove na ivice iskošenog oblika · {commit} potvrđuje · Esc otkazuje + Artboard Tool Alatka radna tabla + Make Portrait Postavi uspravno + Make Landscape Postavi položeno + Add New Artboard Dodaj novu radnu tablu + Freeform Pen Tool Alatka slobodno pero + Curvature Pen Tool Alatka pero za krive + Curve Fit: Prilagođavanje krive: + Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · {alt}-klik: pravi segment · Otpuštanje na početnoj tački: zatvori + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: glatka tačka · Dvoklik: ugao · Prevlačenje tačke: pomeri · Klik na prvu tačku: zatvori · {key} završi + New Crop Preset… Novi šablon opsecanja… + Delete Crop Preset… Obriši šablon opsecanja… + Select the crop presets to delete: Izaberite šablone opsecanja za brisanje: + Select the crop presets to delete Izaberite šablone opsecanja za brisanje + Enter a name for the crop preset Unesite ime šablona opsecanja + There is no crop size or ratio to save Nema veličine ili odnosa opsecanja za čuvanje + Scale: Razmera: + Snap to Origin Prianjaj uz ishodište + Search settings… Pretraži podešavanja… + No matching settings Nema odgovarajućih podešavanja + Loading fonts… Učitavanje fontova… + Tool Alatka + Simulate Pressure Simuliraj pritisak + Stroke Path… Ocrtaj putanju… + Background Eraser Gumica za pozadinu + Healing Brush Četkica za popravku + Spot Healing Brush Četkica za tačkastu popravku + Smudge Razmazivanje + Sponge Sunđer + Amount: Količina: + Protect: Zaštiti: + Protect skin tones Zaštiti tonove kože + Edit points to modify the curve Uredite tačke da izmenite krivu + Draw to modify the curve Crtajte da izmenite krivu +curves Smooth Izgladi + Smooth the curve values Izgladi vrednosti krive + Drag to draw the curve · {key}-click draws a straight line Prevucite da nacrtate krivu · {key}-klik crta pravu liniju + Grid Size: Veličina mreže: + Vanishing Point: click four corners to create a plane Tačka nestajanja: kliknite četiri ugla da napravite ravan + Vanishing Point is not active Tačka nestajanja nije aktivna + Apply Vanishing Point ({key}) Primeni tačku nestajanja ({key}) + Fix the red plane first: its corners must form a convex quad Prvo ispravite crvenu ravan: njeni uglovi moraju činiti konveksan četvorougao +# Select and Mask view modes (#1754) + Onion Skin Prozirni prikaz + Marching Ants Pokretne crtice + On Black Na crnoj + On White Na beloj + On Layers Na slojevima + Show Edge Prikaži ivicu + Show Original Prikaži original + F cycles the views; hold X to see the image F smenjuje prikaze; držite X da vidite sliku +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Četkica za doterivanje ivice (R) + Expand Detection Area Proširi oblast detekcije + Restore Original Edge Vrati originalnu ivicu + Paint over hair or fur on the image; Alt paints the other mode Slikajte preko kose ili krzna na slici; Alt slika u drugom režimu + Clear Brush Strokes Obriši poteze četkicom + Show rulers Prikaži lenjire + +# Color Sampler (added with the i18n coverage repair) + Click to place · Alt-click or drag off-canvas to remove Kliknite da postavite · Alt-klik ili prevlačenje van platna da uklonite + No document open Nijedan dokument nije otvoren + {n} of {max} sample points {n} od {max} tačaka uzorkovanja +# Print dialog (#2697) + Emulsion Down Emulzija nadole + Functions Funkcije + Print Resolution: Rezolucija štampe: + Use Previous Layer to Create Clipping Mask Koristi prethodni sloj za pravljenje maske isecanja + Fill with {mode}-neutral color (white) Popuni neutralnom bojom za {mode} (bela) + Fill with {mode}-neutral color (black) Popuni neutralnom bojom za {mode} (crna) + Fill with {mode}-neutral color (50% gray) Popuni neutralnom bojom za {mode} (50% siva) + (No neutral color exists for {mode} mode.) (Za režim {mode} ne postoji neutralna boja.) + Apply mask to layer before removing? Primeniti masku na sloj pre uklanjanja? + The layer or its mask changed. Drag the mask onto the Trash again. Sloj ili njegova maska su se promenili. Ponovo prevucite masku u kantu. + Delete the vector mask? Obrisati vektorsku masku? +# View › Guides › New Guide… › Color + Light Blue Svetloplava + Light Red Svetlocrvena + Medium Blue Srednje plava + Light Gray Svetlosiva + Remove after each stroke Ukloni posle svakog poteza + Apply the removal ({key}) Primeni uklanjanje ({key}) + Strokes to remove: {n} (Enter applies, Esc cancels) Potezi za uklanjanje: {n} (Enter primenjuje, Esc otkazuje) + +# Gradient Editor window, opened from the options bar's gradient swatch. + The current gradient (pick one in Window › Gradients) Tekući gradijent (izaberite ga u Prozor › Gradijenti) + +# Camera Raw: contextual controls and window title +cameraRaw Red Crvena +cameraRaw Orange Narandžasta +cameraRaw Yellow Žuta +cameraRaw Green Zelena +cameraRaw Aqua Akva +cameraRaw Blue Plava +cameraRaw Purple Ljubičasta +cameraRaw Magenta Magenta +cameraRaw Temperature Temperatura + +# Counted status messages and labels (Serbian: one | few | many) +@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Izvezen je {n} uzorak boje u {file}|Izvezena su {n} uzorka boja u {file}|Izvezeno je {n} uzoraka boja u {file} +@plural Imported {n} brush from {group}|Imported {n} brushes from {group} Uvezena je {n} četkica iz {group}|Uvezene su {n} četkice iz {group}|Uvezeno je {n} četkica iz {group} +@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Uvezen je {n} uzorak boje iz {file}|Uvezena su {n} uzorka boja iz {file}|Uvezeno je {n} uzoraka boja iz {file} +@plural Played {n} step|Played {n} steps Izvršen je {n} korak|Izvršena su {n} koraka|Izvršeno je {n} koraka +@plural Updated {n} linked smart object changed on disk|Updated {n} linked smart objects changed on disk Ažuriran je {n} povezani pametni objekat izmenjen na disku|Ažurirana su {n} povezana pametna objekta izmenjena na disku|Ažurirano je {n} povezanih pametnih objekata izmenjenih na disku +@plural Saved for Web: {n} file|Saved for Web: {n} files Sačuvano za veb: {n} datoteka|Sačuvano za veb: {n} datoteke|Sačuvano za veb: {n} datoteka +@plural {n} layer recorded by this comp was deleted. Update the comp, or clear the warning.|{n} layers recorded by this comp were deleted. Update the comp, or clear the warning. {n} sloj zabeležen u ovoj kompoziciji je obrisan. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} sloja zabeležena u ovoj kompoziciji su obrisana. Ažurirajte kompoziciju ili uklonite upozorenje.|{n} slojeva zabeleženih u ovoj kompoziciji je obrisano. Ažurirajte kompoziciju ili uklonite upozorenje. +@plural {n} plane|{n} planes {n} ravan|{n} ravni|{n} ravni +@plural {n} preset|{n} presets {n} šablon|{n} šablona|{n} šablona +@plural {n} sample point removed|{n} sample points removed Uklonjena je {n} tačka uzorkovanja|Uklonjene su {n} tačke uzorkovanja|Uklonjeno je {n} tačaka uzorkovanja +@plural {n} slice|{n} slices {n} isečak|{n} isečka|{n} isečaka +@plural {n} step|{n} steps {n} korak|{n} koraka|{n} koraka +@plural {warning} (+{n} more brush preset warning)|{warning} (+{n} more brush preset warnings) {warning} (+ još {n} upozorenje za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica)|{warning} (+ još {n} upozorenja za šablone četkica) +@plural {what} with {n} warning|{what} with {n} warnings {what} sa {n} upozorenjem|{what} sa {n} upozorenja|{what} sa {n} upozorenja + +# Dynamic labels (filter and adjustment parameters, Camera Raw, panels, dialogs) +@id edit.stroke Ocrtaj… +@id edit.transform.scale Skaliranje +cameraRaw Luminance Luminansa +cameraRaw Color Boja +cameraRaw Vibrance Živost +cameraRaw Color Grading Gradacija boja +cameraRaw Global Globalno +cameraRaw Hue Nijansa +cameraRaw Luminance Detail Detalji luminanse +cameraRaw Color Detail Detalji boje +cameraRaw Roughness Hrapavost +cameraRaw Whites Bele +layerLabel No Color Bez boje + Preferences… Podešavanja… + Actual Pixels Stvarni pikseli + Clipping Mask Maska isecanja + Tool Options Opcije alatke + Color Picker Birač boja + Color Swatches Uzorci boja + Linear Dodge Linearno posvetljivanje + Preview Image Pregled slike + Save As Sačuvaj kao + Remove All Ukloni sve + Unlock Otključaj + Enable Omogući + Disable Onemogući + On Uključeno + Clear Selection Ukloni selekciju + Invert Selection Invertuj selekciju + Select Inverse Izaberi inverzno + Contract Suzi + Sample Size Veličina uzorka + Strength Jačina + Gamma Gama + Input Ulaz + Input Levels Ulazni nivoi + Output Levels Izlazni nivoi + Layer Styles Stilovi slojeva + Blending Mode Režim stapanja + Fill Color Boja popune + Stroke Color Boja obruba + Stroke Width Širina obruba + Shadow Senka + Adjustment Layers Korekcioni slojevi + Text Layer Tekstualni sloj + Documents Dokumenti + File Name Ime datoteke + File Format Format datoteke + Compression Kompresija + Artboards Radne table + Snap to Grid Prianjaj uz mrežu + Full Screen Ceo ekran + Fullscreen Ceo ekran + Workspaces Radni prostori + Show All Prikaži sve + Hidden Skriveno + Duplicate Layer Dupliraj sloj + Ungroup Razgrupiši + Lock Transparent Pixels Zaključaj providne piksele + Lock Image Pixels Zaključaj piksele slike + Lock Position Zaključaj položaj + Lock All Zaključaj sve + Show Rulers Prikaži lenjire + Guide Layout… Raspored vodilja… + Page Setup… Podešavanje stranice… + Reset Preferences Resetuj podešavanja + Documentation Dokumentacija + Check for Updates Proveri ažuriranja + Loading… Učitavanje… + Processing… Obrađivanje… + Saving… Čuvanje… + Exporting… Izvoz u toku… + Please wait… Sačekajte… + No selection Nema selekcije + No layers Nema slojeva + No recent files Nema nedavnih datoteka + Unsupported file format Nepodržan format datoteke + Failed to open file Otvaranje datoteke nije uspelo + Failed to save file Čuvanje datoteke nije uspelo + Operation canceled Operacija je otkazana + Changes have not been saved. Izmene nisu sačuvane. + Are you sure? Da li ste sigurni? + Discard Changes Odbaci izmene + Recent Files Nedavne datoteke + Open File Otvori datoteku + Save File Sačuvaj datoteku + Save Changes Sačuvaj izmene + Rendering… Renderovanje… + Importing… Uvoz u toku… + Ready Spremno + Failed Neuspešno + Warning Upozorenje + Information Informacija + Retry Pokušaj ponovo + Continue Nastavi + Skip Preskoči + Resume Nastavi + Reset All Resetuj sve + Defaults Podrazumevano + Apply to All Primeni na sve + Confirm Potvrdi + Finish Završi + Back Nazad + Next Sledeće + Previous Prethodno + More Više + Less Manje + Manual Ručno + Disabled Onemogućeno + Locked Zaključano + Unlocked Otključano + Delete… Obriši… + Create… Napravi… + Edit… Uredi… + Change… Promeni… + Transformation Transformacija + Scaling Skaliranje + Canvas Size Veličina platna + Percentage Procenat + Degrees Stepeni + Middle Sredina + Diameter Prečnik + Intensity Intenzitet + Sensitivity Osetljivost + Softness Mekoća + Feather Radius Poluprečnik omekšavanja + Library Biblioteka + Layer Properties Svojstva sloja + Groups Grupe + Masks Maske + Add Mask Dodaj masku + Delete Mask Obriši masku + Invert Mask Invertuj masku + Enable Mask Omogući masku + Disable Mask Onemogući masku + Apply Mask Primeni masku + Import Settings Uvezi podešavanja + Export Settings Izvezi podešavanja + Shortcut Prečica + Shortcuts Prečice + Keyboard Tastatura + Mouse Miš + Tablet Tablet + Display Ekran + Panel Panel + Hide Grid Sakrij mrežu + Fit to Window Uklopi u prozor + Live Preview Pregled uživo + Before Pre + After Posle + Compare Uporedi + File Size Veličina datoteke + Filename Ime datoteke + Folder Fascikla + Open Folder Otvori fasciklu + Choose Folder Izaberi fasciklu + Browse Pregledaj + Find Pronađi + Replace Zameni + Date Datum + Learn More Saznajte više + Report a Bug Prijavi grešku + Updates Ažuriranja + Update Ažuriraj + Restart Required Potrebno je ponovno pokretanje + Restart Ponovo pokreni + Quit Izađi + Quit PhotoCraft Izađi iz PhotoCraft-a + Close Window Zatvori prozor + New Window Novi prozor + Open Image Otvori sliku + Save Image Sačuvaj sliku + Export Image Izvezi sliku + Import Image Uvezi sliku + Export As Izvezi kao + Export to Izvezi u + Export Options Opcije izvoza + Import Options Opcije uvoza + No Selection Nema selekcije + No Document Nema dokumenta + No Image Nema slike + No Layers Nema slojeva + No Results Nema rezultata + No Results Found Nije pronađen nijedan rezultat + Nothing to Undo Nema šta da se opozove + Nothing to Redo Nema šta da se ponovi + Changes Saved Izmene su sačuvane + Unsaved Changes Nesačuvane izmene + Save before closing? Sačuvati pre zatvaranja? + Are you sure you want to quit? Da li zaista želite da izađete? + The file could not be opened. Datoteka nije mogla da se otvori. + The file could not be saved. Datoteka nije mogla da se sačuva. + Unsupported format Nepodržan format + Out of memory Nema dovoljno memorije + Permission denied Pristup je odbijen + File not found Datoteka nije pronađena + Operation failed Operacija nije uspela + Operation completed Operacija je završena + Operation cancelled Operacija je otkazana + Sharpen Amount Količina izoštravanja + Sharpen Radius Poluprečnik izoštravanja + Sharpen Detail Detalji izoštravanja + Sharpen Masking Maskiranje izoštravanja + Noise Luminance Šum luminanse + Noise Luminance Detail Detalji šuma luminanse + Noise Color Šum boje + Noise Color Detail Detalji šuma boje + Grain Amount Količina zrna + Grain Size Veličina zrna + Grain Roughness Hrapavost zrna + Vignette Roundness Zaobljenost vinjete + Vignette Feather Omekšavanje vinjete + Vignette Highlights Svetla vinjete + Vignette Style Stil vinjete + Planes Ravni + Plane Ravan + Shadow Amount Količina senki + Shadow Tone Ton senki + Shadow Radius Poluprečnik senki + Highlight Amount Količina svetla + Highlight Tone Ton svetla + Highlight Radius Poluprečnik svetla + Midtone Srednji ton + Black Clip Odsecanje crne + White Clip Odsecanje bele + Fuzziness Rasplinutost + Source Layer Izvorni sloj + Neutralize Neutrališi + Use Selection In Source Koristi selekciju u izvoru + Use Selection In Target Koristi selekciju u cilju + Flame Type Vrsta plamena + Randomize Length Nasumična dužina + Interval Interval + Adjust Interval For Loops Prilagodi interval za petlje + Use Custom Color Koristi prilagođenu boju + Flame Lines Linije plamena + Flame Bottom Alignment Poravnanje dna plamena + Flame Style Stil plamena + Flame Shape Oblik plamena + Randomize Shapes Nasumični oblici + Frame Okvir + Arrangement Raspored + Thickness Debljina + Vine Color Boja loze + Flower Color Boja cveta + Leaf Color Boja lista + Base Tree Type Osnovna vrsta drveta + Light Direction Smer svetla + Leaves Amount Količina lišća + Leaves Size Veličina lišća + Branches Height Visina grana + Branches Thickness Debljina grana + Default Leaves Podrazumevano lišće + Leaves Color Boja lišća + Custom Branch Color Prilagođena boja grana + Branches Color Boja grana + Flat Shading Ravno senčenje + Date and Time Datum i vreme + Scale Units Jedinice razmere + Scale Factor Faktor razmere + Area Površina + Perimeter Obim + Circularity Kružnost + Gray Value (Minimum) Vrednost sive (minimum) + Gray Value (Maximum) Vrednost sive (maksimum) + Gray Value (Mean) Vrednost sive (srednja vrednost) + Gray Value (Median) Vrednost sive (medijana) + Integrated Density Integrisana gustina + Length Dužina + In Black Ulazna crna + In White Ulazna bela + Out Black Izlazna crna + Out White Izlazna bela + Tint Color Boja preliva + Stops Tačke + Center X Centar X + Center Y Centar Y + Distribution Raspodela + Monochromatic Jednobojno + Cell Size Veličina ćelije + Generators Generatori + Wavelength Min Talasna dužina, min. + Wavelength Max Talasna dužina, maks. + Amplitude Min Amplituda, min. + Amplitude Max Amplituda, maks. + Undefined Areas Nedefinisane oblasti + Preserve Očuvaj + Max Radius Maks. poluprečnik + Depth Mode Režim dubine + Solid Front Faces Pune prednje strane + Mask Incomplete Blocks Maskiraj nepotpune blokove + Stylization Stilizacija + Cleanliness Čistoća + Bristle Detail Detalji čekinja + Lighting Osvetljenje + Shine Sjaj + Max Offset Maks. pomak + Map Document Dokument mape + Map Path Putanja mape + Map Layer Sloj mape + Ridges Grebeni + Light Type Vrsta svetla + Light X Svetlo X + Light Y Svetlo Y + Light Z Svetlo Z + Target X Cilj X + Target Y Cilj Y + Hotspot Žarište + Elevation Elevacija + Ambient Ambijentalno + Warmth Toplina + Gloss Sjajnost + Metallic Metalnost + Ambience Ambijent + White Is High Bela je visoko + Reduce Color Noise Smanji šum boje + Sharpen Details Izoštri detalje + Remove Jpeg Artifact Ukloni JPEG artefakte + Blade Curvature Zakrivljenost lamela + Depth Map Mapa dubine + Focal Distance Žižna udaljenost + Focus Fokus + Transition Prelaz + Radius X Poluprečnik X + Radius Y Poluprečnik Y + Pins Čiode + Blur Angle Ugao zamućenja + Speed Brzina + Taper Sužavanje kraja + Start X Početak X + Start Y Početak Y + End X Kraj X + End Y Kraj Y + Kernel Jezgro + Input Mode Režim ulaza + Row Order Redosled redova + Eliminate Eliminiši + Create By Napravi pomoću + List Spisak + Pencil Width Širina olovke + Stroke Pressure Pritisak poteza + Paper Brightness Osvetljenost papira + Number Of Levels Broj nivoa + Edge Simplicity Jednostavnost ivica + Edge Fidelity Vernost ivica + Brush Detail Detalji četkice + Highlight Area Oblast svetla + Glow Size Veličina sjaja + Glow Brightness Osvetljenost sjaja + Brush Type Vrsta četkice + Stroke Size Veličina poteza + Stroke Detail Detalji poteza + Highlight Strength Jačina svetla + Smoothness Glatkoća + Edge Thickness Debljina ivica + Edge Intensity Intenzitet ivica + Posterization Posterizacija + Stroke Length Dužina poteza + Relief Reljef + Definition Definicija + Texture Coverage Pokrivenost teksturom + Shadow Intensity Intenzitet senki + Edge Width Širina ivica + Edge Brightness Osvetljenost ivica + Direction Balance Balans smera + Black Intensity Intenzitet crne + White Intensity Intenzitet bele + Dark Intensity Intenzitet tamnih tonova + Custom Pixel Aspect Ratio Prilagođeni odnos stranica piksela + 32-bit Preview Options Opcije 32-bitnog pregleda + New Guide Nova vodilja + Join the ArtCraft Discord Pridružite se ArtCraft Discord zajednici + Report an Issue Prijavi problem + Load Files into Stack Učitaj datoteke u stek + Statistics Statistika + Duplicate Layers Dupliraj slojeve + Edit Toolbar Uredi traku alatki + Import Brushes Uvezi četkice + Load 3D LUT Učitaj 3D LUT + New document Novi dokument + Press keys Pritisnite tastere + Rename Group Preimenuj grupu + Rename Tool Preset Preimenuj šablon alatke + Search commands, tools and panels Pretraži komande, alatke i panele + Slice Options Opcije isečka + Rename Brush Preimenuj četkicu + Opening Otvaranje + Reading Čitanje + Working Obrada + Accented Edges Naglašene ivice + Angled Strokes Kosi potezi + Anisotropic Anizotropno + Around Center Oko centra + Artistic Umetnički + Bamboo Bambus + Bas Relief Bareljef + Berries Bobice + Blast Udar + Bleach Bypass Preskočeno beljenje + Blocks Blokovi + Brick Cigla + Brush Strokes Potezi četkicom + Burlap Juta + Candle Light Svetlost sveće + Circle Krug + Clumped Zgrudvano + Coarse Dots Krupne tačke + Colored Pencil Bojica + Contrasty Kontrastno + Cool Hladno + Craquelure Krakelir + Cross Krst + Cross Process Unakrsna obrada + Crosshatch Unakrsno šrafiranje + Cutout Isečak od papira + Daisies Bele rade + Dark Rough Tamno grubo + Dark Strokes Tamni potezi + Darken Only Samo zatamni + Day For Night Dan za noć + Deep Blue Tamnoplava + Deep Emerald Tamni smaragd + Deep Red Tamnocrvena + Deep Yellow Tamnožuta + Dot Tačka + Double Line Dvostruka linija + Draft Nacrt + Dry Brush Suva četkica + Duplication Dupliranje + Edge Extension Proširenje ivica + Edge Only Samo ivice + Enlarged Uvećano + Even Fields Parni poluslikovi + Faded Film Izbledeli film + Film Grain Filmsko zrno + Fine Fino + Fine Dots Fine tačke + Flat Ravno + Fresco Freska + From Left Sleva + From Right Zdesna + Frosted Mat staklo + Generic Opšte + Glass Staklo + Glowing Edges Svetleće ivice + Grainy Dots Zrnaste tačke + Hearts Srca + Heptagon Sedmougao + Hexagon Šestougao + Horizontal Only Samo horizontalno + Infinite Beskonačno + Ink Outlines Obrisi mastilom + Ivy Bršljan + Left Diagonal Leva dijagonala + Level Based Prema nivoima + Light Rough Svetlo grubo + Lighten Only Samo posvetli + Long Lines Duge linije + Long Strokes Dugi potezi + Lower Donje + Medium Dots Srednje tačke + Medium Lines Srednje linije + Medium Strokes Srednji potezi + Mono Contrast Jednobojni kontrast + Mosaic Tiles Mozaik pločice + Multiple Flames Along Path Više plamenova duž putanje + Multiple Flames One Direction Više plamenova u jednom smeru + Multiple Flames Path Directions Više plamenova u smerovima putanje + Multiple Flames Various Length Više plamenova različite dužine + Octagon Osmougao + Odd Fields Neparni poluslikovi + One Flame Along Path Jedan plamen duž putanje + Out From Center Od centra ka spolja + Oval Oval + Overlay Edge Ivica preklopa + Palette Knife Slikarska špahtla + Parallel Paralelno + Patchwork Mozaik od krpica + Pentagon Petougao + Point Tačka + Pointed Šiljato + Pond Ripples Talasići na jezeru + Pyramids Piramide + Random Nasumično + Right Diagonal Desna dijagonala + Rope Konopac + Roses Ruže + Sandstone Peščar + Scallops Festoni + Sepia Sepija + Short Lines Kratke linije + Short Strokes Kratki potezi + Simple Jednostavno + Light Intensity Intenzitet svetla + Spray Radius Poluprečnik raspršivanja + Stroke Direction Smer poteza + Graininess Zrnastost + Glow Amount Količina sjaja + Clear Amount Količina čistoće + Distortion Izobličenje + Ripple Size Veličina talasića + Ripple Magnitude Jačina talasića + Charcoal Area Oblast uglja + Chalk Area Oblast krede + Charcoal Thickness Debljina uglja + Light Dark Balance Balans svetlog i tamnog + Foreground Level Nivo prednjeg plana + Background Level Nivo pozadine + Pattern Type Vrsta šare + Image Balance Balans slike + Darkness Tama + Fiber Length Dužina vlakana + Crack Spacing Razmak pukotina + Crack Depth Dubina pukotina + Crack Brightness Osvetljenost pukotina + Grain Type Vrsta zrna + Tile Size Veličina pločice + Grout Width Širina fuge + Lighten Grout Posvetli fugu + Square Size Veličina kvadrata + Border Thickness Debljina oboda + Correct Distortion Ispravi izobličenje + Correct Vignette Ispravi vinjetiranje + Correct C A Ispravi hromatsku aberaciju + Auto Scale Automatsko skaliranje + Red Cyan Crvena–cijan + Blue Yellow Plava–žuta + Vignette Amount Količina vinjete + Vignette Midpoint Središte vinjete + Constraints Ograničenja + Curve Highlights Kriva: svetla + Curve Lights Kriva: svetli tonovi + Curve Darks Kriva: tamni tonovi + Curve Shadows Kriva: senke + Curve Splits Kriva: podele + Point Curve Tačkasta kriva + Point Curve Red Tačkasta kriva, crvena + Point Curve Green Tačkasta kriva, zelena + Point Curve Blue Tačkasta kriva, plava + Hsl Hue HSL nijansa + Hsl Sat HSL zasićenost + Hsl Lum HSL svetlina + Grade Shadows Gradacija: senke + Grade Midtones Gradacija: srednji tonovi + Grade Highlights Gradacija: svetla + Grade Global Gradacija: globalno + Grade Blending Gradacija: stapanje + Grade Balance Gradacija: balans + Sine Sinusoida + Sketch Skica + Snowflakes Pahuljice + Sparkle Iskrenje + Spatter Prskanje + Speckle Pegavost + Spin Okretanje + Sprinkles Mrvice + Squareness Četvrtastost + Stagger Smaknuto + Stained Glass Vitraž + Star Zvezda + Stars Zvezde + Stippled Tačkasto + Stretch Rastegni + Teal Orange Tirkizno-narandžasto + Tiny Lens Sićušni objektiv + To Center Ka centru + Unaltered Neizmenjeno + Underpainting Podslika + Underwater Pod vodom + Uniform Ravnomerno + Upper Gornje + Vertical Only Samo vertikalno + Vine With Flowers Loza sa cvećem + Vine With Leaves Loza sa lišćem + Violent Silovito + Warm Toplo + Watercolor Akvarel + Waves Talasi + Wide Blurry Široko zamućeno + Wide Sharp Široko oštro + Wrap Obmotavanje + Zigzag Cik-cak + Wetness Vlažnost + AI Integrations AI integracije + Add Noise Dodaj šum + Apply Data Set Primeni skup podataka + Apply Image Primeni sliku + Arbitrary Proizvoljno + Artboard from Group Radna tabla od grupe + Artboard from Layers Radna tabla od slojeva + Artboards to Files Radne table u datoteke + Artboards to PDF Radne table u PDF + Assign Profile Dodeli profil + At Na + Auto-Align Layers Automatski poravnaj slojeve + Auto-Blend Layers Automatski stopi slojeve + Batch Grupna obrada + Border Obod + Box Blur Pravougaono zamućenje + Calculations Proračuni + Camera Raw Camera Raw + Camera Raw Filter Camera Raw filter + Channel1 Kanal 1 + Channel2 Kanal 2 + Channel3 Kanal 3 + Channel4 Kanal 4 + Clone Kloniraj + Color Decontaminate Dekontaminacija boja + Color Halftone Polutonski raster u boji + Color Lookup Tables Tabele preslikavanja boja + Color Settings Podešavanja boja + Color Table Tabela boja + Conditional Mode Change Uslovna promena režima + Content-Aware Fill Popuna prema sadržaju + Convert to Linked Pretvori u povezani + Convert to Profile Pretvori u profil + Crystallize Kristalizacija + Data Podaci + Data Sets as Files Skupovi podataka kao datoteke + De-Interlace Uklanjanje preplitanja + Define Brush Preset Definiši šablon četkice + Define Custom Shape Definiši prilagođeni oblik + Define Pattern Definiši šaru + Defringe Ukloni rub + Diffuse Rasipanje + Diffuse Glow Difuzni sjaj + Displace Premeštanje + Do you want to save you made to “{name}” before quitting? Želite li da sačuvate izmene u „{name}“ pre izlaska? + Dust & Scratches Prašina i ogrebotine + Export Contents Izvezi sadržaj + Export Preferences Podešavanja izvoza + Export/Import Presets Izvoz/uvoz šablona + Extrude Istiskivanje + Fibers Vlakna + Field Blur Zamućenje polja + File Info Informacije o datoteci + Filter Gallery Galerija filtera + Find and Replace Text Pronađi i zameni tekst + Fit Image Uklopi sliku + Flame Plamen + Focus Area Oblast fokusa + Gaussian Blur Gausovo zamućenje + Global Light Globalno svetlo + Group from Layers Grupa od slojeva + HDR Toning HDR toniranje + High Pass Visokopropusni filter + Highlight Svetlo + Image Processor Obrada slika + Install Plug-in Instaliraj dodatak + Interpret Footage Tumači snimak + Iris Blur Iris zamućenje + Jag Nazubljenost + Label Oznaka + Layer Comps to Files Kompozicije slojeva u datoteke + Layer Content Options Opcije sadržaja sloja + Layer From Background Sloj od pozadine + Layer from Background Sloj od pozadine + Layers to Files Slojevi u datoteke + Legacy Staro + Lens Blur Zamućenje objektiva + Lens Correction Korekcija objektiva + Lens Flare Odsjaj objektiva + Lighting Effects Efekti osvetljenja + Liquify Tečno izobličavanje + Lock Layers Zaključaj slojeve + Lum Svet. + Match Color Uskladi boje + Merge to HDR Pro Spoji u HDR Pro + Mezzotint Mecotinta + Mosaic Mozaik + Motion Blur Zamućenje pokreta + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Izvorno prevlačenje i otpuštanje datoteka još nije podržano na Wayland-u. Koristite Datoteka › Otvori ili pokrenite PhotoCraft pod XWayland-om pomoću `WAYLAND_DISPLAY= photocraft`. + Neon Glow Neonski sjaj + New Guide Layout Novi raspored vodilja + New Video Layer from File Novi video sloj iz datoteke + Ocean Ripple Okeanski talasi + Oil Paint Uljana boja + Open As Otvori kao + Package Spakuj + Paint Daubs Mrlje boje + Path Blur Zamućenje duž putanje + Paths to Illustrator Putanje u Illustrator + Photomerge Spajanje fotografija + Picture Frame Ram za sliku + Pinch Skupljanje + Place Embedded Postavi ugrađeno + Place Linked Postavi povezano + Place Scale Marker Postavi oznaku razmere + Plastic Wrap Plastična folija + Pointillize Poentilizam + Polar Coordinates Polarne koordinate + Polar To Rectangular Iz polarnih u pravougle + Poster Edges Posterske ivice + Preset Manager Upravljač šablonima + Profile Profil + Radial Blur Radijalno zamućenje + Raw Defaults Podrazumevana Raw podešavanja + Rectangular To Polar Iz pravouglih u polarne + Reduce Noise Smanji šum + Relink to File Ponovo poveži sa datotekom + Render Video Renderuj video + Replace Color Zameni boju + Replace Contents Zameni sadržaj + Replace Footage Zameni snimak + Resolve Missing Fonts Razreši fontove koji nedostaju + Ripple Talasanje + Rough Pastels Grube pastele + Sat Zas. + Save a Copy Sačuvaj kopiju + Save for Web (Legacy) Sačuvaj za veb (staro) + Scale Effects Skaliraj efekte + Script Events Manager Upravljač događajima skripti + Search menus Pretraži menije + Seed Seme + Select and Mask Izaberi i maskiraj + Set Measurement Scale Postavi razmeru merenja + Shadows/Highlights Senke/svetla + Shape Blur Zamućenje oblikom + Shear Smicanje + Smart Blur Pametno zamućenje + Smart Sharpen Pametno izoštravanje + Smudge Stick Štapić za razmazivanje + Solid Color Puna boja + Spherize Sferizacija + Spin Blur Rotaciono zamućenje + Spot Reflektor + Sprayed Strokes Raspršeni potezi + Strokes Potezi + Sumi-e Sumi-e + Surface Blur Površinsko zamućenje + Texturizer Teksturizer + Tiles Pločice + Tilt-Shift Tilt-shift + Trace Contour Praćenje konture + Trap Trepovanje + Tree Drvo + Trim Odseci ivice + Turbulent Turbulentno + Twirl Uvrtanje + Unsharp Mask Maska neoštrine + Use File › Open to open an image. Koristite Datoteka › Otvori da otvorite sliku. + Vanishing Point Tačka nestajanja + Variable Data Sets Promenljivi skupovi podataka + Video Frames to Layers Video kadrovi u slojeve + WIA Support WIA podrška + Warp Text Krivljenje teksta + Wind Vetar + ZigZag Cik-cak + Vertical Type Tool Alatka za vertikalni tekst + About {app} O programu {app} + Hide {app} Sakrij {app} + Quit {app} Zatvori {app} + Services Usluge + Hide Others Sakrij ostale + Lut LUT + Cooling L B B Hlađenje (LBB) + Cooling80 Hlađenje (80) + Cooling82 Hlađenje (82) + Hsb HSB + Hsl HSL + Movie Prime Filmski fiksni objektiv + Prime105 Fiksni objektiv 105 + Prime35 Fiksni objektiv 35 + Rgb RGB + Warming L B A Zagrevanje (LBA) + Warming81 Zagrevanje (81) + Warming85 Zagrevanje (85) +@id filter.sharpen.sharpen Izoštri + Good Dobro + Best Najbolje + Adjustment Properties Svojstva korekcije + Default Foreground and Background Colors Podrazumevane boje prednjeg plana i pozadine + Move Layer Pomeri sloj + Rectangular Selection Pravougaona selekcija + Reorder Layer Promeni redosled sloja + Select Layer Izaberi sloj + Set Colors Postavi boje + Switch Foreground and Background Colors Zameni boje prednjeg plana i pozadine + Target Cilj + +# Path, channel, layer style, status and System Info labels the Czech catalog added + Make Work Path… Napravi radnu putanju… + Create Vector Mask Napravi vektorsku masku + Make Selection… Napravi selekciju… + Fill Path… Popuni putanju… + Clipping Path… Putanja isecanja… + Free Transform Path Slobodna transformacija putanje + Unite Shapes at Overlap Ujedini oblike na preklapanju + Subtract Shapes at Overlap Oduzmi oblike na preklapanju + Copy Fill Kopiraj popunu + Copy Complete Stroke Kopiraj kompletan obrub + Paste Fill Nalepi popunu + Paste Complete Stroke Nalepi kompletan obrub + Make Symmetry Path Napravi putanju simetrije + Disable Symmetry Path Onemogući putanju simetrije + Dark Gray Tamnosiva + Medium Gray Srednje siva + Select Custom Color… Izaberi prilagođenu boju… + Color Picker (Custom Canvas Color) Birač boja (prilagođena boja platna) + Duplicate Channel Dupliraj kanal + Delete Channel Obriši kanal + Rename Channel… Preimenuj kanal… + Merge Spot Channel Spoji kanal spot boje + Convert to Alpha Channel Pretvori u alfa kanal + Exit Quick Mask Izađi iz brze maske + New Channel… Novi kanal… + New Spot Channel… Novi kanal spot boje… + Split Channels Razdvoji kanale + Merge Channels… Spoji kanale… + Color Indicates Masked Areas Boja označava maskirane oblasti + Color Indicates Selected Areas Boja označava izabrane oblasti + ✓ Color Indicates Masked Areas ✓ Boja označava maskirane oblasti + ✓ Color Indicates Selected Areas ✓ Boja označava izabrane oblasti + Up Nagore + To Do + Shallow Plitko + Deep Duboko + Blend Interior Effects as Group Stapaj unutrašnje efekte kao grupu + Blend Clipped Layers as Group Stapaj isečene slojeve kao grupu + Transparency Shapes Layer Providnost oblikuje sloj + Layer Mask Hides Effects Maska sloja skriva efekte + Vector Mask Hides Effects Vektorska maska skriva efekte + General Blending Opšte stapanje + This Layer: Ovaj sloj: + Underlying Layer: Sloj ispod: + Nothing to paste: the clipboard holds no image Nema šta da se nalepi: u ostavi nema slike + Preferences saved Podešavanja su sačuvana + Invalid panel layout; restored the default dock Neispravan raspored panela; vraćen je podrazumevani raspored + Preferences were reset: {error} Podešavanja su resetovana: {error} + Recovered {name} Oporavljeno: {name} + Couldn't save preferences: {error}. Retrying… Čuvanje podešavanja nije uspelo: {error}. Ponovni pokušaj… + Autosave failed: {error} Automatsko čuvanje nije uspelo: {error} + Exported presets to {path} Šabloni su izvezeni u {path} + Ran script {name} Pokrenuta skripta {name} + Sent to the printer ({spooler}) Poslato na štampač ({spooler}) + Printed to {path} Odštampano u {path} + Exported {path} ({size}) Izvezeno: {path} ({size}) + Exported {path} Izvezeno: {path} + Added swatch {name} Dodat uzorak boje {name} + Step {step} ({id}) failed: {error} Korak {step} ({id}) nije uspeo: {error} + Could not rename the action: {error} Preimenovanje radnje nije uspelo: {error} + Action playback failed: {error} Izvršavanje radnje nije uspelo: {error} + Clone source set at {x}, {y} Izvor kloniranja postavljen na {x}, {y} + Color management: {error} Upravljanje bojama: {error} + Opened {url} Otvoreno: {url} + No custom shape "{name}": pick one in the options bar or Window › Shapes Nema prilagođenog oblika „{name}“: izaberite jedan na traci opcija ili u Prozor › Oblici + Saved a copy as {path} Kopija je sačuvana kao {path} + Exported contents to {path} Sadržaj je izvezen u {path} + {label} is not available: {reason} {label} nije dostupno: {reason} + Saved {path}; image assets: {n} in {dir} Sačuvano: {path}; slikovni resursi: {n} u {dir} + {message} (notes: {n}; {warning}) {message} (beleške: {n}; {warning}) + Preferences can't be saved Podešavanja ne mogu da se sačuvaju + PhotoCraft keeps retrying; until a save succeeds, preference changes are lost when it closes. PhotoCraft nastavlja da pokušava; dok čuvanje ne uspe, izmene podešavanja se gube pri zatvaranju. + GPU retry scheduled Zakazan je ponovni pokušaj sa GPU-om + Save your work and restart PhotoCraft to retry GPU acceleration. CPU rendering remains active for this session. Sačuvajte svoj rad i ponovo pokrenite PhotoCraft da biste ponovo pokušali GPU ubrzanje. Renderovanje procesorom ostaje aktivno do kraja ove sesije. + GPU device was lost; using the CPU renderer. GPU uređaj je izgubljen; koristi se renderovanje procesorom. + GPU error; using the CPU renderer. Greška GPU-a; koristi se renderovanje procesorom. + Couldn't open {name}: {error} Otvaranje {name} nije uspelo: {error} + Toggle the Brush Settings panel Prikaži/sakrij panel Podešavanja četkice + New Brush Preset Novi šablon četkice + Grid view Prikaz mreže sličica + List view Prikaz spiska + {verb} {mask} {layer} {verb} {mask} ({layer}) + Unlink Razveži + Link Poveži + layer mask masku sloja + vector mask vektorsku masku + Commit transform Potvrdi transformaciju + Cancel transform Otkaži transformaciju + Scroll menu up Pomeri meni nagore + Scroll menu down Pomeri meni nadole + This type layer must be rasterized before proceeding. Its text will no longer be editable. Rasterize the type? Ovaj tekstualni sloj mora da se rasterizuje pre nastavka. Njegov tekst više neće moći da se uređuje. Rasterizovati tekst? + This shape layer must be rasterized before proceeding. It will no longer be editable as a shape. Rasterize the shape? Ovaj sloj oblika mora da se rasterizuje pre nastavka. Više neće moći da se uređuje kao oblik. Rasterizovati oblik? + This Smart Object must be rasterized before proceeding. Its contents will no longer be editable. Rasterize the Smart Object? Ovaj pametni objekat mora da se rasterizuje pre nastavka. Njegov sadržaj više neće moći da se uređuje. Rasterizovati pametni objekat? + This fill layer must be rasterized before proceeding. Its fill content will no longer be editable. Rasterize the layer? Ovaj sloj popune mora da se rasterizuje pre nastavka. Njegov sadržaj popune više neće moći da se uređuje. Rasterizovati sloj? + This layer must be rasterized before proceeding. Ovaj sloj mora da se rasterizuje pre nastavka. + Color Picker (Background Color) Birač boja (boja pozadine) + Color Picker (Foreground Color) Birač boja (boja prednjeg plana) + Adwaita (Light) Adwaita (svetla) + Adwaita (Dark) Adwaita (tamna) + Breeze Light Breeze svetla + Breeze Dark Breeze tamna + Solarized Dark Solarized tamna + Open Recent is unavailable on the web Otvori nedavno nije dostupno na vebu + Graphics adapter: {adapter} Grafički adapter: {adapter} + Backend: {backend} ({type}) Pozadinski sistem: {backend} ({type}) + Driver: {driver} Upravljački program: {driver} + GPU backend preference: {backend} Željeni GPU pozadinski sistem: {backend} + Selected at launch: {backend} Izabrano pri pokretanju: {backend} + Image compositor: {processor} Kompozitor slike: {processor} + Fallback: {reason} Rezervno rešenje: {reason} + This session: {reason} Ova sesija: {reason} + Window renderer: {renderer} Renderer prozora: {renderer} + Software adapter Softverski adapter + Graphics adapter Grafički adapter + unknown nepoznato + unknown type nepoznata vrsta + auto automatski + Platform: {os} {arch} Platforma: {os} {arch} + previous reading kept; the last re-read failed: {reason} zadržano je prethodno očitavanje; poslednje ponovno očitavanje nije uspelo: {reason} + Monitor profile: {profile} Profil monitora: {profile} + Display profiles: {reason} Profili ekrana: {reason} + Monitor profile in use on {display}: {profile} Profil monitora u upotrebi na {display}: {profile} + Monitor profile in use: {profile} Profil monitora u upotrebi: {profile} + The canvas is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + The canvas on {display} is shown as sRGB. Choose a monitor profile in Edit › Color Settings. Platno na {display} se prikazuje kao sRGB. Izaberite profil monitora u Uređivanje › Podešavanja boja. + Display profile not used Profil ekrana se ne koristi + Monitor profile on {display} (main window): {profile} Profil monitora na {display} (glavni prozor): {profile} + Monitor profile on {display}: {profile} Profil monitora na {display}: {profile} + manual ručno + fallback rezervno + The document does not have an embedded colour profile. Working space: {working}. Dokument nema ugrađen profil boja. Radni prostor: {working}. + The document has an embedded colour profile that does not match the working space.\nEmbedded: {embedded}\nWorking: {working} Dokument ima ugrađen profil boja koji se ne slaže sa radnim prostorom.\nUgrađeni: {embedded}\nRadni: {working} + Importing {what} from {file}… Uvoz ({what}) iz {file}… + brushes četkice + gradients gradijenti +@plural Imported {n} brush from {file}|Imported {n} brushes from {file} Uvezena je {n} četkica iz {file}|Uvezene su {n} četkice iz {file}|Uvezeno je {n} četkica iz {file} +@plural Imported {n} gradient from {file}|Imported {n} gradients from {file} Uvezen je {n} gradijent iz {file}|Uvezena su {n} gradijenta iz {file}|Uvezeno je {n} gradijenata iz {file} + Data Set: {name} ({index}/{total}) Skup podataka: {name} ({index}/{total}) + (none) (nijedan) +navigator Zoom Uvećanje + Apply {option}: {state} Primeni {option}: {state} + on uključeno + off isključeno + This adjustment ({key}) is kept as imported and can't be edited yet. Ova korekcija ({key}) je zadržana onakva kakva je uvezena i još ne može da se uređuje. + Join the ArtCraft Discord ({url}) Pridružite se ArtCraft Discord zajednici ({url}) + {index} of {total} {index} od {total} + {shortcut} assigned. {shortcut} je dodeljena. + {shortcut} is already in use by {commands} and will be removed from it when you click OK. {shortcut} već koristi {commands} i biće uklonjena odatle kada kliknete U redu. + +# Command-id overrides where the plain key is also a dialog button +@id file.close Zatvori +@id layer.layerMask.apply Primeni +@id edit.clear Obriši + +# Layer colour labels distinct from channel and ink names +layerLabel Red Crvena +layerLabel Magenta Magenta + + Drag: draw freehand · Release on the start point: close Prevlačenje: crtanje slobodnom rukom · Otpuštanje na početnoj tački: zatvori + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels Prevucite okvir za opsecanje · prevlačenje unutra pomera · ivice menjaju veličinu ({ratio} odnos, {centre} centar) · razmaknica pomera tokom crtanja · {commit} potvrđuje · Esc otkazuje + +# Character, Paragraph and Glyphs panels + Kerning: Metrics, Optical or a value in 1/1000 em (Alt+←/→ at the caret) Kerning: metrički, optički ili vrednost u 1/1000 em (Alt+←/→ kod kursora) + Basic Latin Osnovna latinica + Latin 1 Latinica 1 + Latin Extended-A Proširena latinica A + Latin Extended-B Proširena latinica B + Greek Grčko pismo + Cyrillic Ćirilica + Numbers Brojevi + Currency Valute + Math Symbols Matematički simboli + Arrows Strelice + + Save Path Sačuvaj putanju + +# Camera Raw labels +cameraRaw Highlights Svetla +cameraRaw Shadows Senke + +# Keyboard Shortcuts and Menus editor + All shortcuts reset to Photoshop defaults. Sve prečice su vraćene na podrazumevane vrednosti Photoshop-a. + Temporary Privremeno + none nijedna + red crvena + orange narandžasta + yellow žuta + green zelena + blue plava + violet ljubičasta + gray siva + +# Color Settings dialog + Working Rgb Radni RGB + Working Cmyk Radni CMYK + Working Gray Radna siva + Policy Rgb Pravilo za RGB + Policy Cmyk Pravilo za CMYK + Policy Gray Pravilo za sivu + Convert Pretvori + Ask On Mismatch Pitaj pri neslaganju + Ask On Paste Pitaj pri lepljenju + Ask On Missing Pitaj kada profil nedostaje + Intent Način konverzije + Bpc Kompenzacija crne tačke + Blend Text Gamma Gama za stapanje teksta + Monitor Profile Profil monitora + Display-p3 Display P3 + Adobe-rgb-compat Adobe RGB (kompatibilno) + Prophoto-compat ProPhoto RGB (kompatibilno) + Linear-srgb Linearni sRGB + Rec2020 Rec. 2020 + Coated-cmyk Premazani CMYK + Sgray sGray + Gray-gamma-2.2 Siva gama 2.2 + + Import Photoshop brushes (.abr) Uvezi Photoshop četkice (.abr) + +# Camera Raw chrome, tone, curve, color mixer, grading, detail and effects +cameraRaw Effects Efekti +cameraRaw Exposure Ekspozicija +cameraRaw Contrast Kontrast +cameraRaw Texture Tekstura +cameraRaw Clarity Jasnoća +cameraRaw Dehaze Uklanjanje izmaglice +cameraRaw Saturation Zasićenost +cameraRaw Curve Kriva +cameraRaw Color Mixer Mešač boja +cameraRaw Midtones Srednji tonovi +cameraRaw Balance Balans +cameraRaw Detail Detalji +cameraRaw Radius Poluprečnik +cameraRaw Feather Omekšavanje +cameraRaw Midpoint Središte +cameraRaw Roundness Zaobljenost +cameraRaw Size Veličina + +# New Document, Home and Image Size + Default Photoshop Size Podrazumevana Photoshop veličina +document Orientation Orijentacija + +# Warp, Liquify, Perspective and Puppet + Arc Lower Donji luk + Arc Upper Gornji luk + Forward Warp Guranje napred + Twirl Clockwise Uvrtanje udesno + Twirl Counterclockwise Uvrtanje ulevo + Pucker Skupljanje + Bloat Naduvavanje + Push Left Guranje ulevo + Freeze Mask Zamrzni masku + Thaw Mask Odmrzni masku + Freeze Lasso Laso za zamrzavanje + Reconstruct All Rekonstruiši sve + Mask None Bez maske + +# Contextual labels +colorRange Invert Invertuj +layers Fill Popuna +layers Fill: Popuna: +shape Appearance Izgled + +# Added on merge with main (spelling, Camera Raw settings files, printing, Object Finder, brush +# controls, actions, gradient presets, Pen Auto Add/Delete) + Add to Dictionary Dodaj u rečnik + No Spelling Suggestions Nema predloga za pravopis + Save Settings… Sačuvaj podešavanja… + Load Settings… Učitaj podešavanja… + Save these settings as a Camera Raw .xmp file Sačuvaj ova podešavanja kao Camera Raw .xmp datoteku + Load settings from a Camera Raw .xmp file Učitaj podešavanja iz Camera Raw .xmp datoteke + Loaded Camera Raw settings from {file} Camera Raw podešavanja su učitana iz {file} + Loaded Camera Raw settings from {file} ({n} not supported) Camera Raw podešavanja su učitana iz {file} (nije podržano: {n}) + Manage submitted jobs in the system print queue Upravljajte poslatim poslovima u sistemskom redu za štampu + Pick Color From Image Uzmi boju sa slike + Drag to pan · Scroll to zoom · Double-click to fit Prevucite za pomeranje · Skrolujte za zumiranje · Dvaput kliknite za uklapanje + Object Finder Pronalazač objekata + No object found there — drag a rectangle around it Tu nije pronađen nijedan objekat — prevucite pravougaonik oko njega + Clear Brush Controls Obriši kontrole četkice + Reset All Locked Settings Vrati sva zaključana podešavanja + Copy Texture to Other Tools Kopiraj teksturu u druge alatke + Load Actions… Učitaj radnje… + Save Actions… Sačuvaj radnje… + {name} (Photoshop) {name} (Photoshop) + {message} ({note}) {message} ({note}) +@plural Loaded {n} action from {file}|Loaded {n} actions from {file} Učitana je {n} radnja iz {file}|Učitane su {n} radnje iz {file}|Učitano je {n} radnji iz {file} +@plural {n} step has no PhotoCraft equivalent yet|{n} steps have no PhotoCraft equivalent yet {n} korak još nema ekvivalent u PhotoCraftu|{n} koraka još nemaju ekvivalent u PhotoCraftu|{n} koraka još nema ekvivalent u PhotoCraftu +@plural Saved {n} action to {file}|Saved {n} actions to {file} Sačuvana je {n} radnja u {file}|Sačuvane su {n} radnje u {file}|Sačuvano je {n} radnji u {file} +@plural {n} step with no Photoshop equivalent left out|{n} steps with no Photoshop equivalent left out Izostavljen je {n} korak bez ekvivalenta u Photoshopu|Izostavljena su {n} koraka bez ekvivalenta u Photoshopu|Izostavljeno je {n} koraka bez ekvivalenta u Photoshopu + Insert Stop… Umetni zaustavljanje… + Save Set… Sačuvaj skup… + Message: Poruka: + Allow Continue Dozvoli nastavak + Show dialog during playback Prikaži dijalog tokom puštanja + Select an action first Prvo izaberite radnju + Action playback stopped Puštanje radnje je zaustavljeno + Step {step} is waiting for its dialog Korak {step} čeka svoj dijalog +@plural {n} action|{n} actions {n} radnja|{n} radnje|{n} radnji + Gradient Preset Options Opcije šablona gradijenta + Import .GRD Uvezi .GRD + Choose a gradient file (.grd). Izaberite datoteku gradijenta (.grd). + Auto Add/Delete Automatsko dodavanje/brisanje diff --git a/crates/ui-egui/src/i18n/system.rs b/crates/ui-egui/src/i18n/system.rs index 0b9118386a..49bc3bc79b 100644 --- a/crates/ui-egui/src/i18n/system.rs +++ b/crates/ui-egui/src/i18n/system.rs @@ -98,6 +98,8 @@ mod tests { (vec!["nl-BE", "fr-BE"], "nl"), (vec!["hu-HU", "en-US"], "hu"), (vec!["tr-TR", "en-US"], "tr"), + (vec!["sr-Latn-RS", "en-US"], "sr"), + (vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"), (vec![], "en"), ] { with_system_tags(&tags, || assert_eq!(system_lang().code(), expected)); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 58c96b4dc6..9c73eb094b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian native review; previously minor: Turkish catalog) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026 Per-language status of PhotoCraft's UI. How catalogs work and how to add one: [`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md), @@ -49,7 +49,9 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522), Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr` -(3,731 rows) also covers every measured key. +(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every +measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse +the Russian rule. Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and transform accessibility names, rasterization prompts, profile mismatch messages and preset @@ -82,3 +84,4 @@ engine localization or native review is complete. | 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent | | 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage | | 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed | +| 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs | diff --git a/docs/localization.md b/docs/localization.md index f05b261b36..36e09f8a55 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -27,6 +27,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | Nederlands | `nl` | one / other | | Magyar | `hu` | one form | | Tiếng Việt | `vi` | one form | +| Srpski | `sr` | one / few / many (teens use many) | Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is @@ -47,7 +48,8 @@ The existing preference store saves the selection for future launches. `auto` fo native system locale; an unsupported code follows the same fallback. Regional tags such as `fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the -Brazilian Portuguese catalog. +Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every +Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it. ### First launch and system language diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index f573b316f4..d325b2b9d6 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -17,6 +17,7 @@ es ru uk + sr cs fr id diff --git a/xtask/src/i18n_coverage.rs b/xtask/src/i18n_coverage.rs index 595fc65568..ee337c0b9b 100644 --- a/xtask/src/i18n_coverage.rs +++ b/xtask/src/i18n_coverage.rs @@ -582,6 +582,7 @@ mod tests { ("hu", "hu.tsv"), ("tr", "tr.tsv"), ("vi", "vi.tsv"), + ("sr", "sr.tsv"), ] { let catalog_path = root.join("crates/ui-egui/src/i18n").join(path); let text = fs::read_to_string(&catalog_path).unwrap_or_default(); From f5f0f73f25b22bd7c7941d02805fc51b5e8f9dfd Mon Sep 17 00:00:00 2001 From: "Victor S." <46823269+sssehnsuchttt@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:01:08 +0700 Subject: [PATCH 315/523] feat(ui): Linux caption buttons follow the desktop layout, style and icon theme (#4439) * feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). * feat: linux caption buttons follow the desktop: layout, style and icon theme The title bar's minimize/maximize/close buttons now match the desktop: - Layout and side: GNOME button-layout via the settings portal (live), KDE kdeglobals ButtonsOnLeft/Right, else gtk-3.0 settings.ini. - Style family (GNOME round, Breeze chevrons, Windows) follows the desktop, then the GTK theme. - Glyphs come from the icon theme's window-*-symbolic.svg (freedesktop lookup with Inherits and hicolor; unlisted theme directories such as symbolic/ui are searched lazily), rasterized with resvg at the real pixel size, recoloured and cached; missing icons fall back to the built-in vector glyphs. Override: PHOTOCRAFT_CAPTION_ICONS=|none. - Theme and portal work runs on a worker thread (10-22 ms per theme). --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 1 + apps/photocraft/Cargo.toml | 3 + apps/photocraft/src/appearance.rs | 2 +- apps/photocraft/src/caption_style.rs | 399 +++++++++++++++++++++++ apps/photocraft/src/icon_theme.rs | 329 +++++++++++++++++++ apps/photocraft/src/main.rs | 7 + contributors/people.toml | 3 + crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/panels.rs | 28 +- crates/ui-egui/src/titlebar.rs | 462 +++++++++++++++++++++++++-- 10 files changed, 1200 insertions(+), 41 deletions(-) create mode 100644 apps/photocraft/src/caption_style.rs create mode 100644 apps/photocraft/src/icon_theme.rs diff --git a/Cargo.lock b/Cargo.lock index a0499ca1c8..fa59477940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4220,6 +4220,7 @@ dependencies = [ "photocraft-raster", "photocraft-tablet", "photocraft-ui-egui", + "resvg", "rfd", "ron", "serde_json", diff --git a/apps/photocraft/Cargo.toml b/apps/photocraft/Cargo.toml index 57b537931c..52cb76dbe8 100644 --- a/apps/photocraft/Cargo.toml +++ b/apps/photocraft/Cargo.toml @@ -68,6 +68,9 @@ photocraft-cms = { workspace = true } x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr", "xkb"] } # Wayland desktop color selection through the existing XDG portal transport. zbus = "=5.19.0" +# Caption button glyphs from the desktop's icon theme (`caption_style.rs`): SVG paths only, no +# text or images. Already in Cargo.lock (photocraft-io); pinned exactly. +resvg = { version = "=0.45.1", default-features = false } # Already used by the portal stack; bounded waits let cancelling close the OS request. futures-lite = "=2.6.1" async-io = "=2.6.0" diff --git a/apps/photocraft/src/appearance.rs b/apps/photocraft/src/appearance.rs index efaf6ccdb5..aaae41a1f2 100644 --- a/apps/photocraft/src/appearance.rs +++ b/apps/photocraft/src/appearance.rs @@ -6,7 +6,7 @@ const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; const PORTAL_KEY: &str = "color-scheme"; #[cfg(target_os = "linux")] -fn portal_proxy() -> Option> { +pub(crate) fn portal_proxy() -> Option> { let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() } diff --git a/apps/photocraft/src/caption_style.rs b/apps/photocraft/src/caption_style.rs new file mode 100644 index 0000000000..5ee16f9f96 --- /dev/null +++ b/apps/photocraft/src/caption_style.rs @@ -0,0 +1,399 @@ +//! Linux: which desktop the custom title bar's caption buttons look like, and which buttons go on +//! which side (`photocraft_ui_egui::CaptionStyle`). GNOME gets libadwaita's buttons in the order of +//! its `button-layout` setting (read through the settings portal, and followed as it changes); KDE +//! gets Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's +//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order. +//! +//! `PHOTOCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and +//! `PHOTOCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found. + +#[cfg(target_os = "linux")] +use photocraft_ui_egui::{CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle}; + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Desktop { + Gnome, + Kde, + Other, +} + +/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and +/// the like count as GNOME. +#[cfg(target_os = "linux")] +fn desktop(var: &dyn Fn(&str) -> Option) -> Desktop { + for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] { + let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue }; + return if v.contains("gnome") { + Desktop::Gnome + } else if v.contains("kde") || v.contains("plasma") { + Desktop::Kde + } else { + Desktop::Other + }; + } + if var("KDE_FULL_SESSION").is_some() { + Desktop::Kde + } else if var("GNOME_DESKTOP_SESSION_ID").is_some() { + Desktop::Gnome + } else { + Desktop::Other + } +} + +/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK +/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons. +#[cfg(target_os = "linux")] +fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily { + match desktop { + Desktop::Gnome => CaptionFamily::Adwaita, + Desktop::Kde => CaptionFamily::Breeze, + Desktop::Other => { + let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase(); + if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) { + CaptionFamily::Adwaita + } else if theme.starts_with("breeze") { + CaptionFamily::Breeze + } else { + CaptionFamily::Generic + } + } + } +} + +/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`). +#[cfg(target_os = "linux")] +fn ini_value(text: &str, section: &str, key: &str) -> Option { + let mut inside = false; + for line in text.lines().map(str::trim) { + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + inside = name == section; + } else if inside + && let Some((k, v)) = line.split_once('=') + && k.trim() == key + { + return Some(v.trim().to_string()); + } + } + None +} + +/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a +/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's +/// defaults: the window menu on the left, minimize, maximize and close on the right. +#[cfg(target_os = "linux")] +fn kwin_layout(kwinrc: &str) -> String { + let side = |key: &str, default: &str| { + let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string()); + letters + .chars() + .filter_map(|c| match c { + 'I' => Some("minimize"), + 'A' => Some("maximize"), + 'X' => Some("close"), + _ => None, + }) + .collect::>() + .join(",") + }; + format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX")) +} + +/// What the style is worked out from. +#[cfg(target_os = "linux")] +#[derive(Clone, Default)] +struct Found { + gtk_theme: Option, + /// A GTK decoration layout. + layout: Option, + icon_theme: Option, +} + +/// The icon theme the glyphs come from: `PHOTOCRAFT_CAPTION_ICONS` (`none` draws PhotoCraft's own), +/// the desktop's setting, or its default theme. +#[cfg(target_os = "linux")] +fn icon_theme_name(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option) -> Option { + match var("PHOTOCRAFT_CAPTION_ICONS") { + Some(name) if name.trim().eq_ignore_ascii_case("none") => None, + Some(name) => Some(name.trim().to_string()), + None => Some(found.icon_theme.clone().unwrap_or_else(|| if desktop == Desktop::Kde { "breeze" } else { "Adwaita" }.into())), + } +} + +/// The style from what was found; `icons` loads a theme's glyphs. +#[cfg(target_os = "linux")] +fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option, icons: &dyn Fn(&str) -> Option) -> CaptionStyle { + let family = var("PHOTOCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref())); + // GNOME's own default shows Close only; GTK's shows all three. + let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" }; + let layout = var("PHOTOCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string()); + let mut style = CaptionStyle::from_gtk_layout(family, &layout); + style.icons = icon_theme_name(desktop, found, var).and_then(|name| icons(&name)); + style +} + +#[cfg(target_os = "linux")] +fn read_text(path: &std::path::Path) -> Option { + crate::icon_theme::read_small(path).map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +/// The themes and layout from the desktop's files: `kwinrc` and `kdeglobals` on KDE, GTK 3's +/// `settings.ini` elsewhere (where GNOME's portal answers, read later, replace them). +#[cfg(target_os = "linux")] +fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option) -> Found { + let config = var("XDG_CONFIG_HOME") + .filter(|d| std::path::Path::new(d).is_absolute()) + .map(std::path::PathBuf::from) + .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config"))); + let Some(config) = config else { return Found::default() }; + let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default(); + let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name"); + if desktop == Desktop::Kde { + let layout = Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default())); + let icon_theme = ini_value(&read_text(&config.join("kdeglobals")).unwrap_or_default(), "Icons", "Theme"); + Found { gtk_theme, layout, icon_theme } + } else { + let layout = ini_value(>k, "Settings", "gtk-decoration-layout"); + Found { gtk_theme, layout, icon_theme: ini_value(>k, "Settings", "gtk-icon-theme-name") } + } +} + +/// The caption glyphs of icon theme `theme` (`window-*-symbolic.svg`), read now and rendered at +/// whatever size the bar asks for. `None` when the theme (with its parents) has none of them. +#[cfg(target_os = "linux")] +fn load_icons(theme: &str) -> Option { + use crate::icon_theme::{IconTheme, base_dirs, rasterize_mask, read_small}; + let found = IconTheme::load(theme, &base_dirs(&|k: &str| std::env::var(k).ok()))?; + let svg = |name: &str| found.find_svg(name).and_then(|p| read_small(&p)).map(std::sync::Arc::<[u8]>::from); + let files = [svg("window-minimize-symbolic"), svg("window-maximize-symbolic"), svg("window-restore-symbolic"), svg("window-close-symbolic")]; + if files.iter().all(Option::is_none) { + return None; + } + let render = move |icon: CaptionIcon, px: u32| { + let i = match icon { + CaptionIcon::Minimize => 0, + CaptionIcon::Maximize => 1, + CaptionIcon::Restore => 2, + CaptionIcon::Close => 3, + }; + files.get(i)?.as_deref().and_then(|svg| rasterize_mask(svg, px)) + }; + Some(CaptionIcons { theme: theme.to_string(), render: std::sync::Arc::new(render) }) +} + +/// [`load_icons`] for the last theme asked for, kept, so a layout change doesn't reread it. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct IconCache(std::sync::Mutex)>>); + +#[cfg(target_os = "linux")] +impl IconCache { + fn get(&self, theme: &str) -> Option { + let mut last = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((name, icons)) = last.as_ref() + && name == theme + { + return icons.clone(); + } + let icons = load_icons(theme); + *last = Some((theme.to_string(), icons.clone())); + icons + } +} + +#[cfg(target_os = "linux")] +const WM_NAMESPACE: &str = "org.gnome.desktop.wm.preferences"; +#[cfg(target_os = "linux")] +const LAYOUT_KEY: &str = "button-layout"; +#[cfg(target_os = "linux")] +const INTERFACE_NAMESPACE: &str = "org.gnome.desktop.interface"; +#[cfg(target_os = "linux")] +const ICONS_KEY: &str = "icon-theme"; + +/// A string setting from the portal, unwrapping the variants some portals nest it in. +#[cfg(target_os = "linux")] +fn portal_string(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + zbus::zvariant::Value::Str(s) => return Some(s.to_string()), + _ => return None, + } + } + None +} + +/// The caption style now, then with the icon theme's glyphs and (GNOME) the portal's settings, +/// following the button layout and icon theme as they change. A worker does the file and D-Bus +/// work and wakes the UI only when the style changes; the first answer usually lands before the +/// first frame. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{Arc, Mutex, OnceLock, PoisonError}; + let var = |k: &str| std::env::var(k).ok(); + let desktop = desktop(&var); + let mut found = from_files(desktop, &var); + // Icons need the theme read, which takes tens of milliseconds: the worker brings them. + let current = Arc::new(Mutex::new(resolve(desktop, &found, &var, &|_: &str| None))); + let wake: Arc> = Arc::new(OnceLock::new()); + let (shared, waker) = (Arc::clone(¤t), Arc::clone(&wake)); + let cache = IconCache::default(); + let publish = move |found: &Found| { + let style = resolve(desktop, found, &|k: &str| std::env::var(k).ok(), &|t: &str| cache.get(t)); + let mut cur = shared.lock().unwrap_or_else(PoisonError::into_inner); + if *cur != style { + *cur = style; + if let Some(ctx) = waker.get() { + ctx.request_repaint(); + } + } + }; + let (ready, wait) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("caption-style".into()).spawn(move || { + let proxy = if desktop == Desktop::Gnome { crate::appearance::portal_proxy() } else { None }; + let Some(proxy) = proxy else { + publish(&found); + let _ = ready.send(()); + return; + }; + // Subscribe before the first read, so a change during start-up isn't lost. + let signals = proxy.receive_signal("SettingChanged").ok(); + let read = |namespace: &str, key: &str| { + let value: Option = proxy.call("Read", &(namespace, key)).ok(); + value.and_then(portal_string) + }; + found.layout = read(WM_NAMESPACE, LAYOUT_KEY).or(found.layout.take()); + found.icon_theme = read(INTERFACE_NAMESPACE, ICONS_KEY).or(found.icon_theme.take()); + publish(&found); + let _ = ready.send(()); + for message in signals.into_iter().flatten() { + let Ok((namespace, key, value)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { continue }; + match (namespace.as_str(), key.as_str()) { + (WM_NAMESPACE, LAYOUT_KEY) => found.layout = portal_string(value), + (INTERFACE_NAMESPACE, ICONS_KEY) => found.icon_theme = portal_string(value), + _ => continue, + } + publish(&found); + } + }); + // A fast desktop answers before the first frame; a slow one updates the bar later. + if spawned.is_ok() { + let _ = wait.recv_timeout(std::time::Duration::from_millis(150)); + } + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + current.lock().unwrap_or_else(PoisonError::into_inner).clone() + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use photocraft_ui_egui::Caption::{Close, Maximize, Minimize}; + + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(); + move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone()) + } + + #[test] + fn the_desktop_comes_from_the_first_variable_set() { + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other); + assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde); + assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome); + assert_eq!(desktop(&env(&[])), Desktop::Other); + } + + #[test] + fn the_family_follows_the_desktop_then_the_gtk_theme() { + assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita); + assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze); + assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic); + assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic); + } + + #[test] + fn kwin_button_letters_become_a_gtk_layout() { + assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults"); + let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n"; + assert_eq!(kwin_layout(rc), "close,minimize,maximize:"); + assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze")); + assert_eq!(ini_value(rc, "nope", "library"), None); + // Garbage is just no buttons on that side, never a panic. + assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close"); + } + + fn no_icons(_: &str) -> Option { + None + } + + #[test] + fn the_icon_theme_comes_from_the_override_the_setting_or_the_desktop() { + let found = Found { icon_theme: Some("Papirus".into()), ..Found::default() }; + assert_eq!(icon_theme_name(Desktop::Gnome, &found, &env(&[])).as_deref(), Some("Papirus")); + assert_eq!(icon_theme_name(Desktop::Gnome, &Found::default(), &env(&[])).as_deref(), Some("Adwaita")); + assert_eq!(icon_theme_name(Desktop::Kde, &Found::default(), &env(&[])).as_deref(), Some("breeze")); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", " None ")])), None); + assert_eq!(icon_theme_name(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_ICONS", "Tela")])).as_deref(), Some("Tela")); + // The style carries what the loader returns for that name. + let s = resolve(Desktop::Gnome, &found, &env(&[]), &|t| { + (t == "Papirus").then(|| CaptionIcons { theme: t.into(), render: std::sync::Arc::new(|_, _| None) }) + }); + assert_eq!(s.icons.map(|i| i.theme).as_deref(), Some("Papirus")); + } + + #[test] + fn layouts_and_overrides_resolve_to_a_style() { + let none = env(&[]); + let gnome = resolve(Desktop::Gnome, &Found::default(), &none, &no_icons); + assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout"); + let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none, &no_icons); + assert_eq!((kde.family, kde.right), (CaptionFamily::Breeze, vec![Minimize, Maximize, Close])); + let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()), icon_theme: None }; + let other = resolve(Desktop::Other, &found, &none, &no_icons); + assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![])); + let forced = env(&[("PHOTOCRAFT_CAPTION_STYLE", "Breeze"), ("PHOTOCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]); + let s = resolve(Desktop::Gnome, &found, &forced, &no_icons); + assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize])); + // An unknown override name falls back to what was found. + assert_eq!(resolve(Desktop::Kde, &found, &env(&[("PHOTOCRAFT_CAPTION_STYLE", "aqua")]), &no_icons).family, CaptionFamily::Breeze); + } + + #[test] + fn portal_strings_unwrap_nested_variants() { + use zbus::zvariant::{OwnedValue, Value}; + let nested = Value::Value(Box::new(Value::Value(Box::new(Value::from("appmenu:close"))))); + assert_eq!(portal_string(OwnedValue::try_from(nested).unwrap()).as_deref(), Some("appmenu:close")); + assert_eq!(portal_string(OwnedValue::try_from(Value::U32(7)).unwrap()), None); + } + + #[test] + fn settings_files_are_read_from_the_config_dir() { + let dir = std::env::temp_dir().join(format!("photocraft-caption-{}", std::process::id())); + std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap(); + std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap(); + std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap(); + std::fs::write(dir.join("kdeglobals"), "[Icons]\nTheme=breeze-dark\n").unwrap(); + let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]); + let other = from_files(Desktop::Other, &var); + assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:"))); + let kde = from_files(Desktop::Kde, &var); + assert_eq!((kde.layout.as_deref(), kde.icon_theme.as_deref()), (Some(":maximize,close"), Some("breeze-dark"))); + // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found. + assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/apps/photocraft/src/icon_theme.rs b/apps/photocraft/src/icon_theme.rs new file mode 100644 index 0000000000..cf2ede3b7b --- /dev/null +++ b/apps/photocraft/src/icon_theme.rs @@ -0,0 +1,329 @@ +//! Linux: icons from the desktop's freedesktop icon theme (the Icon Theme Specification), for the +//! caption buttons' glyphs (`caption_style.rs`). A theme is searched, then the themes it inherits, +//! then `hicolor`; within each, its directories from the best fit for a 16 px icon down. Only SVG +//! files are used: symbolic icons are drawn as a coverage mask in the button's ink colour. + +use std::path::{Path, PathBuf}; + +/// Bounds on what a theme's files can make us do: themes in an inheritance chain, directories a +/// theme lists, and the bytes read from one file. +const MAX_THEMES: usize = 32; +const MAX_DIRS: usize = 4096; +/// How deep an index's unlisted directories are looked for (`symbolic/ui`, `16x16@2/actions`). +const SCAN_DEPTH: usize = 3; +const MAX_FILE: u64 = 1 << 20; +/// The size the caption glyphs are drawn at, in the spec's units. +const ICON_SIZE: i64 = 16; + +/// A theme or icon name that stays inside its directory. +pub fn valid_name(name: &str) -> bool { + !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) +} + +/// At most [`MAX_FILE`] bytes of a file (settings, theme indexes and icons are small). +pub fn read_small(path: &Path) -> Option> { + use std::io::Read; + let mut bytes = vec![]; + std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_end(&mut bytes).ok()?; + Some(bytes) +} + +/// Where themes live, in the spec's order: `~/.icons`, `$XDG_DATA_HOME/icons`, then each +/// `$XDG_DATA_DIRS/icons`. Relative paths in the variables are ignored, as the XDG spec says. +pub fn base_dirs(var: &dyn Fn(&str) -> Option) -> Vec { + let absolute = |p: &String| Path::new(p).is_absolute(); + let home = var("HOME").filter(absolute).map(PathBuf::from); + let mut dirs = vec![]; + if let Some(home) = &home { + dirs.push(home.join(".icons")); + } + if let Some(data) = var("XDG_DATA_HOME").filter(absolute).map(PathBuf::from).or_else(|| home.map(|h| h.join(".local").join("share"))) { + dirs.push(data.join("icons")); + } + let data_dirs = var("XDG_DATA_DIRS").filter(|d| !d.trim().is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(data_dirs.split(':').filter(|d| Path::new(d).is_absolute()).map(|d| Path::new(d).join("icons"))); + dirs +} + +/// One `[section]` of an `index.theme`, as (key, value) pairs. +fn section<'a>(index: &'a str, name: &str) -> Vec<(&'a str, &'a str)> { + let mut inside = false; + let mut pairs = vec![]; + for line in index.lines().map(str::trim) { + if let Some(s) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + if inside { + break; + } + inside = s == name; + } else if inside && let Some((k, v)) = line.split_once('=') { + pairs.push((k.trim(), v.trim())); + } + } + pairs +} + +fn list(value: Option<&str>) -> impl Iterator { + value.unwrap_or_default().split(',').map(str::trim).filter(|s| !s.is_empty()) +} + +/// How far a theme directory is from a 16 px icon at scale 1 (the spec's +/// `DirectorySizeDistance`), then its scale: 0 is an exact fit. +fn distance(keys: &[(&str, &str)]) -> (i64, i64) { + let get = |k: &str| keys.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let num = |k: &str, default: i64| get(k).and_then(|v| v.parse::().ok()).unwrap_or(default).clamp(0, 1 << 16); + let size = num("Size", 0); + let scale = num("Scale", 1).max(1); + let d = match get("Type").unwrap_or("Threshold") { + "Fixed" => (size * scale - ICON_SIZE).abs(), + "Scalable" => { + let (min, max) = (num("MinSize", size) * scale, num("MaxSize", size) * scale); + (min - ICON_SIZE).max(ICON_SIZE - max).max(0) + } + _ => ((size * scale - ICON_SIZE).abs() - num("Threshold", 2) * scale).max(0), + }; + (d, scale) +} + +/// Every directory under `root`, at most [`SCAN_DEPTH`] levels down and [`MAX_DIRS`] in all, in +/// path order: scalable and symbolic ones first, as icons there fit any size. +fn subdirectories(root: &Path) -> Vec { + let mut found = vec![]; + let mut level = vec![root.to_path_buf()]; + for _ in 0..SCAN_DEPTH { + let mut next = vec![]; + for dir in &level { + let Ok(entries) = std::fs::read_dir(dir) else { continue }; + for entry in entries.flatten().take(MAX_DIRS) { + let path = entry.path(); + if found.len() + next.len() < MAX_DIRS && path.is_dir() { + next.push(path); + } + } + } + found.extend(next.iter().cloned()); + level = next; + } + let vector = |p: &PathBuf| !p.components().any(|c| matches!(c.as_os_str().to_str(), Some("scalable" | "symbolic"))); + found.sort_by_key(|p| (vector(p), p.clone())); + found +} + +/// One theme of a chain: its listed directories, best fit first, in every base directory that has +/// the theme, and (looked for only when an icon isn't in those) the directories its index forgot. +struct Theme { + roots: Vec, + listed: Vec, + unlisted: std::cell::OnceCell>, +} + +impl Theme { + /// The directories the index forgot to list: GTK still finds icons there, and custom themes + /// often add `symbolic/ui` without listing it. + fn unlisted(&self) -> &[PathBuf] { + self.unlisted.get_or_init(|| { + let listed: std::collections::HashSet<&PathBuf> = self.listed.iter().collect(); + self.roots.iter().flat_map(|r| subdirectories(r)).filter(|d| !listed.contains(d)).collect() + }) + } +} + +/// A theme and the themes it inherits, in search order. +pub struct IconTheme { + themes: Vec, +} + +impl IconTheme { + /// `name` and the themes it inherits, then `hicolor`. `None` when no theme in the chain has an + /// `index.theme`. + pub fn load(name: &str, bases: &[PathBuf]) -> Option { + let mut chain: Vec = vec![name.to_string()]; + let mut themes = vec![]; + let mut i = 0; + loop { + let Some(theme) = chain.get(i).cloned() else { + // Every chain ends in hicolor, whether or not the themes before it exist. + if chain.iter().any(|t| t == "hicolor") { + break; + } + chain.push("hicolor".into()); + continue; + }; + i += 1; + if !valid_name(&theme) { + continue; + } + let roots: Vec = bases.iter().map(|b| b.join(&theme)).filter(|r| r.is_dir()).collect(); + // The first index wins; a theme can still be spread over several base directories. + let Some(index) = roots.iter().find_map(|r| read_small(&r.join("index.theme"))) else { continue }; + let index = String::from_utf8_lossy(&index); + let head = section(&index, "Icon Theme"); + let get = |k: &str| head.iter().find(|(key, _)| *key == k).map(|(_, v)| *v); + let mut subdirs: Vec<((i64, i64), &str)> = list(get("Directories")) + .chain(list(get("ScaledDirectories"))) + .filter(|d| !d.split('/').any(|part| part == ".." || part.is_empty())) + .take(MAX_DIRS) + .map(|d| (distance(§ion(&index, d)), d)) + .collect(); + subdirs.sort_by_key(|(key, _)| *key); + // Not checked for existence: a theme lists hundreds, and the search stops at a hit. + let listed = subdirs.iter().flat_map(|(_, sub)| roots.iter().map(move |r| r.join(sub))).collect(); + for parent in list(get("Inherits")) { + if chain.len() < MAX_THEMES && !chain.iter().any(|t| t == parent) { + chain.push(parent.to_string()); + } + } + themes.push(Theme { roots, listed, unlisted: std::cell::OnceCell::new() }); + } + (!themes.is_empty()).then_some(Self { themes }) + } + + /// The first `.svg` in search order: each theme's listed directories, then its unlisted + /// ones, before its parent. + pub fn find_svg(&self, icon: &str) -> Option { + if !valid_name(icon) { + return None; + } + let file = format!("{icon}.svg"); + let hit = |dirs: &[PathBuf]| dirs.iter().map(|d| d.join(&file)).find(|p| p.is_file()); + self.themes.iter().find_map(|t| hit(&t.listed).or_else(|| hit(t.unlisted()))) + } +} + +/// An SVG's coverage (alpha) as a `px` × `px` mask, the drawing fitted and centred. `None` for an +/// unreadable SVG or an absurd size. +pub fn rasterize_mask(svg: &[u8], px: u32) -> Option> { + use resvg::{tiny_skia, usvg}; + if !(1..=256).contains(&px) { + return None; + } + // Glyphs are paths: no `` is loaded, so an icon can't make us read another file + // (`/dev/zero`, a huge picture) or nest an SVG. + let opts = usvg::Options { + image_href_resolver: usvg::ImageHrefResolver { resolve_data: Box::new(|_, _, _| None), resolve_string: Box::new(|_, _| None) }, + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_data(svg, &opts).ok()?; + let size = tree.size(); + let side = size.width().max(size.height()); + if !side.is_finite() || side <= 0.0 { + return None; + } + let s = px as f32 / side; + let (dx, dy) = ((px as f32 - size.width() * s) / 2.0, (px as f32 - size.height() * s) / 2.0); + let mut pixmap = tiny_skia::Pixmap::new(px, px)?; + resvg::render(&tree, tiny_skia::Transform::from_scale(s, s).post_translate(dx, dy), &mut pixmap.as_mut()); + Some(pixmap.data().as_chunks::<4>().0.iter().map(|[_, _, _, a]| *a).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A throwaway icon tree: `files` are (path under the base, contents). + fn tree(tag: &str, files: &[(&str, &str)]) -> PathBuf { + let base = std::env::temp_dir().join(format!("photocraft-icons-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for (path, text) in files { + let p = base.join(path); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + } + base + } + + const SQUARE: &str = r##""##; + + #[test] + fn icons_come_from_the_theme_then_its_parents_then_hicolor_best_size_first() { + let base = tree( + "chain", + &[ + ("Child/index.theme", "[Icon Theme]\nInherits=Parent, Child\nDirectories=48,16\n[48]\nSize=48\nType=Fixed\n[16]\nSize=16\nType=Fixed\n"), + ("Child/48/a-symbolic.svg", "big"), + // Not in the index: still found, and before the parent's copy. + ("Child/symbolic/ui/d-symbolic.svg", SQUARE), + ("Parent/ui/d-symbolic.svg", SQUARE), + ("Child/16/a-symbolic.svg", SQUARE), + ("Parent/index.theme", "[Icon Theme]\nDirectories=ui\n[ui]\nSize=16\nMinSize=8\nMaxSize=512\nType=Scalable\n"), + ("Parent/ui/b-symbolic.svg", SQUARE), + ("hicolor/index.theme", "[Icon Theme]\nDirectories=s\n[s]\nSize=16\nType=Scalable\n"), + ("hicolor/s/c-symbolic.svg", SQUARE), + ], + ); + let theme = IconTheme::load("Child", std::slice::from_ref(&base)).expect("the theme loads"); + assert_eq!(theme.find_svg("a-symbolic"), Some(base.join("Child/16/a-symbolic.svg")), "16 px before 48 px"); + assert_eq!(theme.find_svg("b-symbolic"), Some(base.join("Parent/ui/b-symbolic.svg"))); + assert_eq!(theme.find_svg("c-symbolic"), Some(base.join("hicolor/s/c-symbolic.svg"))); + assert_eq!(theme.find_svg("d-symbolic"), Some(base.join("Child/symbolic/ui/d-symbolic.svg")), "unlisted, but the theme's own"); + assert_eq!(theme.find_svg("missing"), None); + assert_eq!(theme.find_svg("../Parent/ui/b-symbolic"), None); + assert!(IconTheme::load("Nowhere", std::slice::from_ref(&base)).is_some(), "hicolor still answers"); + assert!(IconTheme::load("Nowhere", &[base.join("empty")]).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + /// Hostile theme files: cycles, escapes and junk sizes end the search, never hang or panic. + #[test] + fn cyclic_or_hostile_indexes_are_harmless() { + let base = tree( + "hostile", + &[ + ( + "A/index.theme", + "[Icon Theme]\nInherits=B,../etc,\nDirectories=../../x,,/abs,ok\n[ok]\nSize=99999999999999999999\nScale=-3\nType=Threshold\n", + ), + ("A/ok/x.svg", SQUARE), + ("B/index.theme", "[Icon Theme]\nInherits=A\n"), + ], + ); + let theme = IconTheme::load("A", std::slice::from_ref(&base)).expect("loads"); + assert_eq!(theme.find_svg("x"), Some(base.join("A/ok/x.svg"))); + assert!(IconTheme::load("..", std::slice::from_ref(&base)).is_none()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn distance_follows_the_spec() { + assert_eq!(distance(&[("Size", "16"), ("Type", "Fixed")]), (0, 1)); + assert_eq!(distance(&[("Size", "22"), ("Type", "Fixed")]), (6, 1)); + assert_eq!(distance(&[("Size", "16"), ("Scale", "2"), ("Type", "Fixed")]), (16, 2)); + assert_eq!(distance(&[("Size", "32"), ("MinSize", "8"), ("MaxSize", "512"), ("Type", "Scalable")]), (0, 1)); + assert_eq!(distance(&[("Size", "48"), ("Type", "Scalable")]), (32, 1)); + assert_eq!(distance(&[("Size", "18")]), (0, 1), "Threshold 2 by default"); + } + + #[test] + fn masks_are_square_coverage() { + let mask = rasterize_mask(SQUARE.as_bytes(), 32).expect("renders"); + assert_eq!(mask.len(), 32 * 32); + assert_eq!(mask[16 * 32 + 16], 255, "inside the square"); + assert_eq!(mask[0], 0, "outside it"); + assert!(rasterize_mask(b"not svg", 16).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 0).is_none()); + assert!(rasterize_mask(SQUARE.as_bytes(), 100_000).is_none()); + } + + /// An icon's `` is never loaded: not a device file, not an embedded picture. + #[test] + fn masks_load_no_images() { + let svg = r##" + + + "##; + let mask = rasterize_mask(svg.as_bytes(), 16).expect("renders"); + assert!(mask.iter().all(|a| *a == 0), "nothing drawn"); + } + + #[test] + fn base_dirs_follow_xdg() { + let var = |k: &str| match k { + "HOME" => Some("/home/u".to_string()), + "XDG_DATA_DIRS" => Some("/a:rel:/b".to_string()), + _ => None, + }; + let dirs = base_dirs(&var); + let want: Vec = ["/home/u/.icons", "/home/u/.local/share/icons", "/a/icons", "/b/icons"].iter().map(PathBuf::from).collect(); + assert_eq!(dirs, want); + assert_eq!(base_dirs(&|_| None), vec![PathBuf::from("/usr/local/share/icons"), PathBuf::from("/usr/share/icons")]); + } +} diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 00d334758b..938fc1f032 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -26,11 +26,14 @@ mod appearance; #[cfg(target_os = "macos")] mod apple_events; mod caps_lock; +mod caption_style; mod control_server; mod crash_guard; mod cursor; mod file_dialog_probe; mod gpu_startup; +#[cfg(target_os = "linux")] +mod icon_theme; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] @@ -492,6 +495,10 @@ fn main() -> eframe::Result { // The custom title bar's undecorated window must stay borderless, or its caption and // border offset the content and the pointer on some Windows systems (#2246). app.services.window_frame = window_frame::service(cc, custom_titlebar); + // Linux: the desktop's caption buttons (GNOME's, KDE's) on the sides its layout puts them. + if custom_titlebar { + app.services.caption_style = caption_style::service(); + } // Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the // precise crosshair for painting tools while it is toggled on (#1758). app.services.caps_lock = caps_lock::service(cc); diff --git a/contributors/people.toml b/contributors/people.toml index 30191f52ae..7b75c991fd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -147,3 +147,6 @@ sync_github_name = true [people.megaDon101] display_name = "megadon" sync_github_name = false + +[people.sssehnsuchttt] +sync_github_name = true diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea40171b9a..55be083d42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -195,6 +195,7 @@ pub mod tiff_options_ui; mod tile_overlay; mod timeline_ui; mod titlebar; +pub use titlebar::{Caption, CaptionFamily, CaptionIcon, CaptionIcons, CaptionStyle, RenderIconFn}; pub mod tone; mod tool_cursor; pub mod tool_feedback; @@ -379,6 +380,8 @@ pub type CapsLockFn = Box bool>; /// Keeps an undecorated window's OS frame from offsetting its content and pointer (#2246). /// Called once per frame; `None` when the window is decorated or the platform needs no fixing. pub type WindowFrameFn = Box; +/// The caption buttons' current style ([`Services::caption_style`]). +pub type CaptionStyleFn = Box CaptionStyle>; /// Platform services injected by the app binary (file dialogs, codecs), keeping this crate free of /// I/O dependencies. @@ -455,6 +458,10 @@ pub struct Services { /// (Windows custom title bar, #2246). Called once per frame; `None` when the window is /// decorated or the platform needs no fixing. pub window_frame: Option, + /// How the custom title bar's caption buttons look and where they sit (Linux: the desktop's + /// style and button layout). Read every frame, with the context to wake when it changes; + /// `None` keeps Windows' buttons at the right end. + pub caption_style: Option, /// The persistent brush preset store, loading in the background (desktop; see /// `photocraft_engine::preset_store`). Attached to the session once it arrives; without /// one, brush presets are session-only unless the shell attached a store before startup. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 3624dbbf5a..dbf53b0f55 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -550,17 +550,25 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui, two_columns: bool) { /// The app's top bar: brand mark, menus, the document title and the workspace controls. With /// [`PhotocraftApp::custom_titlebar`] (Windows and Linux) it is also the window's title bar, as in -/// Photoshop on Windows: the caption buttons take its right end (`titlebar`). With the system +/// Photoshop on Windows: the caption buttons take its ends (`titlebar`). With the system /// title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and title, so the /// in-app bar shows menus and workspace controls only: no brand mark, no centred title. pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let custom = app.custom_titlebar; let system = system_title_bar(app); - let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 78 } else { 10 }; - let right = if custom { 0 } else { 10 }; + // The caption buttons' room at each end (`titlebar`); a side with buttons has no margin. + let (caption_left, caption_right) = crate::titlebar::reserved(app, ui.ctx()); + let left = if cfg!(target_os = "macos") && app.integrated_titlebar { + 78 + } else if caption_left > 0.0 { + 0 + } else { + 10 + }; + let right = if caption_right > 0.0 { 0 } else { 10 }; let bar = egui::Panel::top("title_bar") - .exact_size(if t.pro { 32.0 } else { 38.0 }) + .exact_size(crate::titlebar::bar_height(app, ui.ctx(), if t.pro { 32.0 } else { 38.0 })) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); @@ -586,9 +594,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let rtl = crate::direction::is_rtl(); let mut free = (full.left(), full.right()); crate::direction::horizontal_centered(ui, |ui| { - if rtl && custom { - // The caption buttons keep the right end; the menus start beside them. - ui.add_space(crate::titlebar::WIDTH + 4.0); + // The menus start beside the caption buttons at their end, if it has any. + let lead = if rtl { caption_right } else { caption_left }; + if lead > 0.0 { + ui.add_space(lead + 4.0); } if !system { let side = if t.pro { 18.0 } else { 20.0 }; @@ -606,9 +615,10 @@ pub fn title_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // The menu bar takes the whole row, so the far-end group gets its own rect: from // the menus to the bar's end, or to the caption buttons. let group = if rtl { - egui::Rect::from_min_max(egui::pos2(full.left(), full.top()), egui::pos2((menus_end - TITLE_GAP).max(full.left()), full.bottom())) + let left_edge = if caption_left > 0.0 { full.left() + caption_left + 4.0 } else { full.left() }; + egui::Rect::from_min_max(egui::pos2(left_edge, full.top()), egui::pos2((menus_end - TITLE_GAP).max(left_edge), full.bottom())) } else { - let right_edge = if custom { full.right() - crate::titlebar::WIDTH - 4.0 } else { full.right() }; + let right_edge = if caption_right > 0.0 { full.right() - caption_right - 4.0 } else { full.right() }; egui::Rect::from_min_max(egui::pos2((menus_end + TITLE_GAP).min(right_edge), full.top()), egui::pos2(right_edge, full.bottom())) }; let mut group_ui = ui.new_child(egui::UiBuilder::new().max_rect(group).layout(crate::direction::trailing())); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index d02536aeef..db1963f137 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -1,35 +1,217 @@ //! The window's own title bar on Windows and Linux, where the window has no OS decorations //! ([`PhotocraftApp::custom_titlebar`]): the app's top bar (`panels::title_bar`) is the title bar, //! as in Photoshop on Windows. The free gap between its menus and controls drags the window and a -//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons at -//! the far right minimize, maximize/restore and close, and thin invisible zones along the window's -//! edges resize it. +//! double-click there maximizes it (`panels::title_bar`, on every platform); caption buttons +//! minimize, maximize/restore and close, and thin invisible zones along the window's edges resize +//! it. +//! +//! The caption buttons follow the desktop ([`CaptionStyle`], from `Services::caption_style`): +//! Windows' 46 pt buttons at the right end by default; on Linux libadwaita's round buttons (GNOME), +//! Breeze's (KDE) or a plain round set elsewhere, with the set of buttons and their side taken +//! from the desktop's button layout. The shapes are drawn here, not read from the icon theme. -use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; +use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use serde_json::json; use crate::PhotocraftApp; use crate::theme::Tokens; -/// One caption button: as wide as the native Windows ones. +/// One Windows caption button: as wide as the native ones. pub const BUTTON_WIDTH: f32 = 46.0; -/// The three caption buttons together. -pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Resize zone thickness along an edge, and the side of a corner zone. 5 pt was hard to find /// (#1581); 8 pt is about a native resize border, and only trims the toolbar buttons' outer 3 pt. const EDGE: f32 = 8.0; const CORNER: f32 = 12.0; +/// Breeze's Close button on hover and press (KDE's Breeze decoration colours). +const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2); +const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29); -#[derive(Clone, Copy, PartialEq, Debug)] -enum Caption { +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Caption { Minimize, Maximize, Close, } -/// Left to right. +/// Windows' order, left to right. const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; +/// Which desktop's caption buttons to look like. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CaptionFamily { + /// Windows 10/11: full-height buttons 46 pt wide; Close turns red on hover. + #[default] + Windows, + /// GNOME (libadwaita): 24 pt round buttons on a faint disc that darkens on hover and press. + Adwaita, + /// KDE (Breeze decoration): smaller round buttons, bare at rest, inverted on hover. + Breeze, + /// Other Linux desktops: libadwaita's buttons without the disc at rest. + Generic, +} + +impl CaptionFamily { + /// `windows`, `adwaita`, `breeze` or `generic`, in any case. + pub fn parse(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "windows" => Some(Self::Windows), + "adwaita" => Some(Self::Adwaita), + "breeze" => Some(Self::Breeze), + "generic" => Some(Self::Generic), + _ => None, + } + } + + /// The button side, gap on each side of it, and gap between the outermost button and the + /// window's edge. libadwaita: 24 pt buttons 13 pt apart, 10 pt from the edge (the gap is split + /// between neighbours, so the hit areas touch). Breeze sizes buttons by the font (about 1.3 em, + /// 18 pt at KDE's default 10 pt font), half a button apart, 8 pt from the edge. + fn metrics(self) -> (f32, f32, f32) { + match self { + Self::Windows => (BUTTON_WIDTH, 0.0, 0.0), + Self::Adwaita | Self::Generic => (24.0, 6.5, 10.0), + Self::Breeze => (18.0, 4.5, 8.0), + } + } +} + +/// The caption buttons' look and placement. +#[derive(Clone, PartialEq, Debug)] +pub struct CaptionStyle { + pub family: CaptionFamily, + /// The buttons at the bar's left end, left to right. + pub left: Vec
, + /// The buttons at the bar's right end, left to right. + pub right: Vec, + /// The round buttons' glyphs from the desktop's icon theme; `None`, or an icon it lacks, draws + /// the family's own. + pub icons: Option, +} + +/// A caption button glyph, as the icon themes name them (`window-restore-symbolic`, …). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum CaptionIcon { + Minimize, + Maximize, + Restore, + Close, +} + +/// Renders an icon as a square coverage mask (`side` × `side` bytes, row by row), or `None`. +pub type RenderIconFn = dyn Fn(CaptionIcon, u32) -> Option> + Send + Sync; + +/// Glyphs from an icon theme (Linux: `window-*-symbolic`), drawn in the button's ink colour as +/// symbolic icons are. +#[derive(Clone)] +pub struct CaptionIcons { + /// The theme's name; the cached textures follow it. + pub theme: String, + pub render: std::sync::Arc, +} + +impl PartialEq for CaptionIcons { + fn eq(&self, other: &Self) -> bool { + self.theme == other.theme && std::sync::Arc::ptr_eq(&self.render, &other.render) + } +} + +impl std::fmt::Debug for CaptionIcons { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CaptionIcons").field("theme", &self.theme).finish_non_exhaustive() + } +} + +impl Default for CaptionStyle { + fn default() -> Self { + Self { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None } + } +} + +impl CaptionStyle { + /// `family` with the buttons a GTK decoration layout names (`gtk-decoration-layout`, GNOME's + /// `button-layout`, e.g. `appmenu:minimize,maximize,close`): those before the colon go left, + /// those after it right; without a colon all go left, as in GTK. Names other than `minimize`, + /// `maximize` and `close` (`appmenu`, `icon`, `spacer`, …) are skipped, and a button named + /// twice is placed once. + pub fn from_gtk_layout(family: CaptionFamily, layout: &str) -> Self { + let (left, right) = layout.split_once(':').unwrap_or((layout, "")); + let mut seen = vec![]; + let mut side = |names: &str| { + let mut v = vec![]; + for name in names.split(',') { + let c = match name.trim() { + "minimize" => Caption::Minimize, + "maximize" => Caption::Maximize, + "close" => Caption::Close, + _ => continue, + }; + if !seen.contains(&c) { + seen.push(c); + v.push(c); + } + } + v + }; + let left = side(left); + let right = side(right); + Self { family, left, right, icons: None } + } +} + +/// The caption style the platform reports, or Windows' when it reports none. +fn style(app: &PhotocraftApp, ctx: &egui::Context) -> CaptionStyle { + app.services.caption_style.as_ref().map(|read| read(ctx)).unwrap_or_default() +} + +/// The title bar's height: `base`, or with GNOME's buttons at least libadwaita's header bar +/// (46 pt, plus the bar's 1 pt separator), so the bar is as tall as its neighbours' title bars. +pub fn bar_height(app: &PhotocraftApp, ctx: &egui::Context, base: f32) -> f32 { + if app.custom_titlebar && style(app, ctx).family == CaptionFamily::Adwaita { base.max(47.0) } else { base } +} + +/// The width the caption buttons take at the bar's (left, right) end: zero for a side without +/// buttons, and for both without the custom title bar. +pub fn reserved(app: &PhotocraftApp, ctx: &egui::Context) -> (f32, f32) { + if !app.custom_titlebar { + return (0.0, 0.0); + } + let s = style(app, ctx); + let width = |side: &[Caption]| slots(s.family, side, 0.0, true).last().map_or(0.0, |(_, r, _)| r.1); + (width(&s.left), width(&s.right)) +} + +/// Each button's horizontal extent (left, right) and face centre on a side starting at `from`, in +/// order. `outer_left` puts the edge gap on the first button's left (the left end) instead of the +/// last button's right. +fn slots(family: CaptionFamily, side: &[Caption], from: f32, outer_left: bool) -> Vec<(Caption, (f32, f32), f32)> { + let (size, gap, end) = family.metrics(); + let mut x = from; + let n = side.len(); + side.iter() + .enumerate() + .map(|(i, &c)| { + let before = if outer_left && i == 0 { end } else { gap }; + let after = if !outer_left && i + 1 == n { end } else { gap }; + let slot = (x, x + before + size + after); + let centre = x + before + size / 2.0; + x = slot.1; + (c, slot, centre) + }) + .collect() +} + +/// Every caption button's hit rect (full bar height, flush with the window's edge) and face +/// centre, for a bar `bar` between the window's `left` and `right` edges. +fn layout(s: &CaptionStyle, bar: Rect, left: f32, right: f32) -> Vec<(Caption, Rect, Pos2)> { + let rect = |(c, (l, r), centre): (Caption, (f32, f32), f32)| { + (c, Rect::from_min_max(pos2(l, bar.top()), pos2(r, bar.bottom() - 1.0)), pos2(centre, bar.center().y)) + }; + let lefts = slots(s.family, &s.left, left, true); + let width = slots(s.family, &s.right, 0.0, false).last().map_or(0.0, |(_, r, _)| r.1); + let rights = slots(s.family, &s.right, right - width, false); + lefts.into_iter().chain(rights).map(rect).collect() +} + /// Caption button ids (stable, so tests and agents can find them). fn caption_id(c: Caption) -> Id { Id::new(("titlebar-caption", c as u8)) @@ -43,29 +225,38 @@ fn toggle_maximize(ctx: &egui::Context) { ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx))); } -/// Minimize, Maximize/Restore and Close at the right end of `bar` (the title bar's outer rect), -/// full bar height, flush with the window's edge (even if the bar's contents run wider) so a -/// maximized window's corner hits Close. Close runs File › Exit, which asks about unsaved -/// documents first. +/// The caption buttons at the ends of `bar` (the title bar's outer rect), full bar height, flush +/// with the window's edges (even if the bar's contents run wider) so a maximized window's corner +/// hits the outermost one. Close runs File › Exit, which asks about unsaved documents first. On +/// Linux the buttons drag the window too, as GTK's and KDE's do. pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { let t = Tokens::get(ui.ctx()); + let s = style(app, ui.ctx()); let max = maximized(ui.ctx()); - let right = bar.right().min(ui.ctx().content_rect().right()); + let content = ui.ctx().content_rect(); + let windows = s.family == CaptionFamily::Windows; let mut clicked = None; - for (i, c) in CAPTIONS.into_iter().enumerate() { - let r = Rect::from_min_size(pos2(right - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height() - 1.0)); - let resp = ui.interact(r, caption_id(c), Sense::click()); - let close = c == Caption::Close; - let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { - (true, _) if close => t.caption_close.gamma_multiply(0.8), - (_, true) if close => t.caption_close, - (true, _) => t.pressed, - (_, true) => t.hover, - _ => egui::Color32::TRANSPARENT, - }; - ui.painter().rect_filled(r, 0.0, fill); - let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; - paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) { + let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() }); + if resp.drag_started_by(PointerButton::Primary) { + ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag); + } + let pressed = resp.is_pointer_button_down_on() && resp.hovered(); + if windows { + let close = c == Caption::Close; + let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) { + (true, _) if close => t.caption_close.gamma_multiply(0.8), + (_, true) if close => t.caption_close, + (true, _) => t.pressed, + (_, true) => t.hover, + _ => Color32::TRANSPARENT, + }; + ui.painter().rect_filled(r, 0.0, fill); + let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon }; + paint_windows_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink)); + } else { + paint_round(ui, &t, &s, c, max, centre, resp.hovered(), pressed); + } let tip = match c { Caption::Minimize => tl!("Minimize"), Caption::Maximize if max => tl!("Restore"), @@ -88,8 +279,8 @@ pub fn caption_buttons(app: &mut PhotocraftApp, ui: &mut Ui, bar: Rect) { } } -/// The 10 pt line glyphs of the caption buttons. -fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { +/// The 10 pt line glyphs of the Windows caption buttons. +fn paint_windows_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { let p = ui.painter(); let g = Rect::from_center_size(at, vec2(10.0, 10.0)); match c { @@ -112,6 +303,119 @@ fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) } } +/// A round Linux caption button centred at `centre`: its disc, then its glyph on a 16 pt grid. +/// The disc is the icon colour at a fraction of its opacity (libadwaita's `alpha(currentColor)`), +/// so it suits light and dark themes alike; Breeze instead fills it with the icon colour on hover +/// and draws the glyph in the bar's colour. +#[allow(clippy::too_many_arguments)] +fn paint_round(ui: &Ui, t: &Tokens, s: &CaptionStyle, c: Caption, maximized: bool, centre: Pos2, hovered: bool, pressed: bool) { + let family = s.family; + let (size, _, _) = family.metrics(); + // Whole pixels, so the glyphs' edges stay sharp. + let ppp = ui.ctx().pixels_per_point(); + let centre = pos2((centre.x * ppp).round() / ppp, (centre.y * ppp).round() / ppp); + let close = c == Caption::Close; + let (fill, ink) = if family == CaptionFamily::Breeze { + match (hovered, pressed) { + (_, true) if close => (BREEZE_CLOSE_PRESSED, t.chrome), + (true, _) if close => (BREEZE_CLOSE_HOVER, t.chrome), + (_, true) => (t.icon.gamma_multiply(0.5), t.chrome), + (true, _) => (t.icon, t.chrome), + _ => (Color32::TRANSPARENT, t.icon), + } + } else { + let rest = if family == CaptionFamily::Adwaita { 0.10 } else { 0.0 }; + let target = if pressed { + 0.30 + } else if hovered { + 0.15 + } else { + rest + }; + // libadwaita eases the disc over 0.2 s. + let a = ui.ctx().animate_value_with_time(caption_id(c).with("disc"), target, 0.2); + (t.icon.gamma_multiply(a), t.icon) + }; + let p = ui.painter(); + if fill.a() > 0 { + p.circle_filled(centre, size / 2.0, fill); + } + // The glyph grid: 16 pt, or 2 pt less than a smaller button. + let k = (size - 2.0).min(16.0) / 16.0; + let icon = match c { + Caption::Minimize => CaptionIcon::Minimize, + Caption::Maximize if maximized => CaptionIcon::Restore, + Caption::Maximize => CaptionIcon::Maximize, + Caption::Close => CaptionIcon::Close, + }; + if let Some(icons) = &s.icons + && let Some(tex) = theme_icon(ui.ctx(), icons, icon, (16.0 * k * ppp).round() as u32) + { + let side = tex.size_vec2() / ppp; + let min = centre - side / 2.0; + let rect = Rect::from_min_size(pos2((min.x * ppp).round() / ppp, (min.y * ppp).round() / ppp), side); + p.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), ink); + return; + } + let at = |x: f32, y: f32| centre + vec2(x - 8.0, y - 8.0) * k; + if family == CaptionFamily::Breeze { + // Breeze: 1 pt chevrons (down to minimize, up to maximize), a diamond to restore, an X. + let s = Stroke::new(1.0, ink); + match c { + Caption::Minimize => { + p.line(vec![at(2.5, 5.5), at(8.0, 11.0), at(13.5, 5.5)], s); + } + Caption::Maximize if maximized => { + p.add(egui::Shape::closed_line(vec![at(8.0, 3.0), at(13.0, 8.0), at(8.0, 13.0), at(3.0, 8.0)], s)); + } + Caption::Maximize => { + p.line(vec![at(2.5, 10.5), at(8.0, 5.0), at(13.5, 10.5)], s); + } + Caption::Close => { + let s = Stroke::new(1.2, ink); + p.line_segment([at(3.6, 3.6), at(12.4, 12.4)], s); + p.line_segment([at(12.4, 3.6), at(3.6, 12.4)], s); + } + } + } else { + // libadwaita: a 2 pt bar low down, a 2 pt square outline (smaller to restore), an X. + let frame = |a: f32, b: f32| Rect::from_min_max(at(a, a), at(b, b)); + match c { + Caption::Minimize => { + p.rect_filled(Rect::from_min_max(at(4.0, 10.0), at(12.0, 12.0)), 0.0, ink); + } + Caption::Maximize => { + let square = if maximized { frame(5.0, 11.0) } else { frame(4.0, 12.0) }; + p.rect_stroke(square, 0.0, Stroke::new(2.0 * k, ink), egui::StrokeKind::Inside); + } + Caption::Close => { + let s = Stroke::new(1.5 * k, ink); + p.line_segment([at(4.5, 4.5), at(11.5, 11.5)], s); + p.line_segment([at(11.5, 4.5), at(4.5, 11.5)], s); + } + } + } +} + +/// `icon` from the theme at `px` device pixels, uploaded once per theme and size; `None` (also +/// remembered) when the theme lacks it or it doesn't render. +fn theme_icon(ctx: &egui::Context, icons: &CaptionIcons, icon: CaptionIcon, px: u32) -> Option { + let id = Id::new(("caption-icon", &icons.theme, icon, px)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { + return cached; + } + let side = usize::try_from(px).ok().filter(|&s| (1..=256).contains(&s)); + let tex = side.and_then(|side| { + let mask = (icons.render)(icon, px).filter(|m| m.len() == side * side)?; + // White at the mask's coverage, tinted with the ink when drawn. + let pixels = mask.into_iter().map(Color32::from_white_alpha).collect(); + let image = egui::ColorImage { size: [side, side], source_size: vec2(px as f32, px as f32), pixels }; + Some(ctx.load_texture(format!("caption-{icon:?}-{px}"), image, egui::TextureOptions::LINEAR)) + }); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + /// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners /// come last so they win where they overlap the edges. Edges use the one-sided cursors /// (`w-resize`, …), as GTK and KDE window borders do: some cursor themes ship no `ew-resize` / @@ -465,4 +769,100 @@ mod tests { assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}"); assert!(w.click(pos2(999.5, 0.5)).iter().any(|c| matches!(c, ViewportCommand::Close))); } + + #[test] + fn gtk_layouts_pick_the_buttons_and_their_sides() { + use Caption::*; + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:minimize,maximize,close"); + assert_eq!((s.left, s.right), (vec![], vec![Minimize, Maximize, Close])); + // Unknown names are skipped, spaces trimmed, a repeat placed once, a second colon is a name. + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "close,close , minimize:icon,maximize:close"); + assert_eq!((s.left, s.right), (vec![Close, Minimize], vec![])); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "minimize,close"); + assert_eq!((s.left, s.right), (vec![Minimize, Close], vec![]), "no colon: GTK puts them all at the start"); + let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, ""); + assert!(s.left.is_empty() && s.right.is_empty()); + assert_eq!(CaptionFamily::parse(" Breeze "), Some(CaptionFamily::Breeze)); + assert_eq!(CaptionFamily::parse("aqua"), None); + assert_eq!(CaptionStyle::default(), CaptionStyle { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec(), icons: None }); + } + + #[test] + fn linux_buttons_sit_where_the_layout_says_and_drag_the_window() { + use Caption::*; + for (family, size, gap, end) in [(CaptionFamily::Adwaita, 24.0, 6.5, 10.0), (CaptionFamily::Breeze, 18.0, 4.5, 8.0)] { + let width = 1100.0; + let mut w = Win::new(width); + w.app.services.caption_style = Some(Box::new(move |_| CaptionStyle { family, left: vec![Close], right: vec![Minimize, Maximize], icons: None })); + w.frame(vec![]); + w.frame(vec![]); + let [min, max, close] = CAPTIONS.map(|c| w.caption(c)); + // Flush with the window's edges, the edge gap outermost, the hit areas touching. + assert_eq!((close.left(), close.width()), (0.0, end + size + gap), "{family:?}"); + assert_eq!((max.right(), max.width()), (width, gap + size + end), "{family:?}"); + assert_eq!((min.right(), min.width()), (max.left(), 2.0 * gap + size), "{family:?}"); + // GNOME's header bars are taller than Photoshop's bar; KDE's are not. + if family == CaptionFamily::Adwaita { + assert_eq!(close.height(), 46.0); + } else { + assert!((31.0..46.0).contains(&close.height()), "{close:?}"); + } + let mark = crate::brand::mark_rect(&w.ctx).expect("the brand mark is painted"); + assert!(mark.left() > close.right(), "the bar starts after the left buttons: {mark:?} {close:?}"); + for a in w.bar_widgets() { + assert!(a.left() >= close.right() && a.right() <= min.left(), "{a:?} runs into the caption buttons ({family:?})"); + } + assert!(w.click(max.center()).contains(&ViewportCommand::Maximized(true))); + assert!(w.click(close.center()).iter().any(|c| matches!(c, ViewportCommand::Close))); + // A drag that starts on a button moves the window instead of pressing it. + let at = min.center(); + let cmds = w.gesture(&[at, at + vec2(12.0, 4.0), at + vec2(30.0, 8.0)]); + assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}"); + assert!(!cmds.contains(&ViewportCommand::Minimized(true)), "{cmds:?}"); + } + } + + /// A side without buttons reserves nothing; without the custom title bar, neither does any. + #[test] + fn only_sides_with_buttons_take_room() { + let mut w = Win::new(1000.0); + w.app.services.caption_style = Some(Box::new(|_| CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:close"))); + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 6.5 + 24.0 + 10.0)); + w.app.custom_titlebar = false; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 0.0)); + w.app.custom_titlebar = true; + w.app.services.caption_style = None; + assert_eq!(reserved(&w.app, &w.ctx), (0.0, 3.0 * BUTTON_WIDTH)); + } + + /// Theme glyphs upload once per theme and size; an icon the theme lacks, a wrong-sized mask or + /// an absurd size gives `None`, so the button draws its own glyph. + #[test] + fn theme_icons_are_cached_and_fall_back() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let counter = std::sync::Arc::clone(&calls); + let render = move |icon: CaptionIcon, px: u32| { + counter.fetch_add(1, Ordering::Relaxed); + match icon { + CaptionIcon::Close => Some(vec![255; (px * px) as usize]), + CaptionIcon::Minimize => Some(vec![255; 3]), + _ => None, + } + }; + let icons = CaptionIcons { theme: "Test".into(), render: std::sync::Arc::new(render) }; + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(RawInput::default(), |ui| { + let tex = theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).expect("rendered"); + assert_eq!(tex.size(), [16, 16]); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 16).is_some()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Restore, 16).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Minimize, 16).is_none(), "a mask of the wrong size"); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 0).is_none()); + assert!(theme_icon(ui.ctx(), &icons, CaptionIcon::Close, 100_000).is_none()); + }); + out.textures_delta.clear(); + assert_eq!(calls.load(Ordering::Relaxed), 3, "each icon and size rendered once; absurd sizes never"); + } } From e8af9d592fe32e2634375f9c4dcbd28b133704d1 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 05:06:37 +0200 Subject: [PATCH 316/523] Themes: Soft Dark and Soft Light (#4368) (#4378) * Themes: Soft Dark and Soft Light (#4368) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 1 + crates/automation/src/server.rs | 2 +- crates/engine/src/prefs.rs | 22 ++++- crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/prefs_ui.rs | 15 ++- crates/ui-egui/src/theme.rs | 143 +++++++++++++++++++++++++++- docs/scorecard.md | 4 +- docs/ui-design.md | 4 +- scorecard/ui.toml | 4 +- 28 files changed, 260 insertions(+), 17 deletions(-) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index aa9f590ceb..227aee8ac9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -54,6 +54,7 @@ next to them. | `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code | | `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) | | `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, (its colour schemes are LGPL-2.0-or-later) | Original work | +| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work | ### Optional build input: craft-fonts (not files in this repo) diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 74b3770dff..ea1b40239d 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -309,7 +309,7 @@ pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, /// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio, - /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab, + /// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark, softLight, softDark), brushSection, brushTab, /// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it), /// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one /// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 3a8b7ce695..4db0f248a9 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -55,10 +55,10 @@ choice!(TypeUnit { Points = "points", Pixels = "pixels", Millimeters = "mm" } de choice!(PointSize { PostScript = "postScript", Traditional = "traditional" } default PostScript); choice!(Interpolation { BicubicAutomatic = "bicubicAutomatic", Nearest = "nearestNeighbor", Bilinear = "bilinear", Bicubic = "bicubic", BicubicSmoother = "bicubicSmoother", BicubicSharper = "bicubicSharper", PreserveDetails = "preserveDetails" } default BicubicAutomatic); choice!(ColorPicker { Adobe = "adobe", System = "system" } default Adobe); -choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark" } default ProMedium); +choice!(Theme { Pro = "pro", ProMedium = "proMedium", ProLightGray = "proLightGray", ProLight = "proLight", Studio = "studio", StudioLight = "studioLight", Classic = "classic", SolarizedDark = "solarizedDark", Adwaita = "adwaita", AdwaitaDark = "adwaitaDark", BreezeLight = "breezeLight", BreezeDark = "breezeDark", SoftDark = "softDark", SoftLight = "softLight" } default ProMedium); choice!(AppearanceMode { Auto = "auto", Dark = "dark", Light = "light" } default Dark); -choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark" } default ProMedium); -choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight" } default StudioLight); +choice!(DarkTheme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", SolarizedDark = "solarizedDark", AdwaitaDark = "adwaitaDark", BreezeDark = "breezeDark", SoftDark = "softDark" } default ProMedium); +choice!(LightTheme { StudioLight = "studioLight", ProLightGray = "proLightGray", ProLight = "proLight", Classic = "classic", Adwaita = "adwaita", BreezeLight = "breezeLight", SoftLight = "softLight" } default StudioLight); choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default); choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow); choice!(UiScale { Auto = "auto", P75 = "75", P80 = "80", P85 = "85", P90 = "90", P95 = "95", P100 = "100", P125 = "125", P150 = "150", P175 = "175", P200 = "200", P250 = "250", P300 = "300" } default Auto); @@ -1542,8 +1542,12 @@ impl Session { && let Some(theme) = interface.get("theme").and_then(Value::as_str).and_then(Theme::parse) { let (mode, slot) = match theme { - Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark => ("dark", "darkTheme"), - Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight => ("light", "lightTheme"), + Theme::Pro | Theme::ProMedium | Theme::Studio | Theme::SolarizedDark | Theme::AdwaitaDark | Theme::BreezeDark | Theme::SoftDark => { + ("dark", "darkTheme") + } + Theme::StudioLight | Theme::ProLightGray | Theme::ProLight | Theme::Classic | Theme::Adwaita | Theme::BreezeLight | Theme::SoftLight => { + ("light", "lightTheme") + } }; interface.insert("appearanceMode".into(), json!(mode)); interface.insert(slot.into(), json!(theme.name())); @@ -1666,6 +1670,14 @@ impl Session { next.interface.light_theme = LightTheme::BreezeLight; next.interface.appearance_mode = AppearanceMode::Light; } + Theme::SoftDark => { + next.interface.dark_theme = DarkTheme::SoftDark; + next.interface.appearance_mode = AppearanceMode::Dark; + } + Theme::SoftLight => { + next.interface.light_theme = LightTheme::SoftLight; + next.interface.appearance_mode = AppearanceMode::Light; + } } } self.prefs.edit(|p| *p = next); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6ebb2963c..4fdabb4406 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -735,6 +735,10 @@ Breeze Dark Theme Tmavý motiv Breeze Breeze light Breeze světlý Breeze Light Theme Světlý motiv Breeze + Soft dark Jemný tmavý + Soft light Jemný světlý + Soft Dark Theme Jemné tmavé téma + Soft Light Theme Jemné světlé téma Next Theme Další motiv Actions Akce Brush Settings Nastavení štětce diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a8efbdd687..be910dda32 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -721,6 +721,10 @@ Breeze Dark Theme Breeze-Design (Dunkel) Breeze light Breeze hell Breeze Light Theme Breeze-Design (Hell) + Soft dark Weich dunkel + Soft light Weich hell + Soft Dark Theme Weiches Design (Dunkel) + Soft Light Theme Weiches Design (Hell) Next Theme Nächstes Design Actions Aktionen Brush Settings Pinseleinstellungen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ac29108ad3..d02c1318aa 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2132,6 +2132,10 @@ Breeze Dark Theme Σκούρο θέμα Breeze Breeze light Breeze φωτεινό Breeze Light Theme Φωτεινό θέμα Breeze + Soft dark Απαλό σκούρο + Soft light Απαλό ανοιχτό + Soft Dark Theme Απαλό σκούρο θέμα + Soft Light Theme Απαλό ανοιχτό θέμα Clear Canvas Guides Απαλοιφή οδηγών καμβά Clear Override Απαλοιφή παράκαμψης Clear Samples Απαλοιφή δειγμάτων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0a87b52435..cdee7d44b9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -728,6 +728,10 @@ Breeze Dark Theme Tema Breeze oscuro Breeze light Breeze claro Breeze Light Theme Tema Breeze claro + Soft dark Suave oscuro + Soft light Suave claro + Soft Dark Theme Tema suave oscuro + Soft Light Theme Tema suave claro Next Theme Tema siguiente Actions Acciones Brush Settings Ajustes del pincel diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a8e9e2bf3b..daf5447d76 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -718,6 +718,10 @@ Breeze Dark Theme Thème Breeze sombre Breeze light Breeze clair Breeze Light Theme Thème Breeze clair + Soft dark Doux sombre + Soft light Doux clair + Soft Dark Theme Thème doux sombre + Soft Light Theme Thème doux clair Next Theme Thème suivant Actions Actions Brush Settings Paramètres du pinceau diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9476ed77a2..ef7905b881 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -443,6 +443,10 @@ Box Blur… Dobozelmosás… Breeze Dark Theme Breeze sötét téma Breeze Light Theme Breeze világos téma + Soft dark Lágy sötét + Soft light Lágy világos + Soft Dark Theme Lágy sötét téma + Soft Light Theme Lágy világos téma Brush Preview Ecset előnézete Brush Settings Ecsetbeállítások Brushes Ecsetek diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f4c53e49b3..db948970fe 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -711,6 +711,10 @@ Breeze Dark Theme Tema Breeze Gelap Breeze light Breeze terang Breeze Light Theme Tema Breeze Terang + Soft dark Lembut gelap + Soft light Lembut terang + Soft Dark Theme Tema lembut gelap + Soft Light Theme Tema lembut terang Next Theme Tema Berikutnya Actions Action Brush Settings Pengaturan Kuas diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 5b12254b11..cf4c8962cf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -726,6 +726,10 @@ Breeze Dark Theme Tema Breeze scuro Breeze light Breeze chiaro Breeze Light Theme Tema Breeze chiaro + Soft dark Morbido scuro + Soft light Morbido chiaro + Soft Dark Theme Tema morbido scuro + Soft Light Theme Tema morbido chiaro Next Theme Tema successivo Actions Azioni Brush Settings Impostazioni pennello diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c6a1ac7778..19c36d54ab 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -728,6 +728,10 @@ Breeze Dark Theme Breeze ダークテーマ Breeze light Breeze ライト Breeze Light Theme Breeze ライトテーマ + Soft dark ソフトダーク + Soft light ソフトライト + Soft Dark Theme ソフトダークテーマ + Soft Light Theme ソフトライトテーマ Next Theme 次のテーマ Actions アクション Brush Settings ブラシ設定 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 166a2c7942..7857b418b7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -702,6 +702,10 @@ Breeze Dark Theme Breeze 어두운 테마 Breeze light Breeze 밝게 Breeze Light Theme Breeze 밝은 테마 + Soft dark 소프트 다크 + Soft light 소프트 라이트 + Soft Dark Theme 소프트 다크 테마 + Soft Light Theme 소프트 라이트 테마 Next Theme 다음 테마 Actions 액션 Brush Settings 브러시 설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 10ebe0fd50..a7f7c2ae13 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -707,6 +707,10 @@ Breeze Dark Theme Breeze-thema donker Breeze light Breeze licht Breeze Light Theme Breeze-thema licht + Soft dark Zacht donker + Soft light Zacht licht + Soft Dark Theme Zacht donker thema + Soft Light Theme Zacht licht thema Adwaita Light Theme Adwaita-thema licht Adwaita Dark Theme Adwaita-thema donker Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4bf1a55886..0c82ac3b9b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -727,6 +727,10 @@ Breeze Dark Theme Ciemny motyw Breeze Breeze light Breeze jasny Breeze Light Theme Jasny motyw Breeze + Soft dark Łagodny ciemny + Soft light Łagodny jasny + Soft Dark Theme Łagodny ciemny motyw + Soft Light Theme Łagodny jasny motyw Next Theme Następny motyw Actions Akcje Brush Settings Ustawienia pędzla diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f562d57389..199fa97345 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -713,6 +713,10 @@ Breeze Dark Theme Tema Breeze Escuro Breeze light Breeze claro Breeze Light Theme Tema Breeze Claro + Soft dark Suave escuro + Soft light Suave claro + Soft Dark Theme Tema suave escuro + Soft Light Theme Tema suave claro Next Theme Próximo tema Actions Ações Brush Settings Configurações do pincel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e1fc3c399..dd7001bc1f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1433,6 +1433,10 @@ Breeze Dark Theme Тёмная тема Breeze Breeze light Breeze светлая Breeze Light Theme Светлая тема Breeze + Soft dark Мягкая тёмная + Soft light Мягкая светлая + Soft Dark Theme Мягкая тёмная тема + Soft Light Theme Мягкая светлая тема Studio Theme Тема Studio Studio Light Theme Светлая тема Studio Adwaita Light Theme Светлая тема Adwaita diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c34e673367..605ffd26f9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -702,6 +702,10 @@ Breeze Dark Theme Esinti Koyu Tema Breeze light Esinti ışığı Breeze Light Theme Esinti Işık Teması + Soft dark Yumuşak koyu + Soft light Yumuşak açık + Soft Dark Theme Yumuşak koyu tema + Soft Light Theme Yumuşak açık tema Adwaita Light Theme Adwaita Işık Teması Adwaita Dark Theme Adwaita Karanlık Tema Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 87562df818..712766a793 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1412,6 +1412,10 @@ Breeze Dark Theme Темна тема Breeze Breeze light Breeze світла Breeze Light Theme Світла тема Breeze + Soft dark М'яка темна + Soft light М'яка світла + Soft Dark Theme М'яка темна тема + Soft Light Theme М'яка світла тема Adwaita Light Theme Світла тема Adwaita Adwaita Dark Theme Темна тема Adwaita Adwaita Adwaita diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9fc122a766..7ef6f16dd6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2353,6 +2353,10 @@ shape Appearance Diện mạo Bottom: Dưới: Breeze Dark Theme Chủ đề Breeze tối Breeze Light Theme Chủ đề Breeze sáng + Soft dark Dịu tối + Soft light Dịu sáng + Soft Dark Theme Giao diện dịu tối + Soft Light Theme Giao diện dịu sáng Caps Đầu nét Center Columns Căn giữa các cột Change Panel Group Đổi nhóm bảng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4fb92c2ebd..142718f393 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -730,6 +730,10 @@ Breeze Dark Theme Breeze 深色主题 Breeze light Breeze 浅色 Breeze Light Theme Breeze 浅色主题 + Soft dark 柔和深色 + Soft light 柔和浅色 + Soft Dark Theme 柔和深色主题 + Soft Light Theme 柔和浅色主题 Next Theme 下一代主题 Actions 动作 Brush Settings 画笔设置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c3010b7edf..8e4da035be 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -735,6 +735,10 @@ Breeze Dark Theme Breeze 深色主題 Breeze light Breeze 淺色 Breeze Light Theme Breeze 淺色主題 + Soft dark 柔和深色 + Soft light 柔和淺色 + Soft Dark Theme 柔和深色主題 + Soft Light Theme 柔和淺色主題 Next Theme 下一個主題 Actions 動作 Brush Settings 筆刷設定 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 71fab42239..ee59da8e70 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -89,6 +89,8 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("window.theme.adwaitaDark", "Adwaita Dark Theme", &["Window", "Theme"], None), ("window.theme.breezeLight", "Breeze Light Theme", &["Window", "Theme"], None), ("window.theme.breezeDark", "Breeze Dark Theme", &["Window", "Theme"], None), + ("window.theme.softLight", "Soft Light Theme", &["Window", "Theme"], None), + ("window.theme.softDark", "Soft Dark Theme", &["Window", "Theme"], None), ("window.theme.system", "Sync with system", &["Window", "Theme"], None), ("edit.search", "Search…", &["Edit"], Some("Cmd+F")), ("help.discord", "Join the ArtCraft Discord…", &["Help"], None), @@ -374,7 +376,9 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id: | "window.theme.adwaita" | "window.theme.adwaitaDark" | "window.theme.breezeLight" - | "window.theme.breezeDark" => { + | "window.theme.breezeDark" + | "window.theme.softLight" + | "window.theme.softDark" => { let k = crate::theme::ThemeKind::from_name(&id["window.theme.".len()..]).unwrap_or_default(); app.set_theme(ctx, k); Ok(Value::Null) diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 8260fed722..cf0ff51969 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -97,6 +97,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> LightTheme::Classic => ThemeKind::Classic, LightTheme::Adwaita => ThemeKind::Adwaita, LightTheme::BreezeLight => ThemeKind::BreezeLight, + LightTheme::SoftLight => ThemeKind::SoftLight, } } else { match interface.dark_theme { @@ -106,6 +107,7 @@ fn selected_theme(interface: &prefs::Interface, system: Option) -> DarkTheme::SolarizedDark => ThemeKind::SolarizedDark, DarkTheme::AdwaitaDark => ThemeKind::AdwaitaDark, DarkTheme::BreezeDark => ThemeKind::BreezeDark, + DarkTheme::SoftDark => ThemeKind::SoftDark, } } } @@ -133,6 +135,7 @@ const LIGHT_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("classic", ThemeKind::Classic), ("adwaita", ThemeKind::Adwaita), ("breezeLight", ThemeKind::BreezeLight), + ("softLight", ThemeKind::SoftLight), ]; const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("proMedium", ThemeKind::ProMedium), @@ -141,6 +144,7 @@ const DARK_THEME_OPTIONS: &[(&str, ThemeKind)] = &[ ("solarizedDark", ThemeKind::SolarizedDark), ("adwaitaDark", ThemeKind::AdwaitaDark), ("breezeDark", ThemeKind::BreezeDark), + ("softDark", ThemeKind::SoftDark), ]; fn favorite_theme_groups(favorites: &[SavedTheme]) -> (Vec, Vec) { @@ -2089,7 +2093,12 @@ mod tests { fn breeze_themes_are_chosen_by_preference_and_fix_the_matching_mode() { let ctx = egui::Context::default(); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); - for (id, kind, mode) in [("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark)] { + for (id, kind, mode) in [ + ("breezeLight", ThemeKind::BreezeLight, AppearanceMode::Light), + ("breezeDark", ThemeKind::BreezeDark, AppearanceMode::Dark), + ("softLight", ThemeKind::SoftLight, AppearanceMode::Light), + ("softDark", ThemeKind::SoftDark, AppearanceMode::Dark), + ] { app.run("prefs.set", json!({"path": "interface.theme", "value": id})).unwrap(); tick(&mut app, &ctx); let interface = app.session.prefs().interface.clone(); @@ -2098,8 +2107,8 @@ mod tests { assert_eq!(app.ui.theme, kind, "{id}"); } // The two slots are independent: choosing the dark one left the light choice alone. - assert_eq!(app.session.prefs().interface.light_theme, LightTheme::BreezeLight); - assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::BreezeDark); + assert_eq!(app.session.prefs().interface.light_theme, LightTheme::SoftLight); + assert_eq!(app.session.prefs().interface.dark_theme, DarkTheme::SoftDark); } #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index bee0104b89..9573d1056a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -48,10 +48,14 @@ pub enum ThemeKind { BreezeLight, /// An adaptation of KDE Plasma's Breeze Dark look. BreezeDark, + /// A low-contrast dark theme: muted violet-grey surfaces, softened text and a periwinkle accent. + SoftDark, + /// A low-contrast light theme: pale violet-grey surfaces, softened dark text and a periwinkle accent. + SoftLight, } impl ThemeKind { - pub const ALL: [ThemeKind; 12] = [ + pub const ALL: [ThemeKind; 14] = [ ThemeKind::Pro, ThemeKind::ProMedium, ThemeKind::ProLightGray, @@ -64,6 +68,8 @@ impl ThemeKind { ThemeKind::AdwaitaDark, ThemeKind::BreezeLight, ThemeKind::BreezeDark, + ThemeKind::SoftDark, + ThemeKind::SoftLight, ]; pub fn label(self) -> &'static str { match self { @@ -79,6 +85,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "Solarized Dark", ThemeKind::BreezeLight => "Breeze Light", ThemeKind::BreezeDark => "Breeze Dark", + ThemeKind::SoftDark => "Soft Dark", + ThemeKind::SoftLight => "Soft Light", } } /// The canonical name: `ui.set {theme}` accepts it and the Window › Theme commands are @@ -97,6 +105,8 @@ impl ThemeKind { ThemeKind::SolarizedDark => "solarizedDark", ThemeKind::BreezeLight => "breezeLight", ThemeKind::BreezeDark => "breezeDark", + ThemeKind::SoftDark => "softDark", + ThemeKind::SoftLight => "softLight", } } /// Photoshop-grammar layout (tab strips, flat panels): the four Pro brightness levels. @@ -121,6 +131,8 @@ impl ThemeKind { "solarizeddark" | "solarized" => Some(ThemeKind::SolarizedDark), "breezelight" | "breeze" | "kde" | "kdelight" | "plasma" | "plasmalight" => Some(ThemeKind::BreezeLight), "breezedark" | "kdedark" | "plasmadark" => Some(ThemeKind::BreezeDark), + "softdark" => Some(ThemeKind::SoftDark), + "softlight" => Some(ThemeKind::SoftLight), _ => None, } } @@ -602,6 +614,91 @@ impl Tokens { caption_close_text: Color32::WHITE, scrim: Color32::from_black_alpha(110), }, + // Soft Dark: our own low-contrast palette on the Studio layout. Muted violet-grey + // surfaces (never near-black), softened off-white text and a periwinkle accent; the + // text colours still hold 4.5:1 on every surface (checked by the tests). + ThemeKind::SoftDark => Tokens { + kind, + chrome: Color32::from_rgb(58, 58, 68), + canvas: Color32::from_rgb(40, 40, 48), + canvas_dot: Color32::from_rgb(40, 40, 48), + dock: Color32::from_rgb(47, 47, 56), + card: Color32::from_rgb(54, 54, 64), + card_border: Color32::from_rgb(70, 70, 82), + field: Color32::from_rgb(64, 64, 75), + field_border: Color32::from_rgb(84, 84, 98), + hover: Color32::from_rgb(68, 68, 80), + pressed: Color32::from_rgb(74, 74, 88), + text: Color32::from_rgb(238, 238, 246), + text_dim: Color32::from_rgb(212, 212, 224), + text_faint: Color32::from_rgb(190, 190, 206), + icon: Color32::from_rgb(214, 214, 226), + accent: Color32::from_rgb(138, 154, 232), + accent_soft: Color32::from_rgba_unmultiplied(138, 154, 232, 48), + accent_border: Color32::from_rgba_unmultiplied(138, 154, 232, 150), + accent_text: Color32::from_rgb(190, 200, 252), + separator: Color32::from_rgb(36, 36, 44), + shadow: Color32::from_black_alpha(110), + primary_bg: Color32::from_rgb(86, 104, 196), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(250, 160, 172), + warning: Color32::from_rgb(240, 200, 130), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(40, 40, 48), + histogram_level: 220, + caption_close: Color32::from_rgb(200, 72, 86), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(100), + }, + // Soft Light: pale violet-grey surfaces, softened dark-violet text (not black) and a + // periwinkle accent, with the same generous radii as Soft Dark. + ThemeKind::SoftLight => Tokens { + kind, + chrome: Color32::from_rgb(232, 230, 236), + canvas: Color32::from_rgb(218, 216, 224), + canvas_dot: Color32::from_rgb(218, 216, 224), + dock: Color32::from_rgb(226, 224, 231), + card: Color32::from_rgb(240, 238, 243), + card_border: Color32::from_rgb(208, 205, 215), + field: Color32::from_rgb(250, 249, 252), + field_border: Color32::from_rgb(190, 187, 201), + hover: Color32::from_rgb(228, 226, 234), + pressed: Color32::from_rgb(212, 209, 221), + text: Color32::from_rgb(44, 42, 56), + text_dim: Color32::from_rgb(62, 60, 76), + text_faint: Color32::from_rgb(80, 78, 96), + icon: Color32::from_rgb(64, 62, 80), + accent: Color32::from_rgb(110, 128, 216), + accent_soft: Color32::from_rgba_unmultiplied(110, 128, 216, 44), + accent_border: Color32::from_rgba_unmultiplied(110, 128, 216, 170), + accent_text: Color32::from_rgb(46, 58, 146), + separator: Color32::from_rgb(210, 207, 218), + shadow: Color32::from_black_alpha(40), + primary_bg: Color32::from_rgb(80, 98, 190), + primary_text: Color32::WHITE, + danger: Color32::from_rgb(166, 44, 62), + warning: Color32::from_rgb(140, 72, 0), + radius_sm: 7.0, + radius: 10.0, + radius_lg: 14.0, + bevel: false, + pro: false, + round_chips: true, + tab_strip: Color32::TRANSPARENT, + row_selected: Color32::TRANSPARENT, + histogram_bg: Color32::from_rgb(44, 42, 56), + histogram_level: 240, + caption_close: Color32::from_rgb(200, 60, 72), + caption_close_text: Color32::WHITE, + scrim: Color32::from_black_alpha(90), + }, // Ethan Schoonover's Solarized Dark palette: base03 canvas, base02 surfaces, base0 // body text and the Solarized blue accent. https://ethanschoonover.com/solarized/ // (MIT; see LICENSE-solarized.txt) @@ -714,7 +811,13 @@ impl Tokens { pub fn dark(&self) -> bool { matches!( self.kind, - ThemeKind::Studio | ThemeKind::Pro | ThemeKind::ProMedium | ThemeKind::SolarizedDark | ThemeKind::AdwaitaDark | ThemeKind::BreezeDark + ThemeKind::Studio + | ThemeKind::Pro + | ThemeKind::ProMedium + | ThemeKind::SolarizedDark + | ThemeKind::AdwaitaDark + | ThemeKind::BreezeDark + | ThemeKind::SoftDark ) } } @@ -1168,6 +1271,42 @@ mod tests { assert_eq!(ThemeKind::from_name("gnome dark"), Some(ThemeKind::AdwaitaDark)); } + #[test] + fn soft_themes_parse_and_hold_text_contrast() { + for (kind, alias) in [(ThemeKind::SoftDark, "soft dark"), (ThemeKind::SoftLight, "Soft_Light")] { + assert_eq!(ThemeKind::from_name(kind.id()), Some(kind)); + assert_eq!(ThemeKind::from_name(kind.label()), Some(kind)); + assert_eq!(ThemeKind::from_name(alias), Some(kind)); + assert!(ThemeKind::ALL.contains(&kind)); + let t = Tokens::for_kind(kind); + assert_eq!(t.kind, kind); + assert_eq!(t.dark(), kind == ThemeKind::SoftDark); + assert!(!t.pro && !t.bevel); + // Soft means no extremes: surfaces stay off black and white. + for c in [t.chrome, t.canvas, t.dock, t.card, t.field] { + let (hi, lo) = (c.r().max(c.g()).max(c.b()), c.r().min(c.g()).min(c.b())); + assert!(if t.dark() { (32..96).contains(&hi) } else { hi < 255 && lo > 200 }, "{kind:?}: {c:?}"); + } + let tint = |under: Color32| { + let k = f32::from(t.accent_soft.a()) / 255.0; + let ch = |over: u8, under: u8| (f32::from(over) * k + f32::from(under) * (1.0 - k)).round() as u8; + Color32::from_rgb(ch(t.accent.r(), under.r()), ch(t.accent.g(), under.g()), ch(t.accent.b(), under.b())) + }; + let surfaces = [t.chrome, t.canvas, t.dock, t.card, t.field, t.hover, t.pressed, tint(t.dock), tint(t.card)]; + for (i, surface) in surfaces.into_iter().enumerate() { + for (name, colour) in [("text", t.text), ("dim", t.text_dim), ("faint", t.text_faint), ("accent text", t.accent_text)] { + assert!(contrast(colour, surface) >= 4.5, "{kind:?}: {name} on {surface:?}"); + } + // Status colours are marks on hovered or selected surfaces (3:1), text on resting ones. + let floor = if i < 5 { 4.5 } else { 3.0 }; + assert!(contrast(t.danger, surface) >= floor && contrast(t.warning, surface) >= floor, "{kind:?}: status on {surface:?}"); + } + for k in [1.0, 0.93, 0.85] { + assert!(contrast(t.primary_text, t.primary_bg.gamma_multiply(k)) >= 4.5, "{kind:?}: primary at {k}"); + } + } + } + #[test] fn breeze_themes_parse_and_meet_text_contrast() { for (kind, aliases) in [ diff --git a/docs/scorecard.md b/docs/scorecard.md index 465b7bc7a9..80c047bb41 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | -| [Workspace and UI](#workspace-and-ui) | 13 | 5 | 5 | settings that do nothing: 26 of 156 (target 0) | +| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | | [Type](#type) | 4 | 2 | 3 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | @@ -192,7 +192,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | UI-217-8 | Tool-specific canvas right-click menu; right-click spelling suggestions in text fields | partial | [#217](https://github.com/storytold/photocraft/issues/217) | paint_mouse.rs brush picker and canvas_tool_menu.rs selection actions; text_field_menu.rs gives every text field its right-click Undo/Cut/Copy/Paste/Delete/Select All menu (#2921); other tools missing; the Type tool's edit menu starts with spelling suggestions and Add to Dictionary over a misspelled word (#4370); Ignore / Ignore All, suggestions in the non-editing type menu and in generic text fields are not there yet | | UI-217-9 | UI scale presets 100/115/125/150/200 % through zoom_factor, remembered | done | [#217](https://github.com/storytold/photocraft/issues/217) | prefs::UiScale offers Auto plus 75 to 300 percent (13 presets, more than the five this row lists); prefs_ui.rs display_scale resolves it (Auto follows the system scale, fractional and unscaled 4K included, #1072/#1278) and sync_display_scale applies it with ctx.set_zoom_factor. It is a preference, so it is remembered. The zoom_factor assert in input_tests.rs is about canvas zoom not scaling the interface, not about this setting | | UI-217-10 | Appearance: animation toggle, canvas pattern toggle per theme, start-screen recents | partial | [#217](https://github.com/storytold/photocraft/issues/217) | interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes egui animation_time and scroll animation in both styles; the Home screen lists recent files (canvas.rs home_recent, #2691); no canvas pattern toggle yet (#2264) and no option to hide the Home screen recents | -| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes | +| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | partial | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing | | UI-217-12 | Save custom New Document presets; a real Recent category | done | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent lists Clipboard, the last 20 documents created in the dialog (document.recent.*, persisted with the presets, newest first, deduplicated) and the uncovered defaults (#3131); created_documents_fill_the_recent_category | | UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click | | UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) | diff --git a/docs/ui-design.md b/docs/ui-design.md index 91fac93374..4636cdb7cd 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -14,6 +14,8 @@ | Solarized Dark | Ethan Schoonover's Solarized palette: base03 canvas, base02 panels, base0 text, Solarized blue accent | | Breeze Light | An adaptation of KDE Plasma's Breeze Light on the Studio layout: cool light blue-grey surfaces a few levels from Breeze's so the app blends into a Plasma desktop, Breeze-style blue accent, 5 px radii, red close button on hover; text and status colours are darker than Breeze's for contrast | | Breeze Dark | The same for Breeze Dark: cool dark blue-grey surfaces (darkest canvas, then dock, cards, chrome and controls) near Breeze's, blue accent, 5 px radii; secondary text and status colours are lighter than Breeze's for contrast | +| Soft Dark | Low-contrast dark on the Studio layout: muted violet-grey surfaces (never near-black), softened off-white text, periwinkle accent, generous 7/10/14 px radii; text still holds 4.5:1 on every surface (`theme.rs` tests) | +| Soft Light | The light counterpart: pale violet-grey surfaces, softened dark-violet text instead of black, periwinkle accent, the same radii | Both Breeze themes are our own values close to Plasma's (not a copy of its colour schemes) and put text readability first: every text colour holds 4.5:1 on every surface and on hovered, pressed and selected states (`theme.rs` tests), so their secondary text, accent and status colours are darker (Light) or lighter (Dark) than Breeze's own, and the primary button is a deeper blue with white text. @@ -21,7 +23,7 @@ Edit → Preferences → Interface shows an Appearance Mode selector (Sync with Window → Theme and macOS PhotoCraft → Appearance also offer Sync with system. Its checkmark follows the saved Auto mode rather than the displayed palette; choosing a manual theme selects its fixed appearance mode. -The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light and Breeze Dark (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. +The toolbar's foreground and background colour chips follow the `Tokens::round_chips` flag. Pro and Classic leave it off and draw Photoshop's overlapping squares with Default Colors and Switch Colors above them. Studio, Studio Light, Solarized Dark, Adwaita, Adwaita Dark, Breeze Light, Breeze Dark, Soft Dark and Soft Light (the Studio-layout themes) turn it on and draw large round chips with a curved Switch Colors arrow centred right under them, its heads touching the chips: stacked vertically in one tool column, with Default Colors at their top-right, and side by side in two tool columns, with Default Colors at the toolbar's left edge. Another non-Pro theme can opt in by setting the flag. ## Rules diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 54cc13876c..84f071b4a2 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -78,9 +78,9 @@ note = "interface.enableAnimations (prefs_ui.rs sync_animations, #3273) zeroes e [[item]] id = "UI-217-11" target = "Soft dark and soft light themes with picker previews; outlined knobs in light themes" -status = "missing" +status = "partial" issue = 217 -note = "theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes" +note = "theme.rs ThemeKind has fourteen themes: the twelve earlier ones plus Soft Dark and Soft Light (low-contrast Studio-layout palettes, text at 4.5:1, selectable in Preferences with the existing theme-card previews, ui.set {theme}, Window > Theme); outlined knobs in the light themes are still missing" [[item]] id = "UI-217-12" From 15aac8d7b6e97939f06551857b66cb0c96920f7d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Tue, 13 Oct 2026 05:11:09 +0200 Subject: [PATCH 317/523] Type: type on a path, glyphs follow the curve (#4365) (#4377) * Type: type on a path, glyphs follow the curve (#4365) Add TextShape::Path (path, start offset, flip). The text is laid out as one point-text line and each glyph is placed rigidly on the flattened path by arc length, turned to the tangent at its centre; glyphs past the end are hidden and a degenerate path falls back to straight text. New commands type.attachToPath (work path or shape layer subpath) and type.detachFromPath. TextShape is no longer Copy. .pcraft round-trips the new variant. Co-Authored-By: Claude Opus 5.5 * Automation policy: type.attachToPath/detachFromPath take document vector paths, not host files (#4365) Co-Authored-By: Claude Opus 5.5 * docs: regenerate scorecard after merging main Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/automation/src/workspace.rs | 2 + crates/doc/src/text.rs | 9 +- crates/engine/src/type_extra_cmds.rs | 102 +++++++++++++++- crates/engine/src/type_extra_cmds/tests.rs | 30 +++++ crates/format/src/convert.rs | 4 +- crates/format/tests/roundtrip.rs | 21 ++++ crates/io/src/text_import.rs | 2 +- crates/io/tests/psd_type_bounds.rs | 2 +- crates/io/tests/psd_vertical_type.rs | 2 +- crates/text/src/layout.rs | 3 +- crates/text/src/lib.rs | 2 +- crates/text/src/psd.rs | 11 +- crates/text/src/render.rs | 49 +++++++- crates/text/src/tests.rs | 64 +++++++++- crates/text/src/warp.rs | 134 +++++++++++++++++++-- crates/ui-egui/src/type_tool.rs | 4 +- crates/ui-egui/src/type_transform.rs | 2 +- docs/scorecard.md | 4 +- scorecard/type.toml | 4 +- 19 files changed, 416 insertions(+), 35 deletions(-) diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index eaed0e1c65..cde3a3a702 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -858,6 +858,8 @@ mod tests { | "path.clippingPath.set" | "path.rename" | "path.stroke" + | "type.attachToPath" + | "type.detachFromPath" | "select.toWorkPath" | "layer.vectorMask.add" | "layer.vectorMask.edit" diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index fc586e3c2c..6f7836fa46 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -209,13 +209,18 @@ pub struct ParagraphRun { } /// Point text grows from its anchor (text-space origin = first baseline at the anchor); -/// paragraph (box) text wraps inside a rectangle. -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +/// paragraph (box) text wraps inside a rectangle; path text runs along a curve. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub enum TextShape { #[default] Point, /// Text-space pixels, before the layer transform. Box { x: f32, y: f32, width: f32, height: f32 }, + /// Type on a path: one line laid out like point text, then each glyph is placed on `path` + /// (text-space pixels, before the layer transform) by arc length and turned to follow the + /// curve. `start` is the arc-length offset (px) of the text origin along the path; `flip` + /// puts the text on the other side of the path. + Path { path: super::vector::Subpath, start: f32, flip: bool }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/engine/src/type_extra_cmds.rs b/crates/engine/src/type_extra_cmds.rs index a11db9976b..1e7556b1d4 100644 --- a/crates/engine/src/type_extra_cmds.rs +++ b/crates/engine/src/type_extra_cmds.rs @@ -44,6 +44,7 @@ fn has_point_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Point => Ok(()), TextShape::Box { .. } => Err("the type layer is already paragraph text".into()), + TextShape::Path { .. } => Err("detach the type layer from its path first".into()), } } @@ -51,6 +52,7 @@ fn has_box_text(s: &Session) -> std::result::Result<(), String> { match active_text(s)?.shape { TextShape::Box { .. } => Ok(()), TextShape::Point => Err("the type layer is already point text".into()), + TextShape::Path { .. } => Err("the type layer is path text".into()), } } @@ -279,7 +281,7 @@ fn contours(els: &[PathEl]) -> Vec> { /// which reproduces the nonzero fill under Photoshop's per-subpath path operations. pub fn text_path(doc: &Document, t: &TextLayer) -> Path { let l = layout(doc, t); - let warp = photocraft_text::render::layout_warp(&l, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&l, t); let mut subpaths = Vec::new(); for glyph in photocraft_text::render::outlines(&l, &t.transform, warp.as_ref()) { let cs = contours(&glyph); @@ -321,6 +323,95 @@ fn convert_to_shape(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": id.0, "subpaths": n})) } +// ---------- type on a path ---------- + +/// Largest anchor count of a path that type can follow (a hostile document cannot make the +/// layout walk millions of segments). +const MAX_TYPE_PATH_KNOTS: usize = 100_000; + +/// The subpath `type.attachToPath` follows, in document space: the work path (default) or a +/// shape layer's path (`"shape":id`), subpath `"subpath"` (default 0). +fn path_source(doc: &Document, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let path = match p.get("shape") { + None | Some(Value::Null) => doc.work_path.as_ref().ok_or_else(|| bad("the document has no work path; draw a path or pass \"shape\":layerId"))?, + Some(v) => { + let id = v.as_u64().ok_or_else(|| bad("\"shape\" must be a layer id"))?; + match doc.layer(LayerId(id)).map(|l| &l.content) { + Some(LayerContent::Shape(sh)) => &sh.path, + Some(_) => return Err(bad("\"shape\" is not a shape layer")), + None => return Err(EngineError::NoLayer(LayerId(id))), + } + } + }; + let index = match p.get("subpath") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("\"subpath\" must be a non-negative index"))?, + }; + let sub = path.subpaths.get(index).ok_or_else(|| bad("the path has no such subpath"))?; + if sub.knots.len() < 2 { + return Err(bad("the subpath needs at least two anchor points")); + } + if sub.knots.len() > MAX_TYPE_PATH_KNOTS { + return Err(bad("the subpath has too many anchor points")); + } + Ok(sub.clone()) +} + +fn attach_to_path(s: &mut Session, p: &Value) -> Result { + let bad = |m: &str| EngineError::BadParams { cmd: "type.attachToPath".into(), msg: m.into() }; + let start = match p.get("start") { + None | Some(Value::Null) => 0.0, + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("\"start\" must be a finite number of pixels"))? as f32, + }; + let flip = match p.get("flip") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("\"flip\" must be true or false"))?, + }; + let id = layer_param(s, p)?; + let doc = &s.active().ok_or(EngineError::NoDocument)?.doc; + if doc.effective_locks(id).all { + return Err(EngineError::Other("the type layer is locked".into())); + } + let sub = path_source(doc, p)?; + with_text(s, p, "Type on a Path", |t, _| { + // The path lives in document space; the type layer keeps it in its own text space, so + // the type lands on the path where it is drawn. + let inv = t.transform.inverse().ok_or_else(|| EngineError::Other("the type layer's transform cannot be inverted".into()))?; + let map = |q: Point| inv.apply(q); + let knots = sub.knots.iter().map(|k| Knot { anchor: map(k.anchor), in_ctrl: map(k.in_ctrl), out_ctrl: map(k.out_ctrl), smooth: k.smooth }).collect(); + let path = Subpath { closed: sub.closed, knots, op: PathOp::Combine }; + if !path.knots.iter().all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|q| q.x.is_finite() && q.y.is_finite())) { + return Err(EngineError::Other("the path cannot be mapped into the type layer".into())); + } + t.shape = TextShape::Path { path, start, flip }; + Ok(()) + })?; + Ok(json!({"layer": id.0, "start": start, "flip": flip})) +} + +fn is_path_text(s: &Session) -> std::result::Result<(), String> { + match active_text(s)?.shape { + TextShape::Path { .. } => Ok(()), + _ => Err("the type layer is not on a path".into()), + } +} + +fn detach_from_path(s: &mut Session, p: &Value) -> Result { + let id = layer_param(s, p)?; + with_text(s, p, "Release Type from Path", |t, _| { + if !matches!(t.shape, TextShape::Path { .. }) { + return Err(EngineError::Other("the type layer is not on a path".into())); + } + // Point text starts where the type started on the path. + let origin = photocraft_text::warp::Warp::on_path(&t.shape).map_or((0.0, 0.0), |w| w.apply(0.0, 0.0)); + t.shape = TextShape::Point; + t.transform = then_translate(&t.transform, origin.0, origin.1); + Ok(()) + })?; + Ok(json!({"layer": id.0})) +} + // ---------- warp ---------- fn warp_text(s: &mut Session, p: &Value) -> Result { @@ -588,6 +679,15 @@ pub fn specs() -> Vec { has_format_target, warp_text ), + spec!( + "type.attachToPath", + "Type on a Path", + &[], + r##"{"layer":id? (default active Type layer),"shape":layerId? (a shape layer; default the work path),"subpath":index=0,"start":px=0 (offset along the path),"flip":bool=false (run along the path the other way, on its other side)}"##, + has_text, + attach_to_path + ), + spec!("type.detachFromPath", "Release Type from Path", &[], LP, is_path_text, detach_from_path), spec!("type.updateAllTextLayers", "Update All Text Layers", &["Type"], "{}", any_text, |s, _| update_all(s)), spec!("type.replaceAllMissingFonts", "Replace All Missing Fonts", &["Type"], "{} (with the default family)", any_text, |s, _| replace_fonts( s, diff --git a/crates/engine/src/type_extra_cmds/tests.rs b/crates/engine/src/type_extra_cmds/tests.rs index 395128a440..c0c091b10c 100644 --- a/crates/engine/src/type_extra_cmds/tests.rs +++ b/crates/engine/src/type_extra_cmds/tests.rs @@ -283,6 +283,36 @@ fn rasterize_type_layer() { assert!(!s.is_enabled("type.rasterizeTypeLayer")); } +#[test] +fn type_attaches_to_a_path_and_releases_it() { + let mut s = session(8); + let id = text_layer(&mut s, "Hello path"); + assert!(s.is_enabled("type.attachToPath")); + assert!(!s.is_enabled("type.detachFromPath")); + // No work path yet, a bad subpath and bad params are errors, not panics. + assert!(s.execute("type.attachToPath", json!({})).is_err()); + let flat = ink(&s, id); + s.edit("path", |doc, _| { + doc.work_path = Some(Path::new(vec![Subpath::polyline(&[(150.0, 10.0), (150.0, 110.0)])])); + Ok(()) + }) + .unwrap(); + assert!(s.execute("type.attachToPath", json!({"subpath": 5})).is_err()); + assert!(s.execute("type.attachToPath", json!({"start": "far"})).is_err()); + assert!(s.execute("type.attachToPath", json!({"shape": 9999})).is_err()); + s.execute("type.attachToPath", json!({"start": 4})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Path { start, .. } if start == 4.0)); + let on_path = ink(&s, id); + assert!(on_path.height() > on_path.width(), "the type runs down the vertical path: {on_path:?}"); + assert!(on_path.height() > flat.height()); + assert!(!s.is_enabled("type.convertToPointText")); + s.execute("type.detachFromPath", json!({})).unwrap(); + assert_eq!(text(&s, id).shape, TextShape::Point); + assert!(ink(&s, id).width() > ink(&s, id).height()); + assert!(s.undo()); + assert!(matches!(text(&s, id).shape, TextShape::Path { .. })); +} + #[test] fn all_locked_layer_rejects_paste_lorem_ipsum_without_history() { // #4188: Lock All stopped type.edit (#3132) but not Paste Lorem Ipsum into the layer. diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 47ea5383d7..a26219033b 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -140,7 +140,7 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { psd_raw: opt_blob(&t.psd_raw, sink), runs: t.runs.clone(), paragraphs: t.paragraphs.clone(), - shape: t.shape, + shape: t.shape.clone(), orientation: t.orientation, antialias: t.antialias, warp: t.warp.clone(), @@ -459,7 +459,7 @@ impl Loader<'_> { psd_raw: self.opt_blob(psd_raw)?, runs: runs.clone(), paragraphs: paragraphs.clone(), - shape: *shape, + shape: shape.clone(), orientation: *orientation, antialias: *antialias, warp: warp.clone(), diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 42dbfae5c3..8b74a00c4b 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -541,6 +541,27 @@ fn text_kerning_roundtrips_and_defaults() { assert!(s.contains("\"kern\":0.0"), "kern defaults to 0"); } +/// Type on a path (#218): the path, start offset and flip survive .pcraft. +#[test] +fn path_text_shape_roundtrips() { + use photocraft_doc::text::TextShape; + use photocraft_doc::vector::Subpath; + let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8); + let path = Subpath::polyline(&[(0.0, 0.0), (50.0, 20.0), (120.0, 5.5)]); + let ids: Vec = doc.walk().into_iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect(); + for id in ids { + if let Some(LayerContent::Text(t)) = doc.layer_mut(id).map(|l| &mut l.content) { + t.shape = TextShape::Path { path: path.clone(), start: 12.5, flip: true }; + } + } + let back = load_from_bytes(&save_to_bytes(&doc, &SaveOptions::default()).unwrap()).unwrap(); + let shape = back.walk().into_iter().find_map(|(_, _, l)| match &l.content { + LayerContent::Text(t) => Some(t.shape.clone()), + _ => None, + }); + assert_eq!(shape, Some(TextShape::Path { path, start: 12.5, flip: true })); +} + #[test] fn channel_restrictions_and_bevel_elements_roundtrip() { use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect}; diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9159f704dd..595c887be6 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -11,7 +11,7 @@ pub(crate) const MAX_RENDER_PIXELS: u64 = 64 * 1024 * 1024; /// Renders `t` from its model unless its ink would exceed [`MAX_RENDER_PIXELS`]. fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: f32, format: photocraft_color::PixelFormat) { let layout = eng.layout(t, dpi); - let warp = photocraft_text::render::layout_warp(&layout, t.warp.as_ref()); + let warp = photocraft_text::render::layout_deform(&layout, t); let ink = photocraft_text::render::ink_rect_warped(&layout, &t.transform, warp.as_ref()); if u64::from(ink.width()).saturating_mul(u64::from(ink.height())) > MAX_RENDER_PIXELS { t.cache = Some(photocraft_raster::Surface::new(photocraft_color::PixelFormat { alpha: true, ..format })); diff --git a/crates/io/tests/psd_type_bounds.rs b/crates/io/tests/psd_type_bounds.rs index 95e7ddfa13..8fabe8be24 100644 --- a/crates/io/tests/psd_type_bounds.rs +++ b/crates/io/tests/psd_type_bounds.rs @@ -261,7 +261,7 @@ fn imported_type_layers_keep_their_full_bounds() { // Model: size in points at the document resolution, transform and shape as written. assert!((t.runs[0].style.size_pt - c.size_pt).abs() < 0.01, "{}: size {} pt, want {}", c.name, t.runs[0].style.size_pt, c.size_pt); assert_eq!(t.transform, c.transform, "{}", c.name); - match (c.box_bounds, t.shape) { + match (c.box_bounds, t.shape.clone()) { (None, TextShape::Point) => {} (Some(b), TextShape::Box { x, y, width, height }) => { assert_eq!([x, y, x + width, y + height].map(f64::from), b, "{}", c.name) diff --git a/crates/io/tests/psd_vertical_type.rs b/crates/io/tests/psd_vertical_type.rs index e69f725da7..93cd1a8858 100644 --- a/crates/io/tests/psd_vertical_type.rs +++ b/crates/io/tests/psd_vertical_type.rs @@ -34,7 +34,7 @@ fn text(doc: &Document) -> &TextLayer { #[test] fn vertical_orientation_round_trips_through_psd() { for shape in [TextShape::Point, TextShape::Box { x: -200.0, y: 0.0, width: 220.0, height: 300.0 }] { - let doc = doc_with(shape); + let doc = doc_with(shape.clone()); let ink = text(&doc).cache.as_ref().unwrap().content_bounds(); assert!(ink.height() > ink.width(), "{shape:?}: {ink:?}"); let out = photocraft_io::export(&doc, "v.psd", &Default::default()).unwrap(); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fdbb8a01f..720347d622 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -483,7 +483,8 @@ impl Layouter { let text = &t.text; let (box_rect, is_box) = match t.shape { TextShape::Box { x, y, width, height } => ((x, y, width, height), true), - TextShape::Point => ((0.0, 0.0, 0.0, 0.0), false), + // Path text is laid out as one point-text line, then bent onto the path. + TextShape::Point | TextShape::Path { .. } => ((0.0, 0.0, 0.0, 0.0), false), }; let mut prev_baseline: Option = None; let mut pending_space = 0.0f32; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 35e7dccbdf..64db53ece1 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -125,7 +125,7 @@ impl TextEngine { /// Lays out and rasterizes a text layer into document space. pub fn render(&mut self, layer: &TextLayer, dpi: f32, format: PixelFormat) -> (TextLayout, Rendered) { let l = self.layout(layer, dpi); - let warp = render::layout_warp(&l, layer.warp.as_ref()); + let warp = render::layout_deform(&l, layer); let r = render::rasterize_warped(&l, &layer.transform, format, layer.antialias, warp.as_ref()); (l, r) } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 27b1909510..8a915c2d04 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -244,7 +244,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { let m = arr_f(frame.path(&["2", "2"])); let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; let at = match layer.shape { - TextShape::Point => { + TextShape::Point | TextShape::Path { .. } => { let p = arr_f(frame.get("0")); if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } } @@ -260,7 +260,7 @@ pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { // the frame folded into its transform and its `bounds` near the origin: applying the frame // again would move it twice. let origin = match layer.shape { - TextShape::Point => Point::new(0.0, 0.0), + TextShape::Point | TextShape::Path { .. } => Point::new(0.0, 0.0), TextShape::Box { .. } => { let b = engine_data(&t.text) .map(|e| { @@ -1183,7 +1183,8 @@ pub fn build_engine_data(layer: &TextLayer, template: Option, dpi: f32) -> E dict.set("UseFractionalGlyphWidths", E::Bool(true)); } let (shape_type, box_bounds) = match layer.shape { - TextShape::Point => (0, None), + // PSD path type is not written yet: the layer saves as point text. + TextShape::Point | TextShape::Path { .. } => (0, None), TextShape::Box { x, y, width, height } => (1, Some([x, y, x + width, y + height])), }; let mut photoshop = E::Dict(vec![("ShapeType".into(), E::Int(shape_type))]); @@ -1296,8 +1297,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec }, ), ); - let [l, t, r, b] = match (layer.shape, ink) { - (TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], + let [l, t, r, b] = match (&layer.shape, ink) { + (&TextShape::Box { x, y, width, height }, _) => [x, y, x + width, y + height], (_, Some(i)) => i, _ => [0.0; 4], }; diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index b50a63fe0a..af29dd10d6 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -10,7 +10,7 @@ use skrifa::{GlyphId, MetadataProvider}; use crate::layout::{GlyphOrient, PlacedGlyph, TextLayout}; use crate::raster::{Bounds, Coverage, LineSink, Pen, Xform, rect_to}; -use crate::warp::Warp; +use crate::warp::{Placement, Warp}; /// Faux-italic slant in degrees (Photoshop-like). pub const FAUX_ITALIC_DEG: f32 = 12.0; @@ -90,7 +90,19 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); + let mut glyph = glyph_xform(layout, g, face.skew_deg); + // Type on a path moves each glyph rigidly: turned to the curve at its centre. + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } let bold = st.faux_bold || face.embolden; let r = (face.size_px * FAUX_BOLD_RADIUS) as f64; let single = [(0.0, 0.0)]; @@ -107,7 +119,7 @@ fn draw_pass(layout: &TextLayout, transform: &Xform, sink: &mut impl LineSink, o for &(ox, oy) in offsets { let local = glyph.mul(&Xform([1.0, 0.0, 0.0, 1.0, ox * r, oy * r])); let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); - match warp { + match bend { Some(w) => { let mut ws = WarpSink { inner: &mut *sink, warp: w, post: *transform }; let mut pen = Pen::new(&mut ws, local); @@ -161,6 +173,22 @@ fn glyph_xform(layout: &TextLayout, g: &PlacedGlyph, face_skew_deg: f32) -> Xfor } } +/// The horizontal centre (text space) of a placed glyph: the pen position plus half its advance. +fn glyph_center(layout: &TextLayout, g: &PlacedGlyph, font: &skrifa::FontRef, size_px: f32, coords: &[NormalizedCoord]) -> f64 { + let hs = layout.styles.get(g.style as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }); + let advance = match g.orient { + GlyphOrient::Horizontal => font.glyph_metrics(Size::new(size_px), LocationRef::new(coords)).advance_width(GlyphId::new(g.id)).unwrap_or(0.0), + _ => 0.0, + }; + f64::from(g.x) + f64::from(advance * hs) / 2.0 +} + +/// The deformation of a type layer: the path it runs along, or its Warp Text settings (None +/// when neither applies). +pub fn layout_deform(layout: &TextLayout, layer: &photocraft_doc::TextLayer) -> Option { + Warp::on_path(&layer.shape).or_else(|| layout_warp(layout, layer.warp.as_ref())) +} + /// The warp to apply to `layout` (None when `warp` is absent, `warpNone` or flat). pub fn layout_warp(layout: &TextLayout, warp: Option<&photocraft_doc::text::TextWarp>) -> Option { Warp::new(warp?, layout.bounds()?) @@ -347,8 +375,19 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> continue; }; let coords: Vec = face.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect(); - let glyph = glyph_xform(layout, g, face.skew_deg); - let mut rec = Recorder { glyph, post: Xform(transform.m), warp, out: Vec::new() }; + let mut glyph = glyph_xform(layout, g, face.skew_deg); + let mut bend = warp; + if let Some(w) = warp { + match w.place_glyph(glyph_center(layout, g, &font, face.size_px, &coords)) { + Placement::Bend => {} + Placement::Hidden => continue, + Placement::Frame(m) => { + glyph = Xform(m).mul(&glyph); + bend = None; + } + } + } + let mut rec = Recorder { glyph, post: Xform(transform.m), warp: bend, out: Vec::new() }; let settings = DrawSettings::unhinted(Size::new(face.size_px), LocationRef::new(&coords)); if outline.draw(settings, &mut rec).is_ok() && !rec.out.is_empty() { glyphs.push(rec.out); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 832f0f0d85..2dc8905448 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -152,7 +152,7 @@ fn tabs_advance_to_the_next_default_tab_stop() { #[test] fn psd_round_trips_tabs() { for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { - let t = TextLayer { shape, ..point("Name\tValue\n\tIndented", 20.0) }; + let t = TextLayer { shape: shape.clone(), ..point("Name\tValue\n\tIndented", 20.0) }; let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); assert_eq!(back.text, t.text, "{shape:?}"); } @@ -1892,3 +1892,65 @@ fn engine_data_antialias_numbers_match_photoshop() { assert_eq!(back.antialias, mode, "{n}"); } } + +/// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs +/// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. +#[test] +fn type_on_a_path_follows_the_curve() { + use photocraft_doc::vector::{Knot, Subpath}; + let fmt = PixelFormat::RGBA8; + let mut eng = TextEngine::new(); + let ink = |eng: &mut TextEngine, t: &TextLayer| { + let (_, r) = eng.render(t, 72.0, fmt); + r.rect + }; + let straight = point("Path text", 24.0); + let flat = ink(&mut eng, &straight); + assert!(flat.width() > flat.height() * 3, "{flat:?}"); + + let mut down = straight.clone(); + down.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 400.0)]), start: 0.0, flip: false }; + let r = ink(&mut eng, &down); + assert!(r.height() > r.width() * 3, "vertical path gives tall ink: {r:?}"); + assert!(r.height() >= flat.width() - 8, "arc length is preserved: {r:?} vs {flat:?}"); + + // A half circle bends the text through a larger area than the straight line. + let k = 0.5523 * 60.0; + let arc = Subpath { + closed: false, + knots: vec![ + Knot { + anchor: photocraft_geom::Point::new(-60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(-60.0, 0.0), + out_ctrl: photocraft_geom::Point::new(-60.0, -k), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(0.0, -60.0), + in_ctrl: photocraft_geom::Point::new(-k, -60.0), + out_ctrl: photocraft_geom::Point::new(k, -60.0), + smooth: true, + }, + Knot { + anchor: photocraft_geom::Point::new(60.0, 0.0), + in_ctrl: photocraft_geom::Point::new(60.0, -k), + out_ctrl: photocraft_geom::Point::new(60.0, 0.0), + smooth: true, + }, + ], + ..Default::default() + }; + let mut curved = straight.clone(); + curved.shape = TextShape::Path { path: arc.clone(), start: 0.0, flip: false }; + let c = ink(&mut eng, &curved); + assert!(c.height() > flat.height() * 2, "curved ink is taller: {c:?} vs {flat:?}"); + + // A path too short for the text hides the glyphs past its end. + let mut short = straight.clone(); + short.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (30.0, 0.0)]), start: 0.0, flip: false }; + assert!(ink(&mut eng, &short).width() < flat.width() / 2); + // A degenerate path falls back to straight point text instead of failing. + let mut dot = straight.clone(); + dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; + assert_eq!(ink(&mut eng, &dot), flat); +} diff --git a/crates/text/src/warp.rs b/crates/text/src/warp.rs index e056bdff33..f148a18e1b 100644 --- a/crates/text/src/warp.rs +++ b/crates/text/src/warp.rs @@ -6,7 +6,8 @@ //! formulas), the identity at `bend = 0` and continuous in `bend`. The mapping is evaluated per //! point; the renderer subdivides long segments so straight edges bend. -use photocraft_doc::text::TextWarp; +use photocraft_doc::text::{TextShape, TextWarp}; +use photocraft_doc::vector::Subpath; use photocraft_geom::warp::{StyleWarp, WarpStyle}; /// Warp styles in Photoshop's menu order: (PSD `warpStyle` value, short id used by commands). @@ -45,10 +46,29 @@ pub fn short_style(psd: &str) -> Option<&'static str> { STYLES.iter().find(|(p, _)| *p == psd).map(|(_, short)| *short) } +/// Where a glyph goes when the layout is bent: see [`Warp::place_glyph`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Placement { + /// Not path text: bend the outline point by point with [`Warp::apply`]. + Bend, + /// Beyond either end of the path: the glyph is not drawn. + Hidden, + /// Path text: the glyph keeps its shape and is moved rigidly by this text-space map + /// (`[a, b, c, d, e, f]`, applied to text-space points). + Frame([f64; 6]), +} + /// A warp prepared for one layout: the shared style math of -/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box. -#[derive(Clone, Copy, Debug)] -pub struct Warp(StyleWarp); +/// [`photocraft_geom::warp::StyleWarp`] (also used by Edit › Transform › Warp) over the text box, +/// or a path the type runs along. +#[derive(Clone, Debug)] +pub struct Warp(Kind); + +#[derive(Clone, Debug)] +enum Kind { + Style(Box), + Path(PathFrame), +} impl Warp { /// Prepares `w` for text whose bounds are `[x0, y0, x1, y1]` (text space). `None` for @@ -56,17 +76,117 @@ impl Warp { pub fn new(w: &TextWarp, bounds: [f32; 4]) -> Option { let style = WarpStyle::parse(&w.style).filter(|s| s.is_preset() && s.psd_name() == w.style)?; StyleWarp::new(style, f64::from(w.value), f64::from(w.horizontal_distortion), f64::from(w.vertical_distortion), !w.horizontal, bounds.map(f64::from)) - .map(Warp) + .map(|s| Warp(Kind::Style(Box::new(s)))) + } + + /// The deformation of type on a path (`None` for any other shape, or a path without length). + pub fn on_path(shape: &TextShape) -> Option { + let TextShape::Path { path, start, flip } = shape else { return None }; + PathFrame::new(path, *start, *flip).map(|f| Warp(Kind::Path(f))) } /// Longest segment (text-space px) worth sending through [`Warp::apply`] unsplit. pub fn max_segment(&self) -> f64 { - self.0.max_segment() + match &self.0 { + Kind::Style(s) => s.max_segment(), + Kind::Path(_) => 2.0, + } } /// Maps a text-space point. pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { - self.0.apply(x, y) + match &self.0 { + Kind::Style(s) => s.apply(x, y), + Kind::Path(p) => p.apply(x, y), + } + } + + /// How to place a glyph whose horizontal centre is at text-space `cx`. + pub fn place_glyph(&self, cx: f64) -> Placement { + match &self.0 { + Kind::Style(_) => Placement::Bend, + Kind::Path(p) => p.frame(cx).map_or(Placement::Hidden, Placement::Frame), + } + } +} + +/// Most cubic segments of a type path that are followed (a hostile file cannot make this large). +const MAX_PATH_SEGMENTS: usize = 4096; + +/// A flattened path with arc lengths: text-space `x` is arc length from `start`, `y` the +/// distance to the left of the direction of travel (the side the glyph tops point to). +#[derive(Clone, Debug)] +struct PathFrame { + pts: Vec<(f64, f64)>, + /// Arc length at each point. + cum: Vec, + start: f64, +} + +impl PathFrame { + fn new(path: &Subpath, start: f32, flip: bool) -> Option { + let mut pts: Vec<(f64, f64)> = Vec::new(); + for seg in path.segments().iter().take(MAX_PATH_SEGMENTS) { + let p = seg.map(|q| (q.x, q.y)); + if p.iter().any(|q| !q.0.is_finite() || !q.1.is_finite()) { + return None; + } + let poly = (p[1].0 - p[0].0).hypot(p[1].1 - p[0].1) + (p[2].0 - p[1].0).hypot(p[2].1 - p[1].1) + (p[3].0 - p[2].0).hypot(p[3].1 - p[2].1); + let n = ((poly / 2.0).ceil() as usize).clamp(4, 128); + if pts.is_empty() { + pts.push(p[0]); + } + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + pts.push((a * p[0].0 + b * p[1].0 + c * p[2].0 + d * p[3].0, a * p[0].1 + b * p[1].1 + c * p[2].1 + d * p[3].1)); + } + } + // Drop repeated points so every piece has a direction. + pts.dedup_by(|b, a| (b.0 - a.0).abs() < 1e-9 && (b.1 - a.1).abs() < 1e-9); + if flip { + pts.reverse(); + } + let mut cum = Vec::with_capacity(pts.len()); + let mut acc = 0.0; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + acc += (p.0 - q.0).hypot(p.1 - q.1); + } + cum.push(acc); + } + (pts.len() >= 2 && acc.is_finite() && acc > 1e-6 && start.is_finite()).then(|| PathFrame { pts, cum, start: f64::from(start) }) + } + + fn length(&self) -> f64 { + self.cum.last().copied().unwrap_or(0.0) + } + + /// Point and unit tangent at arc length `s`; past either end the end tangent continues straight. + fn at(&self, s: f64) -> ((f64, f64), (f64, f64)) { + let last = self.pts.len() - 1; + let i = self.cum.partition_point(|&c| c < s).clamp(1, last); + let (a, b) = (self.pts[i - 1], self.pts[i]); + let len = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); + let t = ((b.0 - a.0) / len, (b.1 - a.1) / len); + let along = s - self.cum[i - 1]; + ((a.0 + t.0 * along, a.1 + t.1 * along), t) + } + + fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let (p, t) = self.at(self.start + x); + (p.0 - t.1 * y, p.1 + t.0 * y) + } + + /// The rigid map for a glyph centred at text-space `cx`; `None` outside the path. + fn frame(&self, cx: f64) -> Option<[f64; 6]> { + let s = self.start + cx; + if !(0.0..=self.length()).contains(&s) { + return None; + } + let (p, t) = self.at(s); + Some([t.0, t.1, -t.1, t.0, p.0 - t.0 * cx, p.1 - t.1 * cx]) } } diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 6a7c816084..3238674adf 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -184,7 +184,7 @@ fn handle_sides(i: u8) -> (bool, bool, bool, bool) { } pub(crate) fn box_shape(app: &PhotocraftApp, id: LayerId) -> Option<(f32, f32, f32, f32)> { - match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape))? { + match app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone()))? { photocraft_doc::text::TextShape::Box { x, y, width, height } => Some((x, y, width, height)), _ => None, } @@ -1013,7 +1013,7 @@ pub fn draw_overlay(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewX if crate::type_transform::draw(app, painter, xf, mods) { return; } - let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape)); + let shape = app.session.active().and_then(|s| text_layer(&s.doc, id).map(|t| t.shape.clone())); let frame = Stroke::new(1.0, t.accent); match shape { Some(photocraft_doc::text::TextShape::Box { x, y, width, height }) => { diff --git a/crates/ui-egui/src/type_transform.rs b/crates/ui-egui/src/type_transform.rs index d99334cba9..784162b25c 100644 --- a/crates/ui-egui/src/type_transform.rs +++ b/crates/ui-egui/src/type_transform.rs @@ -85,7 +85,7 @@ pub(crate) fn frame(app: &mut PhotocraftApp) -> Option { return None; } let LayerContent::Text(text) = &layer.content else { return None }; - let shape = text.shape; + let shape = text.shape.clone(); let size = text.size_pt; let (layout, aff, _) = crate::type_tool::layout(app, id)?; if !aff.m.iter().all(|v| v.is_finite()) || aff.inverse().is_none() { diff --git a/docs/scorecard.md b/docs/scorecard.md index 80c047bb41..6741f981e6 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -10,7 +10,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js | [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | | [Tools](#tools) | 28 | 14 | 1 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 156 (target 0) | -| [Type](#type) | 4 | 2 | 3 | | +| [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | | [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present | | [Distribution](#distribution) | 5 | 4 | 2 | | @@ -210,7 +210,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w | Id | Target | Status | Issue | Note | |---|---|---|---|---| -| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | missing | [#218](https://github.com/storytold/photocraft/issues/218) | doc text.rs:209 TextShape has Point and Box only | +| TYPE-218-1 | Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip | partial | [#218](https://github.com/storytold/photocraft/issues/218) | TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text) | | TYPE-218-2 | Paragraph direction (Auto/LTR/RTL) in the panel and options bar; visual RTL caret | missing | [#218](https://github.com/storytold/photocraft/issues/218) | Engine has TextDirection (text.rs:155); no UI | | TYPE-218-3 | Thai/Khmer/Indic fallback fonts; golden shaping tests for complex scripts and RTL | partial | [#218](https://github.com/storytold/photocraft/issues/218) | Fallback fonts are covered for Thai, Khmer, Lao, Myanmar (THAI_FAMILIES, KHMER_FAMILIES, LAO_FAMILIES, MYANMAR_FAMILIES in fonts.rs; #1909, #3259), the nine Indic scripts (INDIC_FAMILIES: Nirmala UI, Kohinoor and Sangam MN, Noto Sans) and Arabic and Hebrew. Still missing: per-script golden shaping tests against reference output (only smoke tests that an installed font shapes without .notdef, plus a bidi test) | | TYPE-218-4 | Variable font axis sliders from the font's axes; opsz follows the point size | done | [#218](https://github.com/storytold/photocraft/issues/218) | One slider per axis of the style's font in the Character section (Properties and Window > Character), setting type.setStyle variations; opsz follows the type size unless set (layout.rs variation_list); a style or weight resets the wght axis (#2985). PSD does not round-trip axis values yet | diff --git a/scorecard/type.toml b/scorecard/type.toml index 078c956a3f..ee8a876a41 100644 --- a/scorecard/type.toml +++ b/scorecard/type.toml @@ -7,9 +7,9 @@ order = 5 [[item]] id = "TYPE-218-1" target = "Type on a path: path shape, glyphs along the curve, caret follows, PSD round trip" -status = "missing" +status = "partial" issue = 218 -note = "doc text.rs:209 TextShape has Point and Box only" +note = "TextShape::Path (doc text.rs) with glyphs placed rigidly along the curve by arc length (text warp.rs PathFrame), type.attachToPath and type.detachFromPath (#4365), .pcraft round trip. Missing: clicking a path with the Type tool, the caret and selection following the curve, and PSD path text (it saves as point text)" [[item]] id = "TYPE-218-2" From 70e182b08f7cbccfb724f7a873d396a847c92b32 Mon Sep 17 00:00:00 2001 From: Fabio Lissi Date: Mon, 12 Oct 2026 22:13:42 -0500 Subject: [PATCH 318/523] panic_hunt: don't fuzz the print commands into a real printer (#4467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * panic_hunt: don't fuzz the print commands into a real printer file.print and file.printOneCopy spool to CUPS, so every run of the adversarial-params test printed a blank page on the default printer. The hunt now skips those two ids (their params are validated like everyone else's; spooling is not what the test is for). * Guard the print spooler itself and the CI environment - print_cmds: disable_printing() (a process-wide flag the panic hunt sets) and the PHOTOCRAFT_NO_PRINT environment variable make spool_pdf refuse before running lp; dryRun still renders and reports the command. Test. - CI: PHOTOCRAFT_NO_PRINT=1 and LPDEST=photocraft-no-such-printer, so a regression fails loudly instead of printing. Documented in docs/development.md › Environment variables. --- .github/workflows/ci.yml | 4 ++++ crates/engine/src/print_cmds.rs | 21 +++++++++++++++++++++ crates/engine/src/print_cmds/tests.rs | 18 ++++++++++++++++++ crates/engine/tests/panic_hunt.rs | 8 +++++++- docs/development.md | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 094c1f3893..bec29abaf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,10 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # No test or fuzz run may print a page: the engine refuses to spool, and if something ever + # bypasses that, lp fails loudly on a printer that does not exist. + PHOTOCRAFT_NO_PRINT: "1" + LPDEST: photocraft-no-such-printer # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the # debug info: faster codegen and links (Windows PDBs most of all), and a target dir small enough # for the Linux job's second, craft-fonts build (it ran out of disk with full debug info). diff --git a/crates/engine/src/print_cmds.rs b/crates/engine/src/print_cmds.rs index 96dfa77fd5..6a5d71ca01 100644 --- a/crates/engine/src/print_cmds.rs +++ b/crates/engine/src/print_cmds.rs @@ -409,6 +409,13 @@ fn render_and_spool(doc: &Document, p: &Value, cmd: &str) -> Result { let mut sent = false; let mut spool = Value::Null; if send && !dry { + // The last line of defence: a test or fuzz run must never print a page (`dryRun` still + // renders and reports the command). + if printing_disabled() { + return Err(other( + "printing is disabled in this process (photocraft_engine::print_cmds::disable_printing or PHOTOCRAFT_NO_PRINT); use dryRun: true", + )); + } spool = json!(craft_print::submit(&pdf, &job).map_err(other)?); sent = true; } @@ -429,6 +436,20 @@ fn remember_print(s: &mut Session, p: &Value) { s.file_menu.last_print = Some(remembered); } +/// Set once by test harnesses (the panic hunt) so no command in the process can reach a printer. +static PRINTING_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Refuse to spool anything from this process from now on (tests, fuzzing). The +/// `PHOTOCRAFT_NO_PRINT` environment variable does the same for a whole shell (CI). +pub fn disable_printing() { + PRINTING_DISABLED.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Whether spooling is blocked by [`disable_printing`] or `PHOTOCRAFT_NO_PRINT`. +pub fn printing_disabled() -> bool { + PRINTING_DISABLED.load(std::sync::atomic::Ordering::Relaxed) || std::env::var_os("PHOTOCRAFT_NO_PRINT").is_some_and(|v| !v.is_empty() && v != "0") +} + fn print(s: &mut Session, p: &Value) -> Result { do_print(s, p, "file.print") } diff --git a/crates/engine/src/print_cmds/tests.rs b/crates/engine/src/print_cmds/tests.rs index 988f11635a..228ade337a 100644 --- a/crates/engine/src/print_cmds/tests.rs +++ b/crates/engine/src/print_cmds/tests.rs @@ -345,8 +345,26 @@ fn spool_transport_streams_pdf_and_propagates_refusal() { .args(["--exact", "print_cmds::tests::spool_transport_streams_pdf_and_propagates_refusal", "--nocapture"]) .env(CHILD, dir.path()) .env("PATH", dir.path()) + // The child spools into the fake `lp` above, so a shell that blocks real printing + // (`PHOTOCRAFT_NO_PRINT`, see `disabled_printing_never_spools`) must not stop it. + .env_remove("PHOTOCRAFT_NO_PRINT") .output() .unwrap(); assert!(result.status.success(), "{}\n{}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)); assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"), "child must select the transport test"); } + +#[test] +fn disabled_printing_never_spools() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8})).unwrap(); + super::disable_printing(); + assert!(super::printing_disabled()); + let e = s.execute("file.print", json!({})).unwrap_err().to_string(); + assert!(e.contains("printing is disabled"), "{e}"); + assert!(s.execute("file.printOneCopy", json!({})).is_err()); + // A dry run still renders and reports the command it would have run. + let r = s.execute("file.print", json!({"dryRun": true})).unwrap(); + assert_eq!(r["sent"], false); + assert!(r["command"].is_array(), "{r}"); +} diff --git a/crates/engine/tests/panic_hunt.rs b/crates/engine/tests/panic_hunt.rs index b301b95389..c73980a2f2 100644 --- a/crates/engine/tests/panic_hunt.rs +++ b/crates/engine/tests/panic_hunt.rs @@ -9,6 +9,8 @@ use std::sync::mpsc; use std::time::Duration; fn fresh() -> Session { + // Belt and braces with the EXTERNAL skip below: nothing in this process may reach a printer. + photocraft_engine::print_cmds::disable_printing(); let mut s = Session::new(); s.execute("file.new", json!({"width": 24, "height": 16})).unwrap(); s.execute("layer.new.layer", json!({})).unwrap(); @@ -58,8 +60,12 @@ fn no_command_panics_or_hangs_on_adversarial_params() { json!({"x": 1e5, "y": -1e5, "radius": 300, "amount": 300, "angle": 1e5, "opacity": -100, "scale": -5, "gamma": -1}), json!({"points": [], "from": [0, 0], "to": [0, 0], "stops": [], "matrix": [0, 0, 0, 0, 0, 0], "colors": [], "name": "", "mode": "", "style": ""}), ]; + // Commands whose success reaches outside the process: `file.print` and `file.printOneCopy` + // spool to CUPS, so a fuzz run with `{}` printed a blank page on the default printer. Their + // params are validated like everyone else's; the spooling itself is not fuzzed. + const EXTERNAL: &[&str] = &["file.print", "file.printOneCopy"]; let mut bad: Vec = Vec::new(); - for spec in command_specs() { + for spec in command_specs().iter().filter(|spec| !EXTERNAL.contains(&spec.id)) { for p in ¶ms { if let Err(why) = run_guarded(spec.id, p) { bad.push(format!("{} [{why}] <- {p}", spec.id)); diff --git a/docs/development.md b/docs/development.md index 6be2bd050b..bb0de12690 100644 --- a/docs/development.md +++ b/docs/development.md @@ -104,6 +104,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`. | Variable | Effect | |---|---| | `PHOTOCRAFT_CONTROL_PORT` | Same as `--control ` | +| `PHOTOCRAFT_NO_PRINT` | Set to anything but `0`: `file.print` / `file.printOneCopy` refuse to spool (`dryRun` still works). CI sets it, with `LPDEST=photocraft-no-such-printer` as a second net; set both when running the panic hunt or fuzzers locally | | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `PHOTOCRAFT_CONTROL_TOKEN` | 64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) | | `PHOTOCRAFT_CONTROL_TOKEN_FILE` | Read, or create for a server, the control bearer-token file | From 72b499a28ff8f089fecc87963561e32ec272ec26 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 23:20:45 -0400 Subject: [PATCH 319/523] Tile grid overlay: gate rayon to native so the web build compiles (#4694) #4217 used rayon's par_iter in ui-egui unconditionally, but rayon is a native-only dependency there, so cargo xtask wasm failed on main. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/tile_overlay.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ui-egui/src/tile_overlay.rs b/crates/ui-egui/src/tile_overlay.rs index 03d8156dde..731ddc06b8 100644 --- a/crates/ui-egui/src/tile_overlay.rs +++ b/crates/ui-egui/src/tile_overlay.rs @@ -21,6 +21,7 @@ use std::sync::Arc; use egui::{Align2, Color32, FontId, Painter, Shape, Stroke}; use photocraft_geom::{Rect as DRect, TILE_SIZE, TileCoord}; use photocraft_raster::{Surface, Tile}; +#[cfg(not(target_arch = "wasm32"))] use rayon::prelude::*; use crate::PhotocraftApp; @@ -70,11 +71,18 @@ pub fn tile_infos(s: &Surface, parallel: bool) -> Vec { by_ptr.entry(ptr(t)).or_insert(t); } let distinct: Vec<(usize, &Arc)> = by_ptr.into_iter().collect(); + // rayon is a native-only dependency; the web build hashes serially. + #[cfg(not(target_arch = "wasm32"))] let hashed: Vec<(usize, u64)> = if parallel { distinct.par_iter().map(|(p, t)| (*p, content_hash(t))).collect() } else { distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() }; + #[cfg(target_arch = "wasm32")] + let hashed: Vec<(usize, u64)> = { + let _ = parallel; + distinct.iter().map(|(p, t)| (*p, content_hash(t))).collect() + }; let hash_of: HashMap = hashed.into_iter().collect(); let mut count: HashMap = HashMap::new(); for (_, t) in s.tiles() { From 23daf16f821fd3703ec8443deeccdcbb16345dbb Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:24:18 +0100 Subject: [PATCH 320/523] Align tighter camera JPEG references for RAW colour fitting (#4598) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/raw/README.md | 4 ++ crates/raw/src/look.rs | 115 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/raw/README.md b/crates/raw/README.md index 461a0ed0b6..fcb3c1a95b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -110,6 +110,10 @@ The D5100's measured image area is 4928 × 3264 at (8, 8) in a 4992 × 3280 sensor plane; it removes the border without changing levels, WB or calibration. Measured image areas preserve an explicit file crop. +For a slightly tighter camera JPEG, fitting can align the reference proxy using a bounded +90–100% centred search on training cells. It requires a strong correlation improvement and +retains the held-out colour gates; the developed crop and camera calibration are unchanged. + Without a usable fit (no preview, an unrelated, monochrome or differently shaped one, or a fit that does not clearly help) the documented neutral fallback stays: the white-balanced camera channels are treated as linear sRGB primaries, with no tone curve (plausible but flat, greyish colours; converting diff --git a/crates/raw/src/look.rs b/crates/raw/src/look.rs index 98d2c38026..77a5d38089 100644 --- a/crates/raw/src/look.rs +++ b/crates/raw/src/look.rs @@ -26,6 +26,10 @@ //! axis, the camera's rendering of white, still follows the JPEG). Cells with a //! clipped sensor sample, clipped or near-black JPEG pixels are left out of the //! fit; cells on strong edges get less weight. +//! A bounded centered alignment (90–100% of the raw frame's dimensions) can +//! account for a camera JPEG's tighter framing. It changes only the fit proxy, +//! requires correlation ≥ 0.9 and an improvement ≥ 0.05, and preserves the +//! developed crop. Alignment uses training cells; held-out cells stay out of it. //! //! The reference must show the same picture (lightness correlation ≥ 0.7 with //! the raw) in colour (≥ 2 % of the cells with chroma above 4). One cell in @@ -194,10 +198,97 @@ struct Cell { /// Fits a [`CameraLook`] so that developing `s` with `opts` (white balance, /// baseline exposure) matches the camera's `reference` rendering of it. pub fn fit_look(s: &Sensor, opts: &DevelopOptions, reference: &Reference) -> Result { - let (gw, cells) = grid(s, opts, reference)?; + let (gw, mut cells) = grid(s, opts, reference)?; + align_preview(&mut cells, gw, s.baseline_exposure); fit_cells(&cells, gw, s.baseline_exposure) } +/// Camera JPEGs can show a slightly tighter frame (lens correction, for example). +/// Align only the fit proxy, never the developed crop. Search a bounded centered +/// crop of at most 10%, and require a strong, substantially improved match. +fn align_preview(cells: &mut [Cell], w: usize, exposure: f64) { + if w == 0 || cells.len() < w || !cells.len().is_multiple_of(w) { + return; + } + let h = cells.len() / w; + let gain = 2f64.powf(if exposure.is_finite() { exposure.clamp(-10.0, 10.0) } else { 0.0 }); + let fallback = color::scale(&color::mul(&color::xyz_d50_to_prophoto(), &color::srgb_to_xyz_d50()), gain); + let step = (w / 64).max(1); + let score = |scale: f64| { + let mut pairs = Vec::new(); + for y in (0..h).step_by(step) { + for x in (0..w).step_by(step) { + if (x + 2 * y) % 3 == 0 { + continue; + } + let Some(c) = cells.get(y * w + x) else { continue }; + let Some((cam, clipped)) = aligned_camera(cells, w, h, x, y, scale) else { continue }; + if clipped || c.ref_clipped >= 0.01 || c.lum <= 0.002 || c.edge_weight <= 0.05 || cam.iter().any(|v| !v.is_finite() || *v <= 1e-4) { + continue; + } + let lum = color::apply(&PRO_TO_XYZ_Y_ROW, color::apply(&fallback, cam))[0]; + pairs.push((lum.max(0.0).sqrt(), c.lum.sqrt())); + } + } + if pairs.len() < 100 { f64::NAN } else { correlation(&pairs) } + }; + let original = score(1.0); + if !original.is_finite() { + return; + } + let (mut best, mut scale) = (original, 1.0); + for i in 0..5 { + let s = 0.9 + 0.02 * i as f64; + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + if scale < 1.0 { + let center = scale; + for i in -7..=7 { + let s = (center + 0.0025 * f64::from(i)).clamp(0.9, 1.0); + let r = score(s); + if r > best { + (best, scale) = (r, s); + } + } + } + if best < 0.9 || best < original + 0.05 || scale >= 1.0 { + return; + } + let aligned: Vec<_> = (0..cells.len()).map(|i| aligned_camera(cells, w, h, i % w, i / w, scale)).collect(); + for (c, aligned) in cells.iter_mut().zip(aligned) { + if let Some((cam, clipped)) = aligned { + c.cam = cam; + c.cam_clipped = clipped; + } + } +} + +/// Bilinear interpolation on cell centers, retaining clipping information from +/// every participating cell. Coordinates remain inside the bounded fit grid. +fn aligned_camera(cells: &[Cell], w: usize, h: usize, x: usize, y: usize, scale: f64) -> Option<([f64; 3], bool)> { + let coord = |i: usize, n: usize| ((i as f64 + 0.5 - n as f64 / 2.0) * scale + n as f64 / 2.0 - 0.5).clamp(0.0, n.saturating_sub(1) as f64); + let (sx, sy) = (coord(x, w), coord(y, h)); + let (x0, y0) = (sx.floor() as usize, sy.floor() as usize); + let (fx, fy) = (sx - x0 as f64, sy - y0 as f64); + let mut cam = [0.0; 3]; + let mut clipped = false; + for (dx, dy, weight) in [(0, 0, (1.0 - fx) * (1.0 - fy)), (1, 0, fx * (1.0 - fy)), (0, 1, (1.0 - fx) * fy), (1, 1, fx * fy)] { + if weight <= 0.0 { + continue; + } + let index = y0.saturating_add(dy).min(h.saturating_sub(1)).checked_mul(w)?.checked_add(x0.saturating_add(dx).min(w.saturating_sub(1)))?; + let c = cells.get(index)?; + clipped |= c.cam_clipped; + for (out, v) in cam.iter_mut().zip(c.cam) { + *out += weight * v; + } + } + Some((cam, clipped)) +} + /// The upright fit grid: (width in cells, cells row-major). fn grid(s: &Sensor, opts: &DevelopOptions, r: &Reference) -> Result<(usize, Vec), LookRejected> { let c = s.crop; @@ -817,6 +908,28 @@ mod tests { assert_ne!(d.rgb, plain.rgb); } + #[test] + fn tighter_camera_preview_aligns_for_color_without_changing_the_raw_crop() { + let (w, h) = (480, 320); + let (s, rgb) = shot(w, h, &true_matrix()); + let (x0, y0, cw, ch) = (24, 16, 432, 288); + let mut tight = Vec::with_capacity(cw * ch * 3); + for y in y0..y0 + ch { + tight.extend_from_slice(&rgb[(y * w + x0) * 3..(y * w + x0 + cw) * 3]); + } + let reference = Reference { width: cw as u32, height: ch as u32, rgb: &tight }; + let (gw, plain) = grid(&s, &wb_opts(), &reference).unwrap(); + let before = fit_cells(&plain, gw, s.baseline_exposure); + let look = fit_look(&s, &wb_opts(), &reference).unwrap(); + assert!(look.delta_e < 3.0, "{look:?}"); + if let Ok(before) = before { + assert!(look.delta_e < before.delta_e * 0.5, "aligned {look:?}, before {before:?}"); + } + let d = crate::develop_sensor(&s, &DevelopOptions { camera_look: Some(look), ..wb_opts() }).unwrap(); + assert_eq!((d.width as usize, d.height as usize), (w, h)); + assert_eq!(s.crop, crate::Rect::new(0, 0, w, h)); + } + #[test] fn follows_the_raw_orientation() { let (w, h) = (480, 320); From 876bb8e2815f2a76e798594c4db3a4ee17f75554 Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:25:42 +0100 Subject: [PATCH 321/523] Check peak RAW development memory and bound AHD scratch (#4597) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/raw/README.md | 5 ++ crates/raw/src/demosaic.rs | 35 ++++++++-- crates/raw/src/develop.rs | 83 +++++++++++++++++++++-- crates/raw/src/par.rs | 29 ++++++++ crates/raw/src/sensor.rs | 28 +++++--- crates/raw/tests/development_limits.rs | 91 ++++++++++++++++++++++++++ 6 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 crates/raw/tests/development_limits.rs diff --git a/crates/raw/README.md b/crates/raw/README.md index fcb3c1a95b..efba4e833d 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -91,6 +91,11 @@ camera colour tables were copied. 16 bits. 5. Orientation. +Development validates public sensor/crop/CFA metadata before allocation. The working-memory +limit checks peak live development buffers instead of summing disjoint phases. AHD limits +simultaneous scratch bands only when required; normal load balancing and pixels are preserved. +The default 2 GiB limit is unchanged and excludes caller-owned sensor/document storage. + Files without colour calibration (CR2, NEF, ARW, RW2, ORF, RAF…) have no camera matrix. Every one of them carries a JPEG the camera rendered from the same sensor data, so `fit_look` estimates the camera's rendering per file: both the sensor data (white-balanced as `develop` does) and the diff --git a/crates/raw/src/demosaic.rs b/crates/raw/src/demosaic.rs index 2689061b32..8b87f14a69 100644 --- a/crates/raw/src/demosaic.rs +++ b/crates/raw/src/demosaic.rs @@ -187,14 +187,14 @@ fn kernels(phase: [u8; 4], method: Demosaic, stride: usize) -> [[Vec<(isize, f32 } /// Demosaics a padded CFA plane into interleaved RGB (`w × h × 3`). -pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3) -> Vec { +pub(crate) fn demosaic(p: &Padded, phase: [u8; 4], method: Demosaic, to_xyz: &Mat3, ahd_jobs: usize) -> Vec { let (w, h, s) = (p.w, p.h, p.stride); let mut out = vec![0.0f32; w * h * 3]; if w == 0 || h == 0 || p.data.len() < s * (h + 2 * PAD) { return out; } if method == Demosaic::Ahd { - ahd(p, phase, to_xyz, &mut out); + ahd(p, phase, to_xyz, &mut out, ahd_jobs); return out; } let ks = kernels(phase, method, s); @@ -460,7 +460,19 @@ const AHD_BAND: usize = 32; /// Extra band rows above and below the output rows. const M: usize = 3; -fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { +/// Maximum scratch allocation of one AHD band: two green, RGB and Lab float +/// planes and two byte homogeneity planes, including their border rows. +pub(crate) fn ahd_scratch(w: u64, h: u64) -> u64 { + w.saturating_add(2 * PAD as u64).saturating_mul(h.min(AHD_BAND as u64).saturating_add(2 * M as u64)).saturating_mul(58) +} + +pub(crate) fn ahd_bands(h: usize) -> usize { + h.div_ceil(AHD_BAND) +} + +pub(crate) const AHD_TABLE_BYTES: u64 = (LABF_STEPS as u64 + 2) * 4; + +fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32], jobs: usize) { let (w, s) = (p.w, p.stride); let d = &p.data; // Colour at padded coordinates (PAD is even, so the phase is unchanged). @@ -468,7 +480,7 @@ fn ahd(p: &Padded, phase: [u8; 4], to_xyz: &Mat3, out: &mut [f32]) { let white = [0.9642f32, 1.0, 0.8249]; let m: [[f32; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| to_xyz[i][j] as f32 / white[i])); let labf = LabF::new(); - par::chunks_mut(out, AHD_BAND * w * 3, |band, chunk| { + par::chunks_mut_limited(out, AHD_BAND * w * 3, jobs, |band, chunk| { let rows = chunk.len() / (w * 3); let n = rows + 2 * M; // Band row b ↔ padded row py(b); padded columns are used as is. @@ -577,13 +589,24 @@ mod tests { use super::*; use crate::color::IDENTITY; + #[test] + fn ahd_band_concurrency_does_not_change_pixels() { + let (w, h) = (53, 103); + let cfa: Vec = (0..w * h).map(|i| ((i * 97 + i / w * 31) % 1009) as f32 / 1009.0).collect(); + let p = Padded::from_plane(&cfa, w, h); + let one = demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, 1); + for jobs in [2, 3, 4, 128] { + assert_eq!(one, demosaic(&p, [0, 1, 1, 2], Demosaic::Ahd, &IDENTITY, jobs)); + } + } + /// Mosaics an RGB image with the given phase. fn mosaic(rgb: &[[f32; 3]], w: usize, h: usize, phase: [u8; 4]) -> Vec { (0..w * h).map(|i| rgb[i][phase[((i / w) & 1) * 2 + ((i % w) & 1)] as usize]).collect() } fn run(cfa: &[f32], w: usize, h: usize, phase: [u8; 4], m: Demosaic) -> Vec { - demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY) + demosaic(&Padded::from_plane(cfa, w, h), phase, m, &IDENTITY, par::workers()) } fn psnr(a: &[f32], b: &[[f32; 3]], w: usize, h: usize, border: usize) -> f64 { @@ -671,7 +694,7 @@ mod tests { let cfa: Vec = (0..w * h).map(|i| ((i * 7919) % 1000) as f32 / 1000.0).collect(); for phase in [[0, 1, 1, 2], [1, 2, 0, 1]] { let p = Padded::from_plane(&cfa, w, h); - let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY); + let fast = demosaic(&p, phase, Demosaic::Bilinear, &IDENTITY, par::workers()); let ks = kernels(phase, Demosaic::Bilinear, p.stride); for y in 0..h { for x in 0..w { diff --git a/crates/raw/src/develop.rs b/crates/raw/src/develop.rs index ebe8656a39..e3ab70b0c7 100644 --- a/crates/raw/src/develop.rs +++ b/crates/raw/src/develop.rs @@ -195,16 +195,31 @@ pub(crate) fn resolve_neutral(s: &Sensor, opts: &DevelopOptions, sc: &Scale, war /// Develops sensor data. pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { let c = s.crop; - if c.is_empty() || c.x + c.width > s.width || c.y + c.height > s.height || s.data.len() < s.width * s.height * s.samples { + let samples = s.width.checked_mul(s.height).and_then(|n| n.checked_mul(s.samples)); + if c.is_empty() + || c.x.checked_add(c.width).is_none_or(|x| x > s.width) + || c.y.checked_add(c.height).is_none_or(|y| y > s.height) + || samples.is_none_or(|n| s.data.len() < n) + { return Err(RawError::malformed("crop lies outside the sensor data")); } + // LinearRaw can be monochrome (one sample) or RGB (three); CFA data needs one. if s.samples != 1 && s.samples != 3 { return Err(RawError::unsupported(format!("{} samples per pixel", s.samples))); } - // Working buffers: one f32 plane plus f32 RGB plus u16 RGB per pixel, and - // a green plane for non-Bayer patterns. + if let Some(cfa) = &s.cfa + && (s.samples != 1 + || cfa.width == 0 + || cfa.height == 0 + || cfa.width > 16 + || cfa.height > 16 + || cfa.colors.len() != cfa.width * cfa.height + || cfa.colors.iter().any(|c| *c > 2)) + { + return Err(RawError::malformed("invalid sensor CFA pattern")); + } let bayer = s.cfa.as_ref().is_none_or(|cfa| cfa.is_bayer()); - opts.limits.check(c.width as u64, c.height as u64, if bayer { 4 + 12 + 6 } else { 4 + 4 + 12 + 6 })?; + let ahd_jobs = working_memory(c.width, c.height, s.cfa.is_some(), bayer, opts, par::workers())?; let mut warnings = s.warnings.clone(); let sc = Scale::new(s); @@ -304,7 +319,7 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { if bayer { let p = cfa.phase(c.x, c.y); pattern = Some(names(&p)); - rgb = demosaic(&plane, p, opts.demosaic, &to_xyz); + rgb = demosaic(&plane, p, opts.demosaic, &to_xyz, ahd_jobs); } else { let pat = Pattern::of(cfa, c.x, c.y); pattern = Some(pat.colors.chunks(pat.w.max(1)).map(names).collect::>().join("/")); @@ -400,6 +415,36 @@ pub fn develop_sensor(s: &Sensor, opts: &DevelopOptions) -> Result { }) } +/// Checks the peak of the two development phases. The CFA and non-Bayer green +/// planes are freed before the u16 output is allocated; AHD scratch is bounded +/// by reducing simultaneous bands when needed. Sensor storage belongs to the +/// caller and is checked separately by the decoder. +fn working_memory(w: usize, h: usize, cfa: bool, bayer: bool, opts: &DevelopOptions, workers: usize) -> Result { + let limits = &opts.limits; + limits.check(w as u64, h as u64, 18)?; + let pixels = (w as u64).saturating_mul(h as u64); + let output = pixels.saturating_mul(18).saturating_add((GAMMA_STEPS as u64 + 2) * 4); + let mut peak = output; + let mut jobs = workers.max(1); + if cfa { + let plane = (w as u64).saturating_add(2 * PAD as u64).saturating_mul((h as u64).saturating_add(2 * PAD as u64)).saturating_mul(4); + let mut mosaic = pixels.saturating_mul(if bayer { 12 } else { 16 }).saturating_add(plane); + if bayer && opts.demosaic == Demosaic::Ahd { + mosaic = mosaic.saturating_add(crate::demosaic::AHD_TABLE_BYTES); + let scratch = crate::demosaic::ahd_scratch(w as u64, h as u64); + let available = limits.max_alloc.saturating_sub(mosaic) / scratch.max(1); + jobs = jobs.min(crate::demosaic::ahd_bands(h)).min(usize::try_from(available).unwrap_or(usize::MAX)); + // At least one band must fit, even when no parallelism is available. + mosaic = mosaic.saturating_add(scratch.saturating_mul(jobs.max(1) as u64)); + } + peak = peak.max(mosaic); + } + if peak > limits.max_alloc || usize::try_from(peak).is_err() { + return Err(RawError::LimitExceeded(format!("{peak} bytes of RAW working memory exceed {}", limits.max_alloc))); + } + Ok(jobs.max(1)) +} + /// Gain-map factors (DNG OpcodeList2 GainMap) for data row `y`, columns /// `x0..x0 + w`, sample `k`; `None` when no map touches the row. fn gain_row(s: &Sensor, y: usize, x0: usize, w: usize, k: usize) -> Option> { @@ -544,6 +589,34 @@ pub(crate) fn orient(src: Vec, w: usize, h: usize, o: u16) -> (Vec, us mod tests { use super::*; + #[test] + fn hundred_megapixel_bayer_fits_without_raising_default_limits() { + let mut opts = DevelopOptions::default(); + for method in [Demosaic::Bilinear, Demosaic::Mhc, Demosaic::Ahd] { + opts.demosaic = method; + for workers in [1, 10, 32, 128] { + let jobs = working_memory(11648, 8736, true, true, &opts, workers).unwrap(); + assert!(jobs <= workers); + if method == Demosaic::Ahd && workers >= 32 { + assert!(jobs < workers); + } + } + } + opts.limits.max_alloc = 1 << 30; + assert!(working_memory(11648, 8736, true, true, &opts, 1).is_err()); + } + + #[test] + fn working_memory_accounts_for_padding_and_at_least_one_ahd_band() { + let mut opts = DevelopOptions { demosaic: Demosaic::Ahd, ..Default::default() }; + opts.limits.max_alloc = 100 * 100 * 22; + assert!(working_memory(100, 100, true, true, &opts, 1).is_err()); + opts.demosaic = Demosaic::Mhc; + assert!(working_memory(100, 100, true, true, &opts, 1).is_ok()); + opts.limits.max_alloc = 100 * 100 * 18; + assert!(working_memory(100, 100, false, true, &opts, 1).is_err()); + } + #[test] fn gamma_table_matches_powf() { let lut = GammaLut::new(); diff --git a/crates/raw/src/par.rs b/crates/raw/src/par.rs index a8e2c9f949..6d23fd70c4 100644 --- a/crates/raw/src/par.rs +++ b/crates/raw/src/par.rs @@ -12,6 +12,35 @@ pub(crate) fn chunks_mut(buf: &mut [T], chunk: usize, f: impl Fn(usize, buf.chunks_mut(chunk).enumerate().for_each(|(i, c)| f(i, c)); } +/// Native worker count (the browser builds run sequentially). +pub(crate) fn workers() -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + rayon::current_num_threads() + } + #[cfg(target_arch = "wasm32")] + { + 1 + } +} + +/// Processes normal-sized chunks with at most `jobs` live scratch buffers. +pub(crate) fn chunks_mut_limited(buf: &mut [T], chunk: usize, jobs: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) { + let chunk = chunk.max(1); + // Each worker holds one band's scratch at a time. When the normal worker + // count fits, preserve Rayon's fine-grained load balancing. + if jobs >= workers() { + chunks_mut(buf, chunk, f); + return; + } + let per_group = buf.len().div_ceil(chunk).div_ceil(jobs.max(1)).max(1); + chunks_mut(buf, chunk.saturating_mul(per_group), |group, part| { + for (local, c) in part.chunks_mut(chunk).enumerate() { + f(group * per_group + local, c); + } + }); +} + /// Maps `f` over `0..n`, keeping order. pub(crate) fn map(n: usize, f: impl Fn(usize) -> T + Sync + Send) -> Vec { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/raw/src/sensor.rs b/crates/raw/src/sensor.rs index e8c65ebf59..585191b799 100644 --- a/crates/raw/src/sensor.rs +++ b/crates/raw/src/sensor.rs @@ -63,14 +63,22 @@ impl Cfa { /// Colour at data coordinates. #[inline] pub fn color(&self, x: usize, y: usize) -> u8 { - let cx = (x + self.width - self.origin_x % self.width) % self.width; - let cy = (y + self.height - self.origin_y % self.height) % self.height; - self.colors.get(cy * self.width + cx).copied().unwrap_or(1) + fn relative(v: usize, origin: usize, period: usize) -> Option { + if period == 0 { + return None; + } + let (v, origin) = (v % period, origin % period); + Some(if v >= origin { v - origin } else { period - (origin - v) }) + } + let Some((cx, cy)) = relative(x, self.origin_x, self.width).zip(relative(y, self.origin_y, self.height)) else { + return 1; + }; + cy.checked_mul(self.width).and_then(|i| i.checked_add(cx)).and_then(|i| self.colors.get(i)).copied().unwrap_or(1) } /// The 2×2 colours seen from data coordinates (`x0`, `y0`), row-major. pub fn phase(&self, x0: usize, y0: usize) -> [u8; 4] { - [self.color(x0, y0), self.color(x0 + 1, y0), self.color(x0, y0 + 1), self.color(x0 + 1, y0 + 1)] + [self.color(x0, y0), self.color(x0.saturating_add(1), y0), self.color(x0, y0.saturating_add(1)), self.color(x0.saturating_add(1), y0.saturating_add(1))] } /// `true` for a Bayer pattern: a 2×2 period holding one red, one blue and two @@ -79,10 +87,11 @@ impl Cfa { if self.width == 0 || self.height == 0 || self.width > 16 || self.height > 16 { return false; } - let p = self.phase(self.origin_x, self.origin_y); - let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(self.origin_x + x, self.origin_y + y) == p[(y % 2) * 2 + x % 2])); - let greens_on_diagonal = - (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && p[1] + p[2] == 2) || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && p[0] + p[3] == 2); + let (ox, oy) = (self.origin_x % self.width, self.origin_y % self.height); + let p = self.phase(ox, oy); + let periodic = (0..self.height).all(|y| (0..self.width).all(|x| self.color(ox + x, oy + y) == p[(y % 2) * 2 + x % 2])); + let greens_on_diagonal = (p[0] == 1 && p[3] == 1 && p[1] != 1 && p[2] != 1 && u16::from(p[1]) + u16::from(p[2]) == 2) + || (p[1] == 1 && p[2] == 1 && p[0] != 1 && p[3] != 1 && u16::from(p[0]) + u16::from(p[3]) == 2); periodic && greens_on_diagonal } } @@ -110,7 +119,8 @@ impl BlackLevels { pub fn at(&self, ax: usize, ay: usize, s: usize, spp: usize) -> f32 { let r = ay % self.rows.max(1); let c = ax % self.cols.max(1); - let base = self.values.get((r * self.cols.max(1) + c) * spp + s).or_else(|| self.values.first()).copied().unwrap_or(0.0); + let index = r.checked_mul(self.cols.max(1)).and_then(|i| i.checked_add(c)).and_then(|i| i.checked_mul(spp)).and_then(|i| i.checked_add(s)); + let base = index.and_then(|i| self.values.get(i)).or_else(|| self.values.first()).copied().unwrap_or(0.0); base + self.delta_h.get(ax).copied().unwrap_or(0.0) + self.delta_v.get(ay).copied().unwrap_or(0.0) } diff --git a/crates/raw/tests/development_limits.rs b/crates/raw/tests/development_limits.rs new file mode 100644 index 0000000000..4e5e1636d3 --- /dev/null +++ b/crates/raw/tests/development_limits.rs @@ -0,0 +1,91 @@ +//! Development limits and hostile public sensor metadata. +use photocraft_raw::testgen::DngSpec; +use photocraft_raw::*; + +fn small_sensor() -> Sensor { + decode(&DngSpec::cfa(64, 48, vec![1000; 64 * 48]).build(), &Limits::default()).unwrap() +} + +#[test] +fn checks_peak_live_buffers_instead_of_summing_disjoint_phases() { + let sensor = decode(&DngSpec::cfa(128, 96, vec![1000; 128 * 96]).build(), &Limits::default()).unwrap(); + let mut opts = DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }; + opts.limits.max_alloc = 128 * 96 * 18 + (4096 + 2) * 4; + let expected = develop_sensor(&sensor, &DevelopOptions { demosaic: Demosaic::Mhc, ..Default::default() }).unwrap(); + assert_eq!(develop_sensor(&sensor, &opts).unwrap().rgb, expected.rgb); + opts.limits.max_alloc -= 1; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); +} + +#[test] +fn monochrome_and_rgb_linear_raw_preserve_output_and_memory_limits() { + let (w, h) = (32, 24); + for samples in [1, 3] { + let data = (0..w * h).flat_map(|i| (0..samples).map(move |c| (i % w * 1000 + c * 5000) as u16)).collect(); + let spec = DngSpec { samples, monochrome: samples == 1, ..DngSpec::cfa(w, h, data) }; + let sensor = decode(&spec.build(), &Limits::default()).unwrap(); + assert!(sensor.cfa.is_none()); + assert_eq!(sensor.samples, samples); + let expected = develop_sensor(&sensor, &DevelopOptions::default()).unwrap(); + let mut opts = DevelopOptions::default(); + opts.limits.max_alloc = 32 * 1024; + let got = develop_sensor(&sensor, &opts).unwrap(); + assert_eq!(got.rgb, expected.rgb); + assert_eq!((got.width, got.height), (w as u32, h as u32)); + if samples == 1 { + assert!(got.rgb.as_chunks::<3>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2])); + } + // Even the encoded RGB output alone cannot fit this budget. + opts.limits.max_alloc = (expected.rgb.len() * 2 - 1) as u64; + assert!(matches!(develop_sensor(&sensor, &opts), Err(RawError::LimitExceeded(_)))); + } +} + +#[test] +fn invalid_public_sensor_metadata_returns_errors() { + let sensor = small_sensor(); + let mut cases = Vec::new(); + let mut s = sensor.clone(); + s.crop.x = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.width = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.samples = usize::MAX; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().width = 0; + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors.clear(); + cases.push(s); + let mut s = sensor.clone(); + s.cfa.as_mut().unwrap().colors[0] = 255; + cases.push(s); + let mut s = sensor.clone(); + s.cfa = None; + s.samples = 2; + s.data.resize(64 * 48 * 2, 1000); + cases.push(s); + let mut s = sensor.clone(); + s.samples = 3; + s.data.resize(64 * 48 * 3, 1000); + cases.push(s); + for s in cases { + assert!(develop_sensor(&s, &DevelopOptions::default()).is_err()); + } +} + +#[test] +fn extreme_cfa_and_black_origins_do_not_overflow() { + let mut sensor = small_sensor(); + let cfa = sensor.cfa.as_mut().unwrap(); + cfa.origin_x = usize::MAX; + cfa.origin_y = usize::MAX; + assert!(cfa.is_bayer()); + sensor.black.rows = usize::MAX; + sensor.black.cols = usize::MAX; + assert!(develop_sensor(&sensor, &DevelopOptions::default()).is_ok()); + assert_eq!(Cfa { width: 0, height: 0, colors: vec![], origin_x: 0, origin_y: 0 }.color(1, 2), 1); +} From 09db9f14247d65389f8e0712a552e386c41429e8 Mon Sep 17 00:00:00 2001 From: Gert Pellin - GPE Date: Tue, 13 Oct 2026 05:27:49 +0200 Subject: [PATCH 322/523] Content-Aware Fill: no tinted blotch in a smooth gradient (#4708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inpaint::fit fits each channel as gain · source + bias, the gain the ratio of the patches' deviations within the level's range and the bias clamped after it. On nearly flat patches that ratio is noise, so the gain often sat at its limit; scaling about zero, it moved a bright mean further than Default's (or High's) bias could bring back, each channel by a different amount: a tinted blotch in a sky gradient (up to 14 levels at Default, 27 at High). For nearly flat patches (deviations under about 5 levels) whose means are already within the bias of each other, the gain is now kept where the bias can still match the means. Textured patches, and flat ones of clearly different colours (Content-Aware Move's Color, measured against Photoshop), are fitted as before. Fixes #4706 Co-authored-by: Brandon Thomas --- crates/algo/src/content_aware.rs | 20 +++++++++++++++++++ crates/algo/src/inpaint.rs | 33 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 5000ad2e7c..ecdb308624 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -662,6 +662,26 @@ mod tests { assert!(err < 0.12, "mean error {err}"); } + /// A vertical sky gradient (each row one colour) filled at every adaptation level stays the + /// gradient: no tinted blotch where the copied rows' colours were shifted per channel. + #[test] + fn color_adaptation_leaves_a_smooth_gradient_untinted() { + let (w, h) = (120, 60); + let row = |y: usize| { + let t = y as f32 / (h - 1) as f32; + [0.27 + 0.45 * t, 0.53 + 0.32 * t, 0.86 + 0.1 * t] + }; + let img: Vec = (0..w * h).flat_map(|i| row(i / w)).collect(); + let hole: Vec = (0..w * h).map(|i| (50..70).contains(&(i % w)) && (20..40).contains(&(i / w))).collect(); + let src = vec![true; w * h]; + for level in ["none", "default", "high", "veryHigh"] { + let out = fill(w, h, 3, &img, &hole, &src, &FillOptions { color_adaptation: color_level(level), ..Default::default() }); + let (out, img) = (&out, &img); + let worst = (0..w * h).filter(|i| hole[*i]).flat_map(|i| (0..3).map(move |c| (out[i * 3 + c] - img[i * 3 + c]).abs())).fold(0.0f32, f32::max); + assert!(worst < 3.0 / 255.0, "{level}: off by {}", worst * 255.0); + } + } + /// I.i.d. noise: every value occurs once, so a filled pixel equal to an image pixel was copied. fn noise(w: usize, h: usize, seed: u64) -> Vec { let mut z = seed; diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 37e91da495..d7d43c032f 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -508,6 +508,9 @@ struct Solver<'a> { ctl: photocraft_raster::Interrupt<'a>, } +/// A channel deviation under which a patch counts as flat for [`fit`] (about 5 levels of 255). +const FLAT: f32 = 0.02; + /// Gain and bias of one channel for target moments `(Σt, Σt²)` and source moments `(Σs, Σs²)` /// over `n` samples, within the limits. #[inline] @@ -516,7 +519,20 @@ pub(crate) fn fit(a: Adapt, n: f32, st: f32, stt: f32, ss: f32, sss: f32) -> (f3 let vt = (stt / n - mt * mt).max(0.0); let vs = (sss / n - ms * ms).max(0.0); let g = if vs > 1e-10 { (vt / vs).sqrt() } else { 1.0 }; - let g = g.clamp(a.gain.0, a.gain.1); + let (mut lo, mut hi) = a.gain; + // Nearly flat patches of about the same colour (their means within the bias of each other): + // the ratio of their deviations is noise, so match the mean first, keeping the gain where the + // bias can still bring the source's mean to the target's. Gained about zero, a gain at its + // limit moved bright means further than the bias could bring back, and each channel by a + // different amount: a tinted blotch in a smooth gradient. Textured patches, and flat ones + // whose colours differ more (Content-Aware Move's Color), are fitted as before. + if vt.max(vs) < FLAT * FLAT && (mt - ms).abs() <= a.bias && ms > 1e-6 { + let (glo, ghi) = ((mt - a.bias) / ms, (mt + a.bias) / ms); + if glo.max(lo) <= ghi.min(hi) { + (lo, hi) = (glo.max(lo), ghi.min(hi)); + } + } + let g = g.clamp(lo, hi); let b = (mt - g * ms).clamp(-a.bias, a.bias); (g, b) } @@ -1959,6 +1975,21 @@ fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Ve mod tests { use super::*; + /// The mean is matched first: on a nearly flat bright patch with a noisy deviation ratio the + /// gain stays where the bias can still reach the target's mean. + #[test] + fn fit_matches_the_mean_before_the_contrast() { + let a = Adapt { gain: (0.9, 1.1), bias: 0.04 }; + // Target mean 0.70, source mean 0.70; deviations 0.01 vs 0.004 (ratio 2.5). + let n = 25.0; + let (mt, ms, sdt, sds) = (0.70f32, 0.70f32, 0.01f32, 0.004f32); + let (g, b) = fit(a, n, mt * n, (sdt * sdt + mt * mt) * n, ms * n, (sds * sds + ms * ms) * n); + assert!((g * ms + b - mt).abs() < 1e-5, "mean {} vs {mt} (g {g}, b {b})", g * ms + b); + // Where the mean can't be reached anyway, the limits hold as before. + let (g, b) = fit(a, n, 0.2 * n, 0.04 * n, 0.9 * n, 0.81 * n); + assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); From adfeab175069782a7dca7e36dc144544ed0e1c0c Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:29:26 -0500 Subject: [PATCH 323/523] Composited CMYK documents now export as CMYK TIFF and JPEG with their profile (#2220) (#4199) A CMYK document with several layers, masks or effects is flattened through the RGB compositor. Its TIFF and JPEG exports were written as sRGB RGB, losing the document's mode and ICC profile. The composite is now converted back through the document's CMYK profile (relative colorimetric with black point compensation) inside each parallel worker, written as four ink channels plus straight alpha at the document depth, and tagged with that profile (the built-in coated CMYK profile when the document is untagged). Invalid, non-CMYK or unusable profiles are rejected. RGB-only formats keep the direct sRGB composite, and a single plain raster layer keeps its native samples. This fixes the output mode and tagging only: the RGB roundtrip does not preserve original ink separations, black generation or CMYK-native blending. Claude-Session: https://claude.ai/code/session_01BSB7fMQUccqFQekksDGWpo Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- book/src/formats/raster-formats.md | 9 + crates/io/src/flat.rs | 101 +++++++--- crates/io/tests/cmyk_composite_export.rs | 235 +++++++++++++++++++++++ 3 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 crates/io/tests/cmyk_composite_export.rs diff --git a/book/src/formats/raster-formats.md b/book/src/formats/raster-formats.md index 7bca7f2fc2..2e65d51550 100644 --- a/book/src/formats/raster-formats.md +++ b/book/src/formats/raster-formats.md @@ -20,6 +20,15 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_ - Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened. - TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit. +Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through +the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed +that exact profile. Untagged composites use and embed PhotoCraft's synthetic coated CMYK profile; +invalid, non-CMYK or unusable embedded profiles are rejected before compositing. TIFF retains +8/16/32f depth and straight alpha; JPEG reduces to 8-bit with a warning and mattes transparency +over zero-ink white. A plain single raster layer retains its native samples. RGB-only formats use +the RGB composite directly. This RGB composite roundtrip does **not** preserve original ink +separations, black generation or native CMYK blending, and is not a claim of Photoshop parity. + ## Implemented decode limits `DecodeOptions::default()` applies `Limits` before decoded pixel allocation: diff --git a/crates/io/src/flat.rs b/crates/io/src/flat.rs index bcb9422e89..1778e5d064 100644 --- a/crates/io/src/flat.rs +++ b/crates/io/src/flat.rs @@ -304,6 +304,10 @@ pub(crate) fn csample(s: SampleType) -> CSample { /// Renders the document to a flat codec image (native pixels when possible). pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result { + document_to_image_for(doc, warnings, true) +} + +fn document_to_image_for(doc: &Document, warnings: &mut Vec, cmyk_target: bool) -> Result { let (w, h) = (doc.size.width, doc.size.height); let canvas = doc.bounds(); let fmt = doc.pixel_format(); @@ -338,16 +342,42 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Profile::parse(bytes).map_err(|e| IoError::Unsupported(format!("invalid CMYK composite profile: {e}")))?, + None => Builtin::CoatedCmyk.profile().clone(), + }; + if profile.color_space != ColorSpace::Cmyk { + return Err(IoError::Unsupported("CMYK composite requires a CMYK ICC profile".into())); + } + let srgb = Builtin::Srgb.profile(); + Transform::new(&profile, srgb, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + Transform::new(srgb, &profile, Intent::RelativeColorimetric, true).map_err(|e| IoError::Unsupported(e.to_string()))?; + if icc.is_none() { + icc = Some(profile.to_bytes().to_vec()); + } + photocraft_compose::cmyk_space(doc) + } else { + None + }; let gray = fmt.mode == ColorMode::Grayscale; - if fmt.mode == ColorMode::Cmyk || fmt.mode == ColorMode::Lab { - // The compositor renders CMYK/Lab documents in sRGB (CMYK through the built-in - // profile), so the file is tagged sRGB. + if (fmt.mode == ColorMode::Cmyk && !cmyk) || fmt.mode == ColorMode::Lab { + // RGB-only targets use the sRGB composite directly, without reseparating CMYK. warnings.push(format!("{:?} composite written as sRGB RGB (colour-managed conversion)", fmt.mode)); icc = Some(photocraft_cms::Builtin::Srgb.profile().to_bytes().to_vec()); } let cs = csample(fmt.sample); - let colors = if gray { 1 } else { 3 }; + let colors = if cmyk { + 4 + } else if gray { + 1 + } else { + 3 + }; // Rendered and quantised in bands (no full-size float composite), with alpha; the alpha // is dropped afterwards, in place, when every pixel turned out opaque. let mut data = try_buffer(n, (colors + 1) * cs.bytes())?; @@ -355,26 +385,35 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Result<(), ()> { opaque &= band.px.iter().all(|p| p[3] >= 1.0); let parts = crate::pixels::par_map(crate::pixels::bands(band.px.len()), |range| { - let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); - let mut put = |v: f32| { - let v = if v.is_nan() { 0.0 } else { v }; - match cs { - CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), - CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), - CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + photocraft_color::convert::with_cmyk_space(space.as_ref(), || { + let mut out = Vec::with_capacity(range.len() * (colors + 1) * cs.bytes()); + let mut put = |v: f32| { + let v = if v.is_nan() { 0.0 } else { v }; + match cs { + CSample::U8 => out.push((v.clamp(0.0, 1.0) * 255.0).round() as u8), + CSample::U16 => out.extend_from_slice(&((v.clamp(0.0, 1.0) * 65535.0).round() as u16).to_ne_bytes()), + CSample::F16 | CSample::F32 => out.extend_from_slice(&v.to_ne_bytes()), + } + }; + let mut converted = [0.0; 5]; + for p in &band.px[range] { + if cmyk { + let channels = photocraft_raster::from_rgba_into(&fmt, *p, &mut converted); + for value in converted.iter().take(channels) { + put(*value); + } + continue; + } else if gray { + put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); + } else { + put(p[0]); + put(p[1]); + put(p[2]); + } + put(p[3]); } - }; - for p in &band.px[range] { - if gray { - put(photocraft_color::convert::rgb_to_gray([p[0], p[1], p[2]])); - } else { - put(p[0]); - put(p[1]); - put(p[2]); - } - put(p[3]); - } - out + out + }) }); for part in parts { data.extend_from_slice(&part); @@ -384,7 +423,16 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec) -> Result Document { + let mut doc = Document::new("CMYK composite", Size::new(8, 8), ColorMode::Cmyk, depth); + doc.icc_profile = Some(Builtin::CoatedCmyk.profile().to_bytes()); + let surface = Surface::with_default(doc.pixel_format(), &[0.3, 0.6, 0.1, 0.2, alpha]); + doc.layers.push(Layer::new("Ink", LayerContent::Raster(surface))); + doc.layers.push(Layer::new("Empty", LayerContent::Raster(Surface::new(doc.pixel_format())))); + doc +} + +fn tag(bytes: &[u8], id: u16) -> Option { + assert_eq!(&bytes[..2], b"II"); + let start = usize::try_from(u32::from_le_bytes(bytes[4..8].try_into().unwrap())).unwrap(); + let count = usize::from(u16::from_le_bytes(bytes[start..start + 2].try_into().unwrap())); + bytes[start + 2..] + .as_chunks::<12>() + .0 + .iter() + .take(count) + .find_map(|entry| (u16::from_le_bytes(entry[..2].try_into().unwrap()) == id).then(|| u16::from_le_bytes(entry[8..10].try_into().unwrap()))) +} + +#[test] +fn tiff_keeps_cmyk_profile_depth_and_straight_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + for alpha in [1.0, 0.5] { + let doc = document(depth, alpha); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), if alpha == 1.0 { ChannelLayout::Cmyk } else { ChannelLayout::CmykA }); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(if alpha == 1.0 { 4 } else { 5 })); + assert_eq!(tag(&result.bytes, 338), if alpha == 1.0 { None } else { Some(2) }); + if alpha < 1.0 { + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - alpha).abs() < 0.004)); + let opaque = document_to_image(&document(depth, 1.0), &mut Vec::new()).unwrap().to_normalized(); + for (px, color) in image.to_normalized().as_chunks::<5>().0.iter().zip(opaque.as_chunks::<4>().0) { + assert!(px[..4].iter().zip(color).all(|(a, b)| (a - b).abs() < 0.004)); + } + } + } + } +} + +#[test] +fn tiff_keeps_true_zero_alpha_when_composited() { + for (depth, sample) in [(SampleType::U8, CodecSample::U8), (SampleType::U16, CodecSample::U16), (SampleType::F32, CodecSample::F32)] { + let doc = document(depth, 0.0); + + let result = export(&doc, "tiff", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.sample_type(), sample); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert_eq!(tag(&result.bytes, 262), Some(5)); + assert_eq!(tag(&result.bytes, 277), Some(5)); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| px[4].abs() < f32::EPSILON)); + } +} + +#[test] +fn psd_keeps_cmyk_mode_depth_and_profile_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let doc = document(depth, 0.5); + + let result = export(&doc, "psd", &ExportOptions::default()).unwrap(); + + let back = photocraft_io::import("composite.psd", &result.bytes).unwrap().document; + assert_eq!(back.mode, ColorMode::Cmyk); + assert_eq!(back.depth, depth); + assert_eq!(back.icc_profile, doc.icc_profile); + assert_eq!(back.layer_count(), doc.layer_count()); + } +} + +#[test] +fn jpeg_keeps_four_components_profile_and_zero_ink_matte_when_composited() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for alpha in [1.0, 0.5, 0.0] { + let doc = document(depth, alpha); + let mut opts = ExportOptions::default(); + opts.encode.jpeg_quality = 100; + + let result = export(&doc, "jpg", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + let adobe = result.bytes.windows(5).position(|bytes| bytes == b"Adobe").unwrap(); + assert_eq!(result.bytes[adobe + 11], 0); + let mut marker = 2; + while result.bytes[marker + 1] != 192 { + let length = usize::from(u16::from_be_bytes(result.bytes[marker + 2..marker + 4].try_into().unwrap())); + marker += length + 2; + } + assert_eq!(result.bytes[marker + 9], 4); + let expected = document_to_image(&doc, &mut Vec::new()).unwrap(); + for (px, source) in image.to_normalized().as_chunks::<4>().0.iter().zip(expected.to_normalized().chunks_exact(expected.layout().channels())) { + for channel in 0..4 { + let ink = source[channel] * if expected.layout().has_alpha() { source[4] } else { 1.0 }; + assert!((px[channel] - ink).abs() < 0.015, "{px:?} vs {source:?}"); + } + } + if depth != SampleType::U8 { + assert!(result.warnings.iter().any(|warning| warning.contains("bit")), "{:?}", result.warnings); + } + } + } +} + +#[test] +fn composite_uses_distinctive_document_profile_on_every_worker() { + let params = photocraft_cms::synth::CmykParams { + description: "Export synthetic uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }; + let profile = photocraft_cms::synth::cmyk_profile(¶ms); + let mut doc = document(SampleType::F32, 0.5); + doc.size = Size::new(64, 64); + doc.icc_profile = Some(profile.to_bytes()); + let rgb = photocraft_compose::flatten(&doc); + let transform = Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap(); + let fallback = Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut expected = [0.0; 4]; + transform.eval_fast(&rgb.px[0][..3], &mut expected); + let mut wrong = [0.0; 4]; + fallback.eval_fast(&rgb.px[0][..3], &mut wrong); + assert!(expected.iter().zip(wrong).any(|(a, b)| (a - b).abs() > 0.02)); + assert!(photocraft_io::pixels::bands(rgb.px.len()).len() > 1); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::CmykA); + for px in image.to_normalized().as_chunks::<5>().0 { + assert!(px[..4].iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.00001), "{px:?} vs {expected:?}"); + } + for extension in ["tiff", "jpg"] { + let encoded = export(&doc, extension, &ExportOptions::default()).unwrap(); + assert_eq!(decode(&encoded.bytes).unwrap().icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + } +} + +#[test] +fn cmyk_composite_rejects_invalid_non_cmyk_and_unusable_profiles() { + let without_tags = |prefix: &[u8]| { + let mut bytes = Builtin::CoatedCmyk.profile().to_bytes().to_vec(); + let count = usize::try_from(u32::from_be_bytes(bytes[128..132].try_into().unwrap())).unwrap(); + for entry in bytes[132..].as_chunks_mut::<12>().0.iter_mut().take(count) { + if &entry[..3] == prefix { + entry[..4].copy_from_slice(b"test"); + } + } + std::sync::Arc::new(bytes) + }; + for bytes in [std::sync::Arc::new(vec![1, 2, 3]), Builtin::Srgb.profile().to_bytes(), without_tags(b"B2A"), without_tags(b"A2B")] { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = Some(bytes); + + for extension in ["tiff", "jpg"] { + assert!(export(&doc, extension, &ExportOptions::default()).is_err(), "accepted invalid profile for {extension}"); + } + // RGB-only targets never write CMYK, so they still export (through the built-in profile). + assert!(export(&doc, "png", &ExportOptions::default()).is_ok(), "rejected invalid profile for png"); + } +} + +#[test] +fn untagged_composite_embeds_coated_cmyk() { + let mut doc = document(SampleType::U8, 1.0); + doc.icc_profile = None; + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.layout(), ChannelLayout::Cmyk); + assert_eq!(image.icc.unwrap(), Builtin::CoatedCmyk.profile().to_bytes().as_slice()); +} + +#[test] +fn native_samples_are_preserved_without_composite_roundtrip() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let mut doc = document(depth, 0.5); + doc.layers.pop(); + let LayerContent::Raster(surface) = &doc.layers[0].content else { panic!() }; + let expected = surface.to_interleaved(doc.bounds()); + + let image = document_to_image(&doc, &mut Vec::new()).unwrap(); + + assert_eq!(image.data(), expected); + } +} + +#[test] +fn rgb_only_export_uses_direct_composite_not_reseparated_cmyk() { + let doc = document(SampleType::U16, 0.5); + let rgb = photocraft_compose::flatten(&doc); + let expected = + photocraft_codecs::Image::from_normalized(8, 8, ChannelLayout::Rgba, CodecSample::U16, &rgb.px.into_iter().flatten().collect::>()).unwrap(); + + let result = export(&doc, "png", &ExportOptions::default()).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.data(), expected.data()); + assert_eq!(Profile::parse(image.icc.as_ref().unwrap()).unwrap().color_space, photocraft_cms::ColorSpace::Rgb); +} + +#[test] +fn layered_tiff_keeps_its_existing_cmyk_psd_composite_path() { + let doc = document(SampleType::U16, 0.5); + let opts = ExportOptions { tiff_layers: true, ..Default::default() }; + + let result = export(&doc, "tiff", &opts).unwrap(); + + let image = decode(&result.bytes).unwrap(); + assert_eq!(image.layout(), ChannelLayout::CmykA); + assert_eq!(image.icc.as_deref(), doc.icc_profile.as_deref().map(Vec::as_slice)); + assert!(image.meta.photoshop_layers.is_some()); + assert_eq!(tag(&result.bytes, 338), Some(2)); + assert!(image.to_normalized().as_chunks::<5>().0.iter().all(|px| (px[4] - 0.5).abs() < 0.0001)); +} From 70537058eb89c104006af78c5f24870b3c7413d3 Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Tue, 13 Oct 2026 04:29:29 +0100 Subject: [PATCH 324/523] Open Canon CR3 camera JPEG previews with container orientation (#4595) Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas --- crates/io/src/raw.rs | 2 +- crates/io/tests/raw.rs | 12 +++ crates/raw/README.md | 2 +- crates/raw/src/cr3.rs | 202 ++++++++++++++++++++++++++++++++++++++ crates/raw/src/lib.rs | 12 +++ crates/raw/src/preview.rs | 8 +- crates/raw/src/testgen.rs | 35 +++++++ crates/raw/tests/cr3.rs | 119 ++++++++++++++++++++++ 8 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 crates/raw/src/cr3.rs create mode 100644 crates/raw/tests/cr3.rs diff --git a/crates/io/src/raw.rs b/crates/io/src/raw.rs index 26e62d25fd..008173839b 100644 --- a/crates/io/src/raw.rs +++ b/crates/io/src/raw.rs @@ -38,7 +38,7 @@ fn limits() -> Limits { fn upright_preview(raw: &[u8], jpeg: &[u8]) -> Result { let img = codecs::decode_as_with(Format::Jpeg, jpeg, &codecs::DecodeOptions { keep_orientation: true, ..Default::default() })?; let own = img.meta.exif.as_deref().map_or(1, codecs::exif_orientation); - let o = if own != 1 { own } else { codecs::exif_orientation(raw) }; + let o = if own != 1 { own } else { photocraft_raw::orientation(raw).unwrap_or_else(|| codecs::exif_orientation(raw)) }; Ok(img.oriented(o)?) } diff --git a/crates/io/tests/raw.rs b/crates/io/tests/raw.rs index 7a11f4fb03..d9e31a87c1 100644 --- a/crates/io/tests/raw.rs +++ b/crates/io/tests/raw.rs @@ -94,6 +94,18 @@ fn unsupported_raw_without_preview_is_a_clear_error() { } } +#[test] +fn cr3_opens_its_upright_camera_jpeg_with_explicit_preview_warning() { + let img = Image::from_u8(32, 20, ChannelLayout::Rgb, vec![180; 32 * 20 * 3]).unwrap(); + let jpeg = photocraft_codecs::encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap(); + let raw = photocraft_raw::testgen::cr3_preview(&jpeg, 6); + let r = import("shot.cr3", &raw).unwrap(); + assert_eq!((r.document.size.width, r.document.size.height), (20, 32)); + assert_eq!(r.document.depth, SampleType::U8); + assert!(r.warnings[0].contains("8-bit, not the raw sensor data")); + assert!(r.warnings[0].contains("CR3")); +} + #[test] fn truncated_raws_do_not_panic() { let b = nef_with_preview(); diff --git a/crates/raw/README.md b/crates/raw/README.md index efba4e833d..232ef83b3b 100644 --- a/crates/raw/README.md +++ b/crates/raw/README.md @@ -75,7 +75,7 @@ camera colour tables were copied. | Nikon "lossy after split" NEF, Sony "Compressed RAW 2", Pentax compressed PEF, RW2 RawFormat 4 and older, Olympus compressed ORF | Unsupported: no public description of these codes was found apart from GPL decoder source, which this crate may not use (clean-room). `photocraft-io` opens the embedded JPEG preview instead | | Fujifilm RAF, uncompressed (X-Trans I–V, GFX and Bayer bodies since about 2010) | Decoded: 16-bit containers (either byte order), 12-bit LSB-first packing, 14-bit packing in 32-bit words; X-Trans 6×6 or Bayer 2×2 layout, black, as-shot WB, crop, RawExposureBias, orientation from the preview's EXIF | | Fujifilm compressed RAF, early FinePix / SuperCCD RAF (no CFA TIFF) | Unsupported (no public description of the compression); preview fallback | -| CR3 | Recognised, unsupported (preview fallback where a preview is found) | +| CR3 | Native CRX unsupported; bounded JPEG-track/PRVW preview fallback with CMT1 orientation. I/O labels the result as an 8-bit camera preview | | X-Trans and other periodic non-Bayer CFAs (up to 16×16) | Demosaiced by one pattern-agnostic edge-directed method, whatever `Demosaic` is chosen | ## Development pipeline diff --git a/crates/raw/src/cr3.rs b/crates/raw/src/cr3.rs new file mode 100644 index 0000000000..bb2119e7d4 --- /dev/null +++ b/crates/raw/src/cr3.rs @@ -0,0 +1,202 @@ +//! Bounded CR3 container reading, independently implemented from ISO BMFF and +//! Laurent Clévy's public format description (canon_cr3/readme.md). This extracts +//! camera JPEGs and orientation, not CRX sensor data. Never scan compressed data +//! for JPEG signatures: offsets must come from a validated sample table or PRVW. + +use crate::preview::{Preview, candidate}; +use crate::tiff::{Tiff, tag}; + +const META_UUID: &[u8] = &[0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const PREVIEW_UUID: &[u8] = &[0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; +const MAX_BOXES: usize = 8192; +const MAX_DEPTH: usize = 12; + +fn u32_at(b: &[u8], p: usize) -> Option { + Some(u32::from_be_bytes(b.get(p..p.checked_add(4)?)?.try_into().ok()?)) +} + +fn u64_at(b: &[u8], p: usize) -> Option { + Some(u64::from_be_bytes(b.get(p..p.checked_add(8)?)?.try_into().ok()?)) +} + +struct BoxRef<'a> { + kind: &'a [u8], + body: &'a [u8], +} + +/// Walks only complete sibling boxes, including extended lengths and a final +/// zero-length box. Invalid lengths end this container; siblings never escape it. +fn boxes<'a>(mut b: &'a [u8], budget: &mut usize, mut f: impl FnMut(BoxRef<'a>, &mut usize)) { + while b.len() >= 8 && *budget > 0 { + *budget -= 1; + let Some(n) = u32_at(b, 0) else { + return; + }; + let (size, header) = match n { + 0 => (b.len(), 8), + 1 => { + let Some(size) = u64_at(b, 8).and_then(|n| usize::try_from(n).ok()) else { + return; + }; + (size, 16) + } + n => (n as usize, 8), + }; + if size < header { + return; + } + let Some(body) = b.get(header..size) else { + return; + }; + let Some(kind) = b.get(4..8) else { + return; + }; + f(BoxRef { kind, body }, budget); + let Some(rest) = b.get(size..) else { + return; + }; + b = rest; + } +} + +#[derive(Default)] +struct Track<'a> { + jpeg: bool, + sizes: Option<&'a [u8]>, + offsets: Option<(&'a [u8], bool)>, + mapping: bool, +} + +/// Accept only a single JPEG sample description, with CRAW's fixed sample-entry +/// header followed by an explicit JPEG codec box. +fn jpeg_description(b: &[u8], budget: &mut usize) -> bool { + if u32_at(b, 0) != Some(0) || u32_at(b, 4) != Some(1) { + return false; + } + let mut jpeg = false; + if let Some(entries) = b.get(8..) { + boxes(entries, budget, |entry, budget| { + if entry.kind == b"CRAW" + && let Some(children) = entry.body.get(82..) + { + boxes(children, budget, |codec, _| { + jpeg |= codec.kind == b"JPEG"; + }); + } + }); + } + jpeg +} + +fn track_boxes<'a>(b: &'a [u8], depth: usize, budget: &mut usize, t: &mut Track<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"mdia" | b"minf" | b"stbl" => track_boxes(item.body, depth + 1, budget, t), + b"stsd" => t.jpeg = jpeg_description(item.body, budget), + b"stsz" => t.sizes = Some(item.body), + b"co64" => t.offsets = Some((item.body, true)), + b"stco" => t.offsets = Some((item.body, false)), + // One entry, starting at chunk 1, one sample per chunk, description 1. + b"stsc" => t.mapping = item.body.len() == 20 && (0..5).all(|i| u32_at(item.body, i * 4) == Some(if i == 0 { 0 } else { 1 })), + _ => {} + }); +} + +fn track_preview<'a>(raw: &'a [u8], track: &'a [u8], budget: &mut usize, media: &[(usize, usize)]) -> Option> { + let mut t = Track::default(); + track_boxes(track, 0, budget, &mut t); + if !t.jpeg || !t.mapping { + return None; + } + let sizes = t.sizes?; + let (offsets, wide) = t.offsets?; + // Burst sequences are deliberately not paired by ordinal index: their + // sample-to-chunk mapping and picture selection need a separate implementation. + if u32_at(sizes, 0) != Some(0) || u32_at(sizes, 8) != Some(1) || u32_at(offsets, 0) != Some(0) || u32_at(offsets, 4) != Some(1) { + return None; + } + let fixed = u32_at(sizes, 4)?; + let len = if fixed == 0 { u32_at(sizes, 12)? } else { fixed }; + let off = if wide { u64_at(offsets, 8)? } else { u64::from(u32_at(offsets, 8)?) }; + let off = usize::try_from(off).ok()?; + let end = off.checked_add(len as usize)?; + if !media.iter().any(|(start, stop)| off >= *start && end <= *stop) { + return None; + } + candidate(raw, off, len as usize) +} + +#[derive(Default)] +struct Found<'a> { + previews: Vec>, + orientation: Option, + media: Vec<(usize, usize)>, +} + +fn walk<'a>(raw: &'a [u8], b: &'a [u8], depth: usize, budget: &mut usize, found: &mut Found<'a>) { + if depth > MAX_DEPTH { + return; + } + boxes(b, budget, |item, budget| match item.kind { + b"moov" => walk(raw, item.body, depth + 1, budget, found), + b"trak" => { + if found.previews.len() < 128 { + found.previews.extend(track_preview(raw, item.body, budget, &found.media)); + } + } + b"uuid" => { + if item.body.get(..16) == Some(META_UUID) + && let Some(children) = item.body.get(16..) + { + walk(raw, children, depth + 1, budget, found); + } else if item.body.get(..16) == Some(PREVIEW_UUID) + && let Some(children) = item.body.get(24..) + { + walk(raw, children, depth + 1, budget, found); + } + } + b"PRVW" => { + if found.previews.len() < 128 + && let Some(len) = u32_at(item.body, 12) + && let Some(p) = candidate(item.body, 16, len as usize) + { + found.previews.push(p); + } + } + b"CMT1" => { + if let Some(tiff) = Tiff::new(item.body) + && let Some(ifd) = tiff.ifd_at(tiff.first_ifd, 0) + && let Some(o) = tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)) + { + found.orientation = Some(o as u16); + } + } + _ => {} + }); +} + +fn read(raw: &[u8]) -> Found<'_> { + let mut found = Found::default(); + if raw.get(4..12) == Some(b"ftypcrx ") { + let mut budget = MAX_BOXES; + boxes(raw, &mut budget, |item, _| { + if item.kind == b"mdat" && found.media.len() < 128 { + let start = (item.body.as_ptr() as usize).saturating_sub(raw.as_ptr() as usize); + if let Some(end) = start.checked_add(item.body.len()) { + found.media.push((start, end)); + } + } + }); + walk(raw, raw, 0, &mut budget, &mut found); + } + found +} + +pub(crate) fn previews(raw: &[u8]) -> Vec> { + read(raw).previews +} +pub(crate) fn orientation(raw: &[u8]) -> Option { + read(raw).orientation +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index d5a3e987c7..f8304b3cfa 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -46,6 +46,7 @@ mod cameras; mod color; mod cr2; +mod cr3; mod demosaic; mod develop; mod dng; @@ -85,6 +86,17 @@ use crate::tiff::{Tiff, tag}; /// Name of the output colour space of [`develop`]. pub const OUTPUT_SPACE: &str = "ProPhoto RGB (ROMM primaries, D50, gamma 1.8)"; +/// The raw container's orientation, when recorded (CR3 metadata or TIFF IFD0). +/// Useful for camera JPEGs that carry no orientation of their own. +pub fn orientation(bytes: &[u8]) -> Option { + if bytes.get(4..12) == Some(b"ftypcrx ") { + return cr3::orientation(bytes); + } + let tiff = Tiff::new(bytes)?; + let ifd = tiff.ifd_at(tiff.first_ifd, 0)?; + tiff.tag_uint(&ifd, tag::ORIENTATION).filter(|o| (1..=8).contains(o)).map(|o| o as u16) +} + /// Camera raw container families. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum RawFormat { diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index ec0c041fb5..d5dd013310 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -41,13 +41,13 @@ fn dct_jpeg_size(b: &[u8]) -> Option<(u32, u32)> { None } -fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { +pub(crate) fn candidate(data: &[u8], offset: usize, len: usize) -> Option> { let jpeg = data.get(offset..offset.checked_add(len)?)?; let (width, height) = dct_jpeg_size(jpeg)?; Some(Preview { jpeg, width, height }) } -/// The largest embedded JPEG preview of a raw file (TIFF-based raws and RAF). +/// The largest embedded JPEG preview of a raw file (TIFF-based raws, RAF and CR3). pub fn embedded_preview(bytes: &[u8]) -> Option> { embedded_previews(bytes).into_iter().max_by_key(|p| u64::from(p.width) * u64::from(p.height)) } @@ -56,7 +56,9 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { /// full-size and a smaller rendering of the same picture). pub fn embedded_previews(bytes: &[u8]) -> Vec> { let mut found: Vec = Vec::new(); - if bytes.starts_with(crate::raf::MAGIC) { + if bytes.get(4..12) == Some(b"ftypcrx ") { + found = crate::cr3::previews(bytes); + } else if bytes.starts_with(crate::raf::MAGIC) { // RAF header: big-endian JPEG offset and length at bytes 84 and 88. if let Some((off, len)) = crate::raf::preview_range(bytes) { found.extend(candidate(bytes, off, len)); diff --git a/crates/raw/src/testgen.rs b/crates/raw/src/testgen.rs index fdacc19828..823cd97ae6 100644 --- a/crates/raw/src/testgen.rs +++ b/crates/raw/src/testgen.rs @@ -7,6 +7,41 @@ // ---------------------------------------------------------------- lossless JPEG +/// A CR3 container holding one JPEG track and CMT1 orientation. It deliberately +/// contains no CRX data: used to test explicit preview fallback and BMFF bounds. +pub fn cr3_preview(jpeg: &[u8], orientation: u16) -> Vec { + fn bx(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut b = ((data.len() + 8) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(data); + b + } + fn moov(jpeg_len: usize, offset: u64, orientation: u16) -> Vec { + let mut tiff = TiffBuilder::default(); + let ifd = tiff.ifd(vec![(274, Val::Short(vec![orientation]))]); + tiff.chain = vec![ifd]; + let mut meta = vec![0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; + meta.extend(bx(b"CMT1", &tiff.build())); + let mut entry = vec![0; 82]; + entry.extend(bx(b"JPEG", &[0; 4])); + let mut description = vec![0, 0, 0, 0, 0, 0, 0, 1]; + description.extend(bx(b"CRAW", &entry)); + let mut sizes = vec![0; 4]; + sizes.extend_from_slice(&(jpeg_len as u32).to_be_bytes()); + sizes.extend_from_slice(&1u32.to_be_bytes()); + let mut offsets = vec![0, 0, 0, 0, 0, 0, 0, 1]; + offsets.extend_from_slice(&offset.to_be_bytes()); + let mapping: Vec = [0u32, 1, 1, 1, 1].iter().flat_map(|n| n.to_be_bytes()).collect(); + let table = [bx(b"stsd", &description), bx(b"stsz", &sizes), bx(b"co64", &offsets), bx(b"stsc", &mapping)].concat(); + let track = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &table)))); + bx(b"moov", &[bx(b"uuid", &meta), track].concat()) + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let header = moov(jpeg.len(), 0, orientation); + let offset = ftyp.len() + header.len() + 8; + [ftyp, moov(jpeg.len(), offset as u64, orientation), bx(b"mdat", jpeg)].concat() +} + /// Huffman code lengths assigned to categories in decreasing frequency /// order. Kraft sum < 1 and no code is all ones. const LENGTHS: [u8; 17] = [2, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 8, 9, 10, 11, 12, 13]; diff --git a/crates/raw/tests/cr3.rs b/crates/raw/tests/cr3.rs new file mode 100644 index 0000000000..6907172460 --- /dev/null +++ b/crates/raw/tests/cr3.rs @@ -0,0 +1,119 @@ +//! CR3 preview extraction never mistakes encoded sensor data for a JPEG. +use photocraft_raw::testgen::cr3_preview; +use photocraft_raw::{RawFormat, embedded_preview, embedded_previews, identify, orientation}; + +fn jpeg() -> Vec { + vec![0xff, 0xd8, 0xff, 0xc0, 0, 11, 8, 0, 20, 0, 32, 1, 1, 0x11, 0, 0xff, 0xd9] +} + +fn position(b: &[u8], marker: &[u8]) -> usize { + b.windows(marker.len()).position(|w| w == marker).unwrap() +} + +#[test] +fn full_size_jpeg_and_orientation_are_read_from_their_boxes() { + let j = jpeg(); + let b = cr3_preview(&j, 6); + assert_eq!(identify(&b), Some(RawFormat::Cr3)); + let p = embedded_preview(&b).unwrap(); + assert_eq!((p.width, p.height), (32, 20)); + assert_eq!(p.jpeg, j); + assert_eq!(embedded_previews(&b).len(), 1); + assert_eq!(orientation(&b), Some(6)); +} + +#[test] +fn variable_sizes_and_32_bit_chunk_offsets_work() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"stsz") + 8; + // Add a single variable-size entry and repair ancestor lengths/offset. + let len = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].fill(0); + b.splice(at + 8..at + 8, len.to_be_bytes()); + for marker in [b"moov", b"trak", b"mdia", b"minf", b"stbl", b"stsz"] { + let at = position(&b, marker) - 4; + let n = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()); + b[at..at + 4].copy_from_slice(&(n + 4).to_be_bytes()); + } + let at = position(&b, b"co64"); + let off = u64::from_be_bytes(b[at + 12..at + 20].try_into().unwrap()) + 4; + b[at..at + 4].copy_from_slice(b"stco"); + b[at + 12..at + 16].copy_from_slice(&(off as u32).to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn broken_tables_and_raw_codec_tracks_are_not_used() { + let good = cr3_preview(&jpeg(), 1); + for marker in [b"stsz", b"co64", b"stsc", b"JPEG"] { + let mut b = good.clone(); + let at = position(&b, marker); + b[at..at + 4].copy_from_slice(b"xxxx"); + assert!(embedded_preview(&b).is_none()); + } + let mut b = good.clone(); + let at = position(&b, b"co64") + 12; + b[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); + let mut b = good; + let at = position(&b, b"stsz") + 12; + b[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(embedded_preview(&b).is_none()); +} + +#[test] +fn truncations_and_corrupted_box_lengths_never_panic() { + let b = cr3_preview(&jpeg(), 8); + for n in 0..b.len() { + let _ = embedded_previews(&b[..n]); + let _ = orientation(&b[..n]); + } + for marker in [b"ftyp", b"moov", b"trak", b"mdat"] { + for len in [0u32, 1, 2, 7, u32::MAX] { + let mut corrupt = b.clone(); + let at = position(&b, marker) - 4; + corrupt[at..at + 4].copy_from_slice(&len.to_be_bytes()); + let _ = embedded_previews(&corrupt); + } + } +} + +#[test] +fn extended_mdat_length_is_supported() { + let mut b = cr3_preview(&jpeg(), 1); + let at = position(&b, b"mdat") - 4; + let size = u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) as u64 + 8; + b[at..at + 4].copy_from_slice(&1u32.to_be_bytes()); + b.splice(at + 8..at + 8, size.to_be_bytes()); + let off = position(&b, b"co64") + 12; + let n = u64::from_be_bytes(b[off..off + 8].try_into().unwrap()) + 8; + b[off..off + 8].copy_from_slice(&n.to_be_bytes()); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); +} + +#[test] +fn prvw_is_used_when_no_jpeg_track_exists() { + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + [(body.len() as u32 + 8).to_be_bytes().as_slice(), kind, body].concat() + } + let ftyp = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut body = vec![0; 12]; + body.extend_from_slice(&(jpeg().len() as u32).to_be_bytes()); + body.extend(jpeg()); + let mut uuid = vec![0xea, 0xf4, 0x2b, 0x5e, 0x1c, 0x98, 0x4b, 0x88, 0xb9, 0xfb, 0xb7, 0xdc, 0x40, 0x6e, 0x4d, 0x16]; + uuid.extend([0; 8]); + uuid.extend(bx(b"PRVW", &body)); + let b = [ftyp, bx(b"uuid", &uuid)].concat(); + assert_eq!(embedded_preview(&b).unwrap().jpeg, jpeg()); + assert_eq!(orientation(&b), None); +} + +#[test] +fn deeply_nested_containers_are_bounded() { + let mut b = vec![0; 8]; + for _ in 0..1000 { + b = [((b.len() + 8) as u32).to_be_bytes().as_slice(), b"moov", &b].concat(); + } + let ftyp = [24u32.to_be_bytes().as_slice(), b"ftypcrx \0\0\0\x01crx isom"].concat(); + assert!(embedded_previews(&[ftyp, b].concat()).is_empty()); +} From 964f9fb9d09c20937280df7d0dac4186d5564a44 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:35:21 +1100 Subject: [PATCH 325/523] Sample every visible layer in Content-Aware Fill by default. (#4133) * Sample every visible layer in Content-Aware Fill by default. An empty layer above the photo used to report a successful fill and stay empty, because only the active layer was sampled. The result is still written to the active layer. sampleAllLayers can be set to false to keep the old behaviour. Fixes #4026 * Sample other layers in Content-Aware Fill only when asked or the layer is empty. Reuse the Remove tool's composite, which writes the active layer's format, and leave Photoshop's Sample All Layers checkbox off unless the window has no pixels on this layer. An explicit false still reads only this layer. --------- Co-authored-by: Brandon Thomas --- crates/engine/src/edit_menu_cmds.rs | 22 ++++++++++++++-- crates/engine/src/edit_menu_cmds/tests.rs | 32 +++++++++++++++++++++++ crates/engine/src/retouch_cmds.rs | 4 +-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 734f7a1b52..f798b6e4af 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -391,6 +391,13 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & } let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; let fmt = surf.format(); + // Photoshop's Sample All Layers checkbox is off unless the caller asks. When the active + // layer has no pixels in the window there is nothing to sample, so that case uses the + // visible composite (#4026). An explicit false still reads only this layer. + let sample_all = match p.get("sampleAllLayers").and_then(Value::as_bool) { + Some(v) => v, + None => surf.content_bounds().intersect(&window).is_empty(), + }; let n = fmt.channels(); // Refuse a window too large to read before allocating for it, whatever the sampling (#963). let (w, h) = crate::fill_cmds::window_size(window)?; @@ -407,7 +414,18 @@ fn content_aware_fill_as(s: &mut Session, p: &Value, cmd: &'static str, label: & ctx.progress(0.0, label); let cancel = || ctx.cancelled(); let surf = doc.layer(id).and_then(|l| l.surface()).ok_or(EngineError::NoLayer(id))?; - let img = read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)?; + let img = if sample_all { + if cancel() { + return Err(EngineError::Cancelled); + } + let region = crate::retouch_cmds::composite_region(&doc, None, crate::retouch_cmds::SampleLayers::All, window, fmt); + if cancel() { + return Err(EngineError::Cancelled); + } + region.data + } else { + read_window(surf, window, &cancel).ok_or(EngineError::Cancelled)? + }; let hole = read_window_cover(&sel, window, &cancel).ok_or(EngineError::Cancelled)?; let mut source = match (sampling.as_str(), custom_rect, custom_mask.as_ref()) { ("custom", Some(r), _) => (0..w * h).into_par_iter().map(|i| r.contains(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32)).collect(), @@ -1139,7 +1157,7 @@ pub fn specs() -> Vec { "Content-Aware Fill…", ["Edit"], None, - r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, + r##"{"sampling":"auto|rectangular|custom","margin":px?,"area":[x,y,w,h]?,"channel":index|name?,"colorAdaptation":"default|none|high|veryHigh","rotationAdaptation":"none|low|medium|high|full","scale":bool=false,"mirror":bool=false,"sampleAllLayers":bool=false,"output":"current|new|duplicate","seed":u64=1}"##, can_caf, content_aware_fill ), diff --git a/crates/engine/src/edit_menu_cmds/tests.rs b/crates/engine/src/edit_menu_cmds/tests.rs index 1bede87251..624a236200 100644 --- a/crates/engine/src/edit_menu_cmds/tests.rs +++ b/crates/engine/src/edit_menu_cmds/tests.rs @@ -218,6 +218,38 @@ fn delete_and_fill_selection_removes_the_object_in_one_step() { assert!(s.execute("edit.deleteAndFillSelection", json!({})).is_err()); } +#[test] +fn content_aware_fill_on_an_empty_layer_samples_the_composite() { + let mut s = blob_session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + let empty = s.active().unwrap().active_layer.unwrap(); + assert_eq!(px(&s, 30, 20)[3], 0.0, "the new layer has no paint"); + let r = s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + assert!(r["filled"].as_u64().unwrap() > 0); + assert_eq!(s.active().unwrap().active_layer, Some(empty)); + assert!(px(&s, 30, 20)[3] > 0.5, "the hole is filled from the layers underneath"); + s.undo(); + let missed = s.execute("edit.contentAwareFill", json!({"sampleAllLayers": false, "colorAdaptation": "none"})).unwrap(); + assert!(missed["filled"].as_u64().unwrap() > 0); + assert!(px(&s, 30, 20)[3] < 0.05, "sampling only the empty layer leaves it empty"); +} + +#[test] +fn content_aware_fill_on_an_empty_cmyk_layer_uses_the_layer_format() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 48, "height": 36, "mode": "cmyk", "background": "white"})).unwrap(); + s.execute("select.rect", json!({"x": 16, "y": 12, "width": 12, "height": 10})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let before = px(&s, 20, 16); + assert!(before.iter().all(|c| *c < 0.05), "the new CMYK layer is empty: {before:?}"); + s.execute("edit.contentAwareFill", json!({"colorAdaptation": "none"})).unwrap(); + let v = px(&s, 20, 16); + assert_eq!(v.len(), before.len()); + // White in the layer's CMYK is near zero ink with a covered alpha, not the compositor's RGBA. + assert!(v[..4].iter().all(|c| *c < 0.15), "CMYK ink stayed low: {v:?}"); + assert!(v.last().copied().unwrap_or(0.0) > 0.5, "the hole is covered: {v:?}"); +} + #[test] fn content_aware_fill_samples_photoshops_window() { // A 50 px selection samples the 200 px square centred on it (Photoshop 25.4's overlay); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 315a8d3929..c25608bb2e 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -146,7 +146,7 @@ fn damage_json(r: Rect) -> Value { /// Which pixels Clone Stamp / Healing Brush sample. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SampleLayers { +pub(crate) enum SampleLayers { Current, CurrentAndBelow, All, @@ -163,7 +163,7 @@ fn sample_layers(p: &Value, cmd: &str) -> Result { /// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer /// and everything below it. -fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { +pub(crate) fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { let mut doc = pre.clone(); if which == SampleLayers::CurrentAndBelow && let Some(id) = id From 13a16a695b1069558c5ca58f5ff340a54993174b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Tue, 13 Oct 2026 14:35:35 +1100 Subject: [PATCH 326/523] Resize the Liquify brush with Alt+right-drag instead of painting. (#4707) The preview treated every drag, including Alt plus the right button, as a stroke. That gesture now sets the diameter and density, and a plain left drag still paints. Fixes #4702. Co-authored-by: Brandon Thomas --- crates/ui-egui/src/liquify_ui.rs | 53 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/liquify_ui.rs b/crates/ui-egui/src/liquify_ui.rs index 0bc4cbd782..c5a04556af 100644 --- a/crates/ui-egui/src/liquify_ui.rs +++ b/crates/ui-egui/src/liquify_ui.rs @@ -151,6 +151,8 @@ pub struct LiquifyDialog { zoom: f32, center: [f64; 2], hover: Option<[f64; 2]>, + /// Alt+right-drag (or Control+Alt-drag) resizing the brush: screen anchor, starting size, starting density. + brush_resize: Option<(Pos2, f32, f32)>, last_dab: f64, /// Last dab + proxy update time (ms), shown in the footer. pub dab_ms: f64, @@ -482,6 +484,7 @@ pub fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> zoom: 0.0, center, hover: None, + brush_resize: None, last_dab: 0.0, dab_ms: 0.0, preview_ms: 0.0, @@ -569,6 +572,21 @@ fn alt_tool(t: LiquifyTool) -> LiquifyTool { } } +/// Alt+right-drag, and Control+Alt-drag, change the Liquify brush instead of painting (#4702). +fn brush_resize_gesture(mods: egui::Modifiers, secondary: bool) -> bool { + (secondary && mods.alt) || (mods.ctrl && mods.alt && !secondary) +} + +/// Horizontal motion sets the diameter (the circle's edge follows the pointer). Vertical motion +/// sets density, Liquify's edge falloff, over the same 200 px travel the canvas uses for hardness. +fn resized_brush(start_size: f32, start_density: f32, dx: f32, dy: f32) -> (f32, f32) { + let dx = if dx.is_finite() { dx } else { 0.0 }; + let dy = if dy.is_finite() { dy } else { 0.0 }; + let size = (start_size + dx).round().clamp(1.0, 15000.0); + let density = (start_density + dy * 100.0 / crate::brush_resize::HARDNESS_TRAVEL).clamp(0.0, 100.0); + (size, (density * 10.0).round() / 10.0) +} + /// Pointer in document coordinates (from the preview or the control channel). Alt at /// pointer-down subtracts from the freeze mask (lasso), reverses a twirl and swaps Pucker/Bloat; /// Shift adds to the mask, same as no modifier. @@ -912,9 +930,26 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { d.hover = Some(to_doc(d, area, hp)); } clip.rect_stroke(img, 0.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Outside); - // Mouse → strokes (Space or middle button pans). - let pan = ui.input(|i| i.key_down(egui::Key::Space) || i.pointer.middle_down()); - if pan && resp.dragged() { + // Mouse → strokes (Space or middle button pans). Alt+right-drag resizes the brush (#4702). + let (pan, secondary, mods_now) = + ui.input(|i| (i.key_down(egui::Key::Space) || i.pointer.middle_down(), i.pointer.button_down(egui::PointerButton::Secondary), i.modifiers)); + let resize = d.brush_resize.is_some() || (resp.drag_started() && brush_resize_gesture(mods_now, secondary)); + if resize { + if d.brush_resize.is_none() + && let Some(p) = resp.interact_pointer_pos() + { + d.brush_resize = Some((p, d.opts.size, d.opts.density)); + } + if let (Some((anchor, size0, dens0)), Some(p)) = (d.brush_resize, resp.interact_pointer_pos()) { + let delta = p - anchor; + let (size, density) = resized_brush(size0, dens0, delta.x, delta.y); + d.opts.size = size; + d.opts.density = density; + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + d.brush_resize = None; + } + } else if pan && resp.dragged() { let dl = resp.drag_delta(); d.center = [d.center[0] - f64::from(dl.x / d.zoom), d.center[1] - f64::from(dl.y / d.zoom)]; } else if let Some(pp) = resp.interact_pointer_pos() { @@ -1020,6 +1055,18 @@ fn draw_mesh(d: &LiquifyDialog, painter: &egui::Painter, area: ERect) { mod tests { use super::*; + #[test] + fn alt_right_drag_resizes_the_liquify_brush_and_does_not_paint() { + assert!(brush_resize_gesture(egui::Modifiers::ALT, true)); + assert!(brush_resize_gesture(egui::Modifiers::CTRL | egui::Modifiers::ALT, false)); + assert!(!brush_resize_gesture(egui::Modifiers::ALT, false), "Alt+left stays the tool's opposite"); + assert!(!brush_resize_gesture(egui::Modifiers::NONE, true)); + let (size, density) = resized_brush(40.0, 50.0, 25.0, crate::brush_resize::HARDNESS_TRAVEL); + assert_eq!((size, density), (65.0, 100.0)); + let (size, density) = resized_brush(40.0, 50.0, -100.0, 0.0); + assert_eq!((size, density), (1.0, 50.0)); + } + fn app_with_layer() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.session.execute("file.new", json!({"width": 120, "height": 80, "depth": 8})).unwrap(); From 71968f0806074e3b030f46badf62938879c3f10e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:38:14 +0200 Subject: [PATCH 327/523] Free Transform: the live preview feathers a linked layer mask (part of #4560) (#4581) Co-authored-by: Brandon Thomas --- crates/ui-egui/src/transform_tool.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index b2ff21d531..dafe4dfe3c 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -801,7 +801,7 @@ fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option